├── .browserslistrc ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── question.md └── workflows │ ├── deploy.yml │ └── test.yml ├── .gitignore ├── .npmrc ├── .prettierrc.js ├── CHANGELOG.md ├── LICENSE ├── README.md ├── README_EN.md ├── babel.config.js ├── docs ├── assets │ ├── index-BcUbPbUa.css │ └── index-Cp_mucJn.js └── index.html ├── examples ├── App.vue ├── Drag.vue ├── Drop.vue ├── DropDataChange.vue ├── DropRemote.vue ├── Feature.vue ├── InsertRenderTree.vue ├── Loading.vue ├── Mobile.vue ├── Performance.vue ├── Search.vue ├── SearchRemote.vue ├── SearchRootRemote.vue ├── app.css ├── env.d.ts ├── main.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── index.html ├── markdown ├── design-tree-search.md └── design-tree.md ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── site ├── .vitepress │ ├── code │ │ ├── ActionsSlot.vue │ │ ├── BasicDrop.vue │ │ ├── Cascade.vue │ │ ├── Checkable.vue │ │ ├── CustomDropDisplay.vue │ │ ├── CustomDropInput.vue │ │ ├── CustomNode.vue │ │ ├── DataDisplay.vue │ │ ├── DragAndDrop.vue │ │ ├── ExpandAnimation.vue │ │ ├── IgnoreMode.vue │ │ ├── LocalSearch.vue │ │ ├── NodeCreationAndRemoval.vue │ │ ├── Performance.vue │ │ ├── ReloadChildren.vue │ │ ├── Remote.vue │ │ ├── RemoteSearch.vue │ │ ├── Selectable.vue │ │ ├── SelectableAndCheckable.vue │ │ ├── ShowLine.vue │ │ ├── UpdateCustomField.vue │ │ └── UpdateNodeTitle.vue │ ├── components │ │ ├── DemoRender.vue │ │ ├── Playground.vue │ │ ├── PlaygroundLink.vue │ │ ├── VersionSelect.vue │ │ └── code-demo.md │ ├── config.mts │ ├── constants │ │ └── i18n.ts │ ├── data │ │ └── code.data.ts │ ├── en.mts │ ├── theme │ │ └── index.ts │ ├── utils │ │ └── i18n.ts │ └── zh.mts ├── api │ ├── vtree-drop.md │ ├── vtree-search.md │ └── vtree.md ├── en │ ├── api │ │ ├── vtree-drop.md │ │ ├── vtree-search.md │ │ └── vtree.md │ ├── examples │ │ ├── node-manipulation.md │ │ ├── performance.md │ │ ├── tree-drop.md │ │ ├── tree-search.md │ │ └── tree.md │ ├── guide │ │ ├── getting-started.md │ │ └── migration.md │ └── index.md ├── examples │ ├── node-manipulation.md │ ├── performance.md │ ├── tree-drop.md │ ├── tree-search.md │ └── tree.md ├── guide │ ├── getting-started.md │ └── migration.md ├── index.md ├── package.json ├── playground.md └── vite.config.ts ├── src ├── components │ ├── LoadingIcon.vue │ ├── Tree.vue │ ├── TreeDrop.vue │ ├── TreeNode.vue │ └── TreeSearch.vue ├── constants │ ├── events.ts │ └── index.ts ├── env.d.ts ├── hooks │ ├── useExpandAnimation.ts │ ├── useIframeResize.ts │ ├── usePublicTreeAPI.ts │ ├── useTreeCls.ts │ ├── useTreeDropCls.ts │ ├── useTreeNodeCls.ts │ ├── useTreeSearchCls.ts │ └── useVirtualList.ts ├── index.ts ├── store │ ├── index.ts │ ├── tree-event-target.ts │ ├── tree-node.ts │ └── tree-store.ts ├── styles │ ├── index.less │ ├── loading-icon.less │ ├── tree-drop.less │ ├── tree-search.less │ ├── tree.less │ └── variables.less ├── types │ └── index.ts ├── utils.ts └── vite-env.d.ts ├── tests ├── tree-data-generator.ts └── unit │ ├── tree-search.spec.ts │ └── tree.spec.ts ├── tsconfig.json ├── tsconfig.node.json ├── tsconfig.tsbuildinfo ├── tslint.json ├── types ├── components │ ├── LoadingIcon.vue.d.ts │ ├── Tree.vue.d.ts │ ├── TreeDrop.vue.d.ts │ ├── TreeNode.vue.d.ts │ └── TreeSearch.vue.d.ts ├── const.d.ts ├── constants │ ├── events.d.ts │ └── index.d.ts ├── hooks │ ├── useExpandAnimation.d.ts │ ├── useIframeResize.d.ts │ ├── usePublicTreeAPI.d.ts │ ├── useTreeCls.d.ts │ ├── useTreeDropCls.d.ts │ ├── useTreeNodeCls.d.ts │ ├── useTreeSearchCls.d.ts │ └── useVirtualList.d.ts ├── index.d.ts ├── store │ ├── index.d.ts │ ├── tree-event-target.d.ts │ ├── tree-node.d.ts │ └── tree-store.d.ts ├── types.d.ts ├── types │ └── index.d.ts └── utils.d.ts ├── vite.config.ts └── vitest.config.ts /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not ie <= 8 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Bug 描述** 11 | 请清晰描述 Bug 情况 12 | 13 | **复现步骤** 14 | 请描述复现步骤,并且提供最小可复现示例(CodeSandbox, CodePen 链接等) 15 | 16 | 17 | 18 | **期望表现** 19 | 20 | 21 | **实际表现** 22 | 23 | 24 | **组件版本** 25 | - Vue: 26 | - @wsfe/ctree: 27 | - @wsfe/vue-tree: 28 | - 其他可帮助复现的 npm 包名称及版本 29 | 30 | **额外信息** 31 | 其他要补充的信息 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Ask questions 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **问题描述** 11 | 想咨询的问题 12 | 13 | **组件版本** 14 | - Vue2 或 Vue3 15 | - @wsfe/ctree 或者 @wsfe/vue-tree 16 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | # 构建 VitePress 站点并将其部署到 GitHub Pages 的示例工作流程 2 | # 3 | name: Deploy VitePress site to Pages 4 | 5 | on: 6 | # 在针对 `main` 分支的推送上运行。如果你 7 | # 使用 `master` 分支作为默认分支,请将其更改为 `master` 8 | push: 9 | branches: [dev] 10 | 11 | # 允许你从 Actions 选项卡手动运行此工作流程 12 | workflow_dispatch: 13 | 14 | # 设置 GITHUB_TOKEN 的权限,以允许部署到 GitHub Pages 15 | permissions: 16 | contents: read 17 | pages: write 18 | id-token: write 19 | 20 | # 只允许同时进行一次部署,跳过正在运行和最新队列之间的运行队列 21 | # 但是,不要取消正在进行的运行,因为我们希望允许这些生产部署完成 22 | concurrency: 23 | group: pages 24 | cancel-in-progress: false 25 | 26 | jobs: 27 | # 构建工作 28 | build: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v4 33 | with: 34 | fetch-depth: 0 # 如果未启用 lastUpdated,则不需要 35 | - uses: pnpm/action-setup@v3 # 如果使用 pnpm,请取消注释 36 | with: 37 | version: 8 38 | # - uses: oven-sh/setup-bun@v1 # 如果使用 Bun,请取消注释 39 | - name: Setup Node 40 | uses: actions/setup-node@v4 41 | with: 42 | node-version: 20 43 | cache: pnpm # 或 pnpm / yarn 44 | - name: Setup Pages 45 | uses: actions/configure-pages@v4 46 | - name: Install dependencies 47 | run: pnpm install # 或 pnpm install / yarn install / bun install 48 | - name: Build with VitePress 49 | run: pnpm docs:build # 或 pnpm docs:build / yarn docs:build / bun run docs:build 50 | - name: Upload artifact 51 | uses: actions/upload-pages-artifact@v3 52 | with: 53 | path: site/.vitepress/dist 54 | 55 | # 部署工作 56 | deploy: 57 | environment: 58 | name: github-pages 59 | url: ${{ steps.deployment.outputs.page_url }} 60 | needs: build 61 | runs-on: ubuntu-latest 62 | name: Deploy 63 | steps: 64 | - name: Deploy to GitHub Pages 65 | id: deployment 66 | uses: actions/deploy-pages@v4 67 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Test 5 | 6 | on: 7 | push: 8 | branches: [ "dev" ] 9 | pull_request: 10 | branches: [ "dev" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [16.x, 18.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: pnpm/action-setup@v3 25 | with: 26 | version: 8 27 | - name: Use Node.js ${{ matrix.node-version }} 28 | uses: actions/setup-node@v3 29 | with: 30 | node-version: ${{ matrix.node-version }} 31 | cache: 'pnpm' 32 | - run: pnpm install --frozen-lockfile 33 | - run: pnpm run test:ci 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | debug.log 2 | .DS_Store 3 | node_modules 4 | /dist 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | 15 | # Editor directories and files 16 | .idea 17 | .vscode 18 | *.suo 19 | *.ntvs* 20 | *.njsproj 21 | *.sln 22 | *.sw* 23 | 24 | # Vitepress 25 | site/.vitepress/dist 26 | site/.vitepress/cache 27 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # shamefully-hoist = true 2 | # strict-peer-dependencies=true 3 | # legacy-peer-deps=true 4 | # dedupe-peer-dependents=false -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | bracketSpacing: true, // {abc: 'aa'} 会被格式化成 { abc: 'aa' } 3 | singleQuote: true, // 单引号 4 | arrowParens: 'avoid', // (x) => x 变成 x => x 5 | trailingComma: 'none', // 不需要尾部逗号 6 | semi: false // 分号结尾 7 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 ChuChencheng 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vue-Tree 4.x 2 | 3 | 简体中文 | [English](https://github.com/wsfe/vue-tree/blob/dev/README_EN.md) 4 | 5 | [接口文档与在线示例](https://wsfe.github.io/vue-tree/) 6 | 7 | 一款高性能 Vue3 虚拟树控件,支持搜索,定位,拖拽等。该控件是在公司业务的基础上不断打磨出来的,提供了十分丰富强大的 API,几乎能够满足你对树控件的所有需求。 8 | 9 | Vue2 版本树组件请使用 [`@wsfe/ctree`](https://github.com/wsfe/vue-tree/tree/2.x) 10 | -------------------------------------------------------------------------------- /README_EN.md: -------------------------------------------------------------------------------- 1 | # Vue-Tree 4.x 2 | 3 | [简体中文](https://github.com/wsfe/vue-tree) | English 4 | 5 | [API Document & Online Demo](https://wsfe.github.io/vue-tree/en/) 6 | 7 | A high performance Vue3 tree component optimized using virtual list. It supports searching, node locating, drag-and-drop, etc. This component is built based on business, providing rich and powerful APIs which can meet your various needs for a tree component. 8 | 9 | For Vue2 users, please use [`@wsfe/ctree`](https://github.com/wsfe/vue-tree/tree/2.x) (Chinese doc only) 10 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', {targets: {node: 'current'}}], 4 | '@babel/preset-typescript', 5 | [ 6 | '@vue/app', 7 | { 8 | 'useBuiltIns': false 9 | } 10 | ] 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | vue tree 8 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | -------------------------------------------------------------------------------- /examples/App.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 70 | 71 | 102 | -------------------------------------------------------------------------------- /examples/Drag.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 56 | -------------------------------------------------------------------------------- /examples/Drop.vue: -------------------------------------------------------------------------------- 1 | 63 | 64 | 118 | -------------------------------------------------------------------------------- /examples/DropDataChange.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 59 | -------------------------------------------------------------------------------- /examples/DropRemote.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 124 | -------------------------------------------------------------------------------- /examples/InsertRenderTree.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 43 | -------------------------------------------------------------------------------- /examples/Loading.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 47 | -------------------------------------------------------------------------------- /examples/Mobile.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 102 | 103 | -------------------------------------------------------------------------------- /examples/Search.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 57 | -------------------------------------------------------------------------------- /examples/SearchRemote.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 59 | -------------------------------------------------------------------------------- /examples/SearchRootRemote.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 65 | -------------------------------------------------------------------------------- /examples/app.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | width: 100%; 3 | height: 100%; 4 | margin: 0; 5 | padding: 0; 6 | } 7 | -------------------------------------------------------------------------------- /examples/env.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import type { defineComponent } from 'vue' 3 | const Component: ReturnType 4 | export default Component 5 | } 6 | -------------------------------------------------------------------------------- /examples/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | import './app.css' 4 | 5 | const app = createApp(App) 6 | app.mount('#app') 7 | -------------------------------------------------------------------------------- /examples/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "moduleResolution": "Node", 7 | "strict": true, 8 | "jsx": "preserve", 9 | "resolveJsonModule": true, 10 | "isolatedModules": true, 11 | "esModuleInterop": true, 12 | "lib": ["ESNext", "DOM"], 13 | "skipLibCheck": true 14 | }, 15 | "include": [ 16 | "examples/**/*.ts", 17 | "examples/**/*.d.ts", 18 | "examples/**/*.tsx", 19 | "examples/**/*.vue" 20 | ], 21 | "references": [ 22 | { "path": "../tsconfig.json" }, 23 | { "path": "./tsconfig.node.json" } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /examples/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /examples/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig(({ mode }) => { 6 | return { 7 | base: './', 8 | plugins: [vue()], 9 | build: { 10 | outDir: 'docs' 11 | } 12 | } 13 | }) 14 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | vue tree 8 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /markdown/design-tree-search.md: -------------------------------------------------------------------------------- 1 | # 界面结构 2 | 3 | 1. 整体分为上下两部分,上部分为搜索、操作区域;下部分为 Tree 4 | 2. 上部分水平分为三块,分别是:全选勾选框(可能不可见)、搜索输入框、操作按钮(可能不可见) 5 | 3. 下部分为一个高度限定、宽度 100% 的树 6 | -------------------------------------------------------------------------------- /markdown/design-tree.md: -------------------------------------------------------------------------------- 1 | # 界面结构 2 | 3 | 节点:从左到右分别为 `展开图标`, `复选框`, `标题` 4 | 5 | ``` 6 | > 口 title 7 | ``` 8 | 9 | 其中展开图标与复选框等宽,分别占用一个正方形位置(宽高相等),并且垂直居中。 10 | 11 | 展开图标位置如果是父节点则显示图标,非父节点则不显示图标但有占位。 12 | 13 | 复选框在非多选状态下不显示且不占位。 14 | 15 | 整个节点宽度需大于等于三个正方形宽度,高度需大于等于一个正方形宽度。 16 | 17 | 整棵树: 18 | 19 | ``` 20 | 口 parent-1 21 | > 口 parent-2 22 | 口 child 23 | ``` 24 | 25 | 子节点在父节点下方,且相对父节点向后推移一个正方形宽度的距离。 26 | 27 | 整棵树宽高由外层容器控制 28 | 29 | 需要设置一个高度,以实现大量数据的高性能展示。 30 | 31 | (* 如果不设置高度,可以考虑以屏幕高度为最大高度) 32 | 33 | 树整体结构解析: 34 | 35 | 最外层可滚动部分:ScollArea 36 | 37 | 内层为 Block 包括:TopSpace, RenderArea, BottomSpace 38 | 39 | # 功能 40 | 41 | ### 普通树结构 `VTree` 组件 42 | 43 | #### 树查看 44 | 45 | - 可查看展示树结构 46 | - 父节点可展开 47 | 48 | #### 树单选 49 | 50 | - 点击未选中节点则选中该节点 51 | - 点击选中节点则取消选中该节点 52 | 53 | #### 树多选 54 | 55 | - 树标题前面有复选框 56 | - 点击复选框或者标题可选中该节点 57 | - 再次点击则取消选中 58 | - 选中父节点则下属子节点均选中(反之均取消选中) 59 | - 可选父子是否关联,不关联则上一条无效 60 | 61 | #### 节点功能 62 | 63 | - 如果是父节点,最前面有展开图标 64 | - 多选模式下展开图标后面为复选框 65 | - 再后面为节点标题,开发者可自定义标题内容 66 | - 单选选中状态下标题背景高亮 67 | - 多选选中状态下复选框为勾选状态 68 | - 可禁用节点,禁用时复选框、标题均不可点击 69 | - 节点可拖拽 70 | 71 | # 思路 72 | 73 | 考虑到性能以及数据量问题,组件需要尽可能减少递归、搜索次数、DOM复杂度等。大致上是用空间去换取时间,但在内存占用上也要有所控制,尽可能避免 Vue 监听不必要的数据。 74 | 75 | 大方向: 76 | 77 | - DOM 数量以及深度限制 78 | - DOM 扁平化 79 | - 数据扁平化 80 | 81 | 第一条其实就是 clusterize 的思想,只渲染看得见的几条数据,上下用空白元素填充,在数据量大的情况下可以有效减少 DOM 的数量,减少页面卡顿的可能。 DOM 深度的限制则需要对树节点的设计进行优化,尽可能保持简洁,删除不必要的动画、类、样式,减少重绘。 82 | 83 | 后两者实际上是相辅相成的,在数据上实现了扁平化,DOM 自然也会使用扁平化的结构 84 | 85 | # 数据结构 86 | 87 | 外部传入的数据就是一棵树的数据结构,这点基本上无法改变,除非特殊要求传入扁平化后的数据。 88 | 89 | 首先要对传入的树结构进行拍平。 90 | 91 | 在这个过程中需要引入 `level` 字段标明每个节点的层级关系,使用 `parent`, `children` 字段存储每个节点的上下层级关系。 92 | 93 | 同时最好建立一个 hash 表,以唯一的 id 作为键值,可快速定位某个节点。 94 | 95 | 如果是进行树搜索,可考虑以其他结构存储一份数据,加快搜索速度。(或许会比较占用空间,可让开发者决定是否建立这份数据。) 96 | 97 | # 数据流 98 | 99 | 1. 100 | 101 | 外部修改 -> VTree -> TreeStore (-> TreeNode) -> (事件等)通知 VTree -> VTreeNode 视图改变 102 | 103 | 2. 104 | 105 | VTreeNode -> VTree -> TreeStore (-> TreeNode) -> (事件等)通知 VTree -> VTreeNode 视图改变 106 | 107 | # 选中实现流程 108 | 109 | 初始化时,首先执行 `new TreeNode` ,待所有节点都实例化后,进行拍平。 110 | 111 | 1. 拍平期间,进行数据上的勾选,例如对 `checked: true` 的节点执行 `setChecked` 操作 112 | 2. 由于数据源勾选数据与 `value` 勾选的数据不一定是一致的,因此在拍平结束后,针对 `value` 的数据进行勾选 113 | 3. 勾选完 `value` 后,将数据源的勾选与 `value` 的勾选合并,通过 `input` 事件传出去同步 `value` 的值 114 | 4. 设置数据源时与初始化无异,也是执行前三个步骤 115 | 5. `value` 改变时,也可能导致最终选中的 key 不一致(因为开发者可能设置了 ignoreMode,或者勾选的是个父节点),因此也需要触发 `input` 事件 116 | 117 | # value 与 data 改变逻辑 118 | 119 | ## 多选 120 | 121 | 1. 一开始进来时, value 的值全都当做未加载的选中节点,在执行拍平数据后(data 未改变时不执行拍平),遍历 value 数据,将树数据中存在的节点 checked 置为 true 122 | 2. 因为树数据上也可能存在初始就是 checked true 的节点(初始化情况),或者选中的节点与父子节点有级联选中关系(value 改变情况),因此触发一次自定义的 `checked-change` 事件通知 VTree 把最终勾选的结果通过 input 事件同步到 value 123 | 3. value 改变时,先清空当前选中的节点(包括加载与未加载的选中节点),然后重新把 value 的值设置为未加载的选中节点,重复1、2步骤 124 | 4. data 改变时,重复1、2步骤 125 | 126 | ## 单选 127 | 128 | 1. 与多选一样,一开始进来时, value 值当做未加载的选中节点,数据初始化、拍平后,将此节点设置为 selected true ,树数据上设置的 selected true 会被 value 覆盖 129 | 2. 因为单选的 value 有的话一定只有一个,因此不需要触发 input 事件去同步 value 的值 130 | 3. value 改变时,执行选中 value 的值(如果树数据中存在,则直接执行选中,不存在则记录为未加载的选中节点) 131 | 4. data 改变时,重复1、2步骤 132 | 5. 注意:后续异步加载的子节点即使有 selected true 也不会再次覆盖 value 的值,除非 value 没有值 133 | 134 | # 树增删 API 135 | 136 | ```typescript 137 | interface IInsertRemoveAPI { 138 | insertBefore(insertedNode: TreeNodeKeyType | ITreeNodeOptions, referenceKey: TreeNodeKeyType): TreeNode 139 | insertAfter(insertedNode: TreeNodeKeyType | ITreeNodeOptions, referenceKey: TreeNodeKeyType): TreeNode 140 | append(newNodeData: ITreeNodeOptions, parentKey: TreeNodeKeyType): TreeNode 141 | prepend(newNodeData: ITreeNodeOptions, parentKey: TreeNodeKeyType): TreeNode 142 | remove(removedKey: TreeNodeKeyType): void 143 | } 144 | ``` 145 | 146 | ## 节点拖拽设计 147 | 148 | ### 节点 DOM 结构 149 | 150 | 在原来的节点前后加上两个 div 用于接收 drop 事件 151 | 152 | 这样,一个节点总体上就被分为三部分 153 | 154 | -节点前 155 | -节点本体 156 | -节点后 157 | 158 | ### 过程中涉及的变化 159 | 160 | 层级:需要更新节点以及其所有子节点的 _level 161 | 父节点:需要给 children 属性添加/删除相应节点 162 | 多选:更新节点后保持被移动节点状态,调用 checkNodeUpward 更新新的父节点状态 163 | 单选: 没有变化 164 | 165 | ### 步骤(用户拖动了节点并 drop): 166 | 167 | 1. 判断 dropzone 节点 drop 的位置是节点前后还是节点本体 168 | 2. 如果是节点前后,则相应调用 `insertBefore`, `insertAfter` 169 | 3. 如果是节点本体,则调用 `prepend` 170 | 171 | ### insertBefore insertAfter 172 | 173 | 步骤: 174 | 175 | 1. 声明变量存储原节点 176 | 2. 从 `flatData`, `mapData` 和 父节点的 `children` 中移除原节点,包括其子节点 177 | 3. 更新被移除处父节点的 `isLeaf` 和 `expand` 178 | 4. 更新被移除处父节点的选中状态 179 | 5. 更新原节点的 `_parent` 180 | 6. 更新原节点的 `_level` ,包括其子节点 181 | 7. 将原节点放置于新的位置(`flatData`, `mapData` 和 父节点的 `children`) 182 | 8. 更新新位置节点其父节点的 `isLeaf` ,并 setExpand 父节点 183 | 9. 更新新位置节点其父节点的选中状态 184 | 185 | 其中,2、3、4 步骤为 `remove` API 的内容,5-9 为 `insertIntoStore` API 的内容,但 `insertIntoStore` API 不打算公开 186 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@wsfe/vue-tree", 3 | "version": "4.1.1", 4 | "types": "./types", 5 | "description": "A vue tree component using virtual list.", 6 | "main": "./dist/vue-tree.umd.js", 7 | "module": "./dist/vue-tree.mjs", 8 | "exports": { 9 | ".": { 10 | "types": "./types/index.d.ts", 11 | "import": "./dist/vue-tree.mjs", 12 | "require": "./dist/vue-tree.umd.js" 13 | }, 14 | "./style.css": "./dist/style.css", 15 | "./*": "./*" 16 | }, 17 | "scripts": { 18 | "dev": "vite", 19 | "dts": "vue-tsc --declaration --emitDeclarationOnly", 20 | "build": "npm run dts && vite build", 21 | "docs": "vue-tsc --noEmit && vite build -c examples/vite.config.ts", 22 | "preview": "vite preview", 23 | "test": "vitest watch", 24 | "test:ci": "vitest run", 25 | "prettier": "prettier --write \"{src,examples,tests}/**/*.{ts,js,json,vue,tsx,less,scss,less,html}\" --fix", 26 | "prepublishOnly": "npm run build", 27 | "docs:dev": "vitepress dev site", 28 | "docs:build": "vitepress build site", 29 | "docs:preview": "vitepress preview site" 30 | }, 31 | "publishConfig": { 32 | "registry": "https://registry.npmjs.org/", 33 | "access": "public" 34 | }, 35 | "files": [ 36 | "dist", 37 | "src", 38 | "types" 39 | ], 40 | "author": { 41 | "name": "ChuChencheng", 42 | "url": "https://github.com/ChuChencheng" 43 | }, 44 | "keywords": [ 45 | "vue", 46 | "vue2", 47 | "vue3", 48 | "tree", 49 | "select", 50 | "tree select", 51 | "virtualtree", 52 | "virtual tree", 53 | "virtual-tree", 54 | "vue-tree", 55 | "vue tree", 56 | "vue tree component", 57 | "虚拟树" 58 | ], 59 | "homepage": "https://github.com/wsfe/vue-tree", 60 | "license": "MIT", 61 | "devDependencies": { 62 | "@babel/preset-env": "^7.24.7", 63 | "@babel/preset-typescript": "^7.24.7", 64 | "@faker-js/faker": "^8.4.1", 65 | "@vitejs/plugin-vue": "^5.0.5", 66 | "@vue/babel-preset-app": "^5.0.8", 67 | "@vue/repl": "^4.3.0", 68 | "@vue/test-utils": "^2.4.6", 69 | "@vue/vue3-jest": "^29.2.6", 70 | "autoprefixer": "^10.4.19", 71 | "happy-dom": "^14.12.0", 72 | "less": "^4.2.0", 73 | "postcss": "^8.4.38", 74 | "prettier": "^3.3.1", 75 | "typescript": "^5.4.5", 76 | "vite": "^5.2.13", 77 | "vitepress": "^1.2.3", 78 | "vitest": "^1.6.0", 79 | "vue": "^3.4.30", 80 | "vue-tsc": "^2.0.22" 81 | } 82 | } -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {} 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /site/.vitepress/code/ActionsSlot.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 101 | -------------------------------------------------------------------------------- /site/.vitepress/code/BasicDrop.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 84 | -------------------------------------------------------------------------------- /site/.vitepress/code/Cascade.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 105 | -------------------------------------------------------------------------------- /site/.vitepress/code/Checkable.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 87 | -------------------------------------------------------------------------------- /site/.vitepress/code/CustomDropDisplay.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 97 | -------------------------------------------------------------------------------- /site/.vitepress/code/CustomDropInput.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 93 | -------------------------------------------------------------------------------- /site/.vitepress/code/CustomNode.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 88 | -------------------------------------------------------------------------------- /site/.vitepress/code/DataDisplay.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 84 | -------------------------------------------------------------------------------- /site/.vitepress/code/DragAndDrop.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 84 | -------------------------------------------------------------------------------- /site/.vitepress/code/ExpandAnimation.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 84 | -------------------------------------------------------------------------------- /site/.vitepress/code/IgnoreMode.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 109 | -------------------------------------------------------------------------------- /site/.vitepress/code/LocalSearch.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 86 | -------------------------------------------------------------------------------- /site/.vitepress/code/NodeCreationAndRemoval.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 110 | 111 | 122 | -------------------------------------------------------------------------------- /site/.vitepress/code/Performance.vue: -------------------------------------------------------------------------------- 1 | 76 | 77 | 146 | 147 | 187 | 188 | 220 | -------------------------------------------------------------------------------- /site/.vitepress/code/ReloadChildren.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 63 | 64 | 73 | -------------------------------------------------------------------------------- /site/.vitepress/code/Remote.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 28 | -------------------------------------------------------------------------------- /site/.vitepress/code/RemoteSearch.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 39 | -------------------------------------------------------------------------------- /site/.vitepress/code/Selectable.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 87 | -------------------------------------------------------------------------------- /site/.vitepress/code/SelectableAndCheckable.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 87 | -------------------------------------------------------------------------------- /site/.vitepress/code/ShowLine.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 123 | -------------------------------------------------------------------------------- /site/.vitepress/code/UpdateCustomField.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 57 | 58 | 67 | -------------------------------------------------------------------------------- /site/.vitepress/code/UpdateNodeTitle.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 64 | 65 | 74 | -------------------------------------------------------------------------------- /site/.vitepress/components/DemoRender.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 24 | -------------------------------------------------------------------------------- /site/.vitepress/components/Playground.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 99 | 100 | 136 | -------------------------------------------------------------------------------- /site/.vitepress/components/PlaygroundLink.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 51 | -------------------------------------------------------------------------------- /site/.vitepress/components/VersionSelect.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 66 | 67 | 116 | -------------------------------------------------------------------------------- /site/.vitepress/components/code-demo.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 9 |
10 | 11 |
12 | 13 | ::: details {{i18n.showCode}} 14 |
15 | ::: 16 | 17 | 35 | -------------------------------------------------------------------------------- /site/.vitepress/config.mts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { defineConfig } from 'vitepress' 3 | import zh from './zh.mjs' 4 | import en from './en.mjs' 5 | 6 | // https://vitepress.dev/reference/site-config 7 | export default defineConfig({ 8 | base: '/vue-tree/', 9 | title: "Vue Tree", 10 | description: "Virtual list optimized Vue tree component", 11 | appearance: false, 12 | head: [ 13 | [ 14 | 'script', 15 | { async: '', src: 'https://www.googletagmanager.com/gtag/js?id=G-BBCLLNZQ2E' } 16 | ], 17 | [ 18 | 'script', 19 | {}, 20 | `window.dataLayer = window.dataLayer || []; 21 | function gtag(){dataLayer.push(arguments);} 22 | gtag('js', new Date()); 23 | gtag('config', 'G-BBCLLNZQ2E');` 24 | ] 25 | ], 26 | themeConfig: { 27 | // https://vitepress.dev/reference/default-theme-config 28 | search: { 29 | provider: 'local' 30 | }, 31 | socialLinks: [ 32 | { icon: 'github', link: 'https://github.com/wsfe/vue-tree' } 33 | ] 34 | }, 35 | vite: { 36 | resolve: { 37 | alias: { 38 | '@wsfe/vue-tree/style.css': path.resolve('src/styles/index.less'), 39 | '@wsfe/vue-tree': path.resolve('src'), 40 | }, 41 | }, 42 | }, 43 | 44 | locales: { 45 | root: { 46 | label: '简体中文', 47 | ...zh, 48 | }, 49 | en: { 50 | label: 'English', 51 | ...en, 52 | }, 53 | }, 54 | }) 55 | -------------------------------------------------------------------------------- /site/.vitepress/constants/i18n.ts: -------------------------------------------------------------------------------- 1 | export const codeDemoI18n = { 2 | root: { 3 | showCode: '查看代码', 4 | openInPlayground: '在 Playground 打开示例', 5 | }, 6 | en: { 7 | showCode: 'Show code', 8 | openInPlayground: 'Open example in Playground', 9 | }, 10 | } 11 | 12 | const themeConfigI18n = { 13 | zh: { 14 | guide: '指南', 15 | examples: '示例', 16 | api: '接口文档', 17 | 18 | gettingStarted: '快速开始', 19 | migration: '从旧版迁移', 20 | basicUsage: '基本用法', 21 | performance: '性能测试', 22 | nodeManipulation: '节点操作', 23 | treeSearch: '树搜索', 24 | treeDrop: '树下拉', 25 | }, 26 | en: { 27 | guide: 'Guide', 28 | examples: 'Examples', 29 | api: 'API Document', 30 | 31 | gettingStarted: 'Getting Started', 32 | migration: 'Migrate from Old Version', 33 | basicUsage: 'Basic Usage', 34 | performance: 'Performance', 35 | nodeManipulation: 'Node Manipulation', 36 | treeSearch: 'Tree Search', 37 | treeDrop: 'Tree Drop', 38 | }, 39 | } 40 | 41 | ;(themeConfigI18n as any).root = themeConfigI18n.zh 42 | 43 | export { themeConfigI18n } 44 | -------------------------------------------------------------------------------- /site/.vitepress/data/code.data.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises' 2 | import path from 'node:path' 3 | import { createMarkdownRenderer } from 'vitepress' 4 | 5 | export default { 6 | async load() { 7 | const md = await createMarkdownRenderer('') 8 | 9 | const codeDirName = path.resolve(__dirname, '../code') 10 | const fileNameList = await fs.readdir(codeDirName) 11 | const sourceCodeMap: Record = {} 12 | for (let fileName of fileNameList) { 13 | const [, componentName, extension] = fileName.match(/^(.+?)?(?:\.(.+))?$/) || [] 14 | if (componentName) { 15 | const sourceCode = (await fs.readFile(path.resolve(codeDirName, fileName), { encoding: 'utf8' })).toString() 16 | sourceCodeMap[componentName] = { 17 | markdown: md.render(`\`\`\`${extension || 'vue'}\n${sourceCode}\n\`\`\``), 18 | sourceCode, 19 | extension, 20 | } 21 | } 22 | } 23 | return sourceCodeMap 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /site/.vitepress/en.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitepress' 2 | import { getLocaleThemeConfig } from './utils/i18n' 3 | 4 | const lang = 'en' 5 | 6 | export default defineConfig({ 7 | lang, 8 | themeConfig: getLocaleThemeConfig(lang), 9 | }) 10 | -------------------------------------------------------------------------------- /site/.vitepress/theme/index.ts: -------------------------------------------------------------------------------- 1 | import type { Theme } from 'vitepress' 2 | import DefaultTheme from 'vitepress/theme' 3 | 4 | import CodeDemo from '../components/code-demo.md' 5 | import PlaygroundLink from '../components/PlaygroundLink.vue' 6 | import '@wsfe/vue-tree/style.css' 7 | 8 | export default { 9 | extends: DefaultTheme, 10 | enhanceApp({ app }) { 11 | // 注册自定义全局组件 12 | app.component('CodeDemo', CodeDemo) 13 | app.component('PlaygroundLink', PlaygroundLink) 14 | } 15 | } satisfies Theme 16 | -------------------------------------------------------------------------------- /site/.vitepress/utils/i18n.ts: -------------------------------------------------------------------------------- 1 | import { themeConfigI18n } from "../constants/i18n" 2 | 3 | export const getLocaleThemeConfig = (lang: string) => { 4 | const pathPrefix = lang === 'root' || lang === 'zh' ? '' : `/${lang}` 5 | const i18nMap = themeConfigI18n[lang] 6 | 7 | return { 8 | nav: [ 9 | { text: i18nMap.guide, link: `${pathPrefix}/guide/getting-started`, activeMatch: '/guide' }, 10 | { text: i18nMap.examples, link: `${pathPrefix}/examples/tree`, activeMatch: '/examples' }, 11 | { text: i18nMap.api, link: `${pathPrefix}/api/vtree`, activeMatch: '/api' }, 12 | ], 13 | 14 | sidebar: [ 15 | { 16 | text: i18nMap.guide, 17 | items: [ 18 | { text: i18nMap.gettingStarted, link: `${pathPrefix}/guide/getting-started` }, 19 | { text: i18nMap.migration, link: `${pathPrefix}/guide/migration` }, 20 | ] 21 | }, 22 | { 23 | text: i18nMap.examples, 24 | items: [ 25 | { text: i18nMap.basicUsage, link: `${pathPrefix}/examples/tree` }, 26 | { text: i18nMap.performance, link: `${pathPrefix}/examples/performance` }, 27 | { text: i18nMap.nodeManipulation, link: `${pathPrefix}/examples/node-manipulation` }, 28 | { text: i18nMap.treeSearch, link: `${pathPrefix}/examples/tree-search` }, 29 | { text: i18nMap.treeDrop, link: `${pathPrefix}/examples/tree-drop` }, 30 | ] 31 | }, 32 | { 33 | text: i18nMap.api, 34 | items: [ 35 | { text: 'VTree', link: `${pathPrefix}/api/vtree` }, 36 | { text: 'VTreeSearch', link: `${pathPrefix}/api/vtree-search` }, 37 | { text: 'VTreeDrop', link: `${pathPrefix}/api/vtree-drop` }, 38 | ] 39 | }, 40 | ], 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /site/.vitepress/zh.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitepress' 2 | import { getLocaleThemeConfig } from './utils/i18n' 3 | 4 | const lang = 'zh' 5 | 6 | export default defineConfig({ 7 | lang, 8 | themeConfig: getLocaleThemeConfig(lang), 9 | }) 10 | -------------------------------------------------------------------------------- /site/api/vtree-drop.md: -------------------------------------------------------------------------------- 1 | # VTreeDrop API 2 | 3 | ## VTreeDrop Props 4 | 5 | 注:可在 `VTreeDrop` 上直接使用 `VTree` 和 `VTreeSearch` 的所有 Props 6 | 7 | | 属性 | 说明 | 类型 | 默认值 | 8 | | :------------------------- | :------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------- | :------------- | 9 | | dropHeight | 下拉内容高度 | `number` | 300 | 10 | | dropPlaceholder | 展示输入框 placeholder | `string` | 无 | 11 | | dropDisabled | 是否禁用 | `boolean` | false | 12 | | clearable | 允许清空 | `boolean` | false | 13 | | placement | 下拉弹出框位置,注意!!不支持自动识别方向 | `'bottom-start' \| 'bottom-end' \| 'bottom' \| 'top-start' \| 'top-end' \| 'top'` | 'bottom-start' | 14 | | transfer | 将下拉 DOM 转移到 body 中 | `boolean` | false | 15 | | dropdownClassName | 在下拉框容器上额外添加的 class | `string \| string[]` | 无 | 16 | | dropdownMinWidth `2.0.1` | 下拉框容器最小宽度,未指定则默认为展示输入框宽度。 适合 transfer 为 false 时使用 | `number` | 无 | 17 | | dropdownWidthFixed `2.0.5` | 固定下拉框容器宽度,当内容超出最小宽度不会伸长,而是出现横向滚动条 | `boolean` | false | 18 | 19 | ## VTreeDrop Events 20 | 21 | 注:可在 `VTreeDrop` 上直接监听 `VTree` 和 `VTreeSearch` 的所有 Events 22 | 23 | | 事件名 | 说明 | 返回值 | 24 | | :---------------------- | :--------------------- | :------------- | 25 | | dropdown-visible-change | 下拉框出现或消失时触发 | 下拉框是否可见 | 26 | | clear | 点击清空按钮时触发 | 无 | 27 | 28 | ## VTreeDrop Methods 29 | 30 | 注:可在 `VTreeDrop` 上直接调用 `VTree` 和 `VTreeSearch` 的所有 Methods 31 | 32 | ## VTreeDrop Slots 33 | 34 | 注:可在 `VTreeDrop` 上直接传入 `VTree` 和 `VTreeSearch` 的所有 Slots 35 | 36 | | 名称 | 说明 | 37 | | :------ | :--------------------------------------------------- | 38 | | 默认 | 展示输入框 | 39 | | display | 展示输入框的展示文字,如果有默认 slot 则此 slot 无效 | 40 | | clear | 替换清空图标,如果有默认 slot 则此 slot 无效 | 41 | 42 | 默认 slot 与 display slot 的 Slot Props `2.3.0` : 43 | 44 | ```typescript 45 | /** 展示 slot 的 props */ 46 | slotProps: { 47 | /** 多选选中的节点 */ 48 | checkedNodes: [] as TreeNode[], 49 | 50 | /** 多选选中的节点 key */ 51 | checkedKeys: [] as Array, 52 | 53 | /** 单选选中的节点 */ 54 | selectedNode: null as TreeNode | null, 55 | 56 | /** 单选选中的节点 key */ 57 | selectedKey: null as string | number | null, 58 | }, 59 | ``` 60 | 61 | **注意**: `checkedNodes` 与 `selectedNode` 只包含已加载的节点,如果设置了选中的值(比如设置了 `value` Prop),但没有设置树的数据,则这两个字段内容将为空;而 `checkedKeys` 与 `selectedKey` 则会包含未加载的选中节点 key 。 62 | -------------------------------------------------------------------------------- /site/api/vtree-search.md: -------------------------------------------------------------------------------- 1 | # VTreeSearch API 2 | 3 | ## VTreeSearch Props 4 | 5 | 注:可在 `VTreeSearch` 上直接使用 `VTree` 的所有 Props 6 | 7 | | 属性 | 说明 | 类型 | 默认值 | 8 | | :------------------- | :--------------------------------------------------------------------------------- | :------------------------------------------- | :----------- | 9 | | searchPlaceholder | 搜索输入框的 placeholder | `string` | '搜索关键字' | 10 | | showCheckAll | 是否显示全选复选框 | `boolean` | true | 11 | | showCheckedButton | 是否显示已选按钮 | `boolean` | true | 12 | | checkedButtonText | 已选按钮文字 | `string` | '已选' | 13 | | showFooter | 是否显示底部信息 | `boolean` | true | 14 | | searchMethod `2.0.2` | 如果传入此 Prop ,触发 `search` 事件后将会执行此方法,否则会执行组件内置的搜索方法 | `(keyword: string) => void \| Promise` | 无 | 15 | | searchLength | 触发搜索的字符长度 | `number` | 1 | 16 | | searchDisabled | 禁用搜索功能 | `boolean` | false | 17 | | searchRemote | 是否远程搜索,传入 `searchMethod` 时无效 | `boolean` | false | 18 | | searchDebounceTime | 搜索防抖时间,单位为毫秒 | `number` | 300 | 19 | 20 | ## VTreeSearch Events 21 | 22 | 注:可在 `VTreeSearch` 上直接监听 `VTree` 的所有 Events 23 | 24 | | 事件名 | 说明 | 返回值 | 25 | | :----- | :----------------- | :----------- | 26 | | search | 执行搜索操作时触发 | 搜索的关键字 | 27 | 28 | ## VTreeSearch Methods 29 | 30 | 注:可在 `VTreeSearch` 上直接调用 `VTree` 的所有 Methods 31 | 32 | | 方法 | 说明 | 参数 | 返回值 | 33 | | :----------- | :------------- | :------------------------------------------------------- | :-------------- | 34 | | clearKeyword | 清空关键字 | 无 | `void` | 35 | | getKeyword | 获取搜索关键字 | 无 | `string` | 36 | | search | 执行搜索 | `keyword: string`: 搜索的关键字,默认为内部 this.keyword | `Promise` | 37 | 38 | ## VTreeSearch Slots 39 | 40 | 注:可在 `VTreeSearch` 上直接传入 `VTree` 的所有 Slots 41 | 42 | | 名称 | 说明 | 43 | | :----------- | :------------------------------------------------- | 44 | | search-input | 搜索输入框,可通过此 slot 自行封装树搜索组件的行为 | 45 | | actions | 操作按钮,可在搜索输入框后加入更多操作按钮 | 46 | | footer | 底部信息 | 47 | 48 | -------------------------------------------------------------------------------- /site/en/api/vtree-drop.md: -------------------------------------------------------------------------------- 1 | # VTreeDrop API 2 | 3 | ## VTreeDrop Props 4 | 5 | Note: You can use all the props of `VTree` and `VTreeSearch` in `VTreeDrop` 6 | 7 | | Prop | Description | Type | Default Value | 8 | | :------------------------- | :-------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------- | :------------- | 9 | | dropHeight | Height of the dropdown | `number` | 300 | 10 | | dropPlaceholder | Placeholder of display input | `string` | None | 11 | | dropDisabled | Whether to disable tree drop | `boolean` | false | 12 | | clearable | Allow to clear | `boolean` | false | 13 | | placement | The position of the dropdown. Note!! Does not support auto adapting directions | `'bottom-start' \| 'bottom-end' \| 'bottom' \| 'top-start' \| 'top-end' \| 'top'` | 'bottom-start' | 14 | | transfer | To transfer DOM to body | `boolean` | false | 15 | | dropdownClassName | Extra class on dropdown container | `string \| string[]` | None | 16 | | dropdownMinWidth `2.0.1` | Min width of the dropdown. The default width is the width of the display input. It's better to use when 'transfer' is false | `number` | None | 17 | | dropdownWidthFixed `2.0.5` | Fix the width of the dropdown. When the content length exceeds the min with of the dropdown, a horizontal scrollbar appears | `boolean` | false | 18 | 19 | ## VTreeDrop Events 20 | 21 | Note: You can listen to all the events of `VTree` and `VTreeSearch` on `VTreeDrop` 22 | 23 | | Event | Description | Return Value | 24 | | :---------------------- | :-------------------------------------- | :------------------------- | 25 | | dropdown-visible-change | Triggers when dropdown shows/hides | Visibility of the dropdown | 26 | | clear | Triggers when click on the clear button | None | 27 | 28 | ## VTreeDrop Methods 29 | 30 | Note: You can use all the methods of `VTree` and `VTreeSearch` in `VTreeDrop` 31 | 32 | ## VTreeDrop Slots 33 | 34 | Note: You can pass all the slots of `VTree` and `VTreeSearch` to `VTreeDrop` 35 | 36 | | Name | Description | 37 | | :------ | :------------------------------------------------------------------------ | 38 | | default | The whole input | 39 | | display | The text of display input. Not effective when the default slot is present | 40 | | clear | To replace the clear icon. Not effective when the default slot is present | 41 | 42 | Slot props of `default` and `display` slots `2.3.0`: 43 | 44 | ```typescript 45 | /** Slot props of display */ 46 | slotProps: { 47 | /** Multiple selected nodes */ 48 | checkedNodes: [] as TreeNode[], 49 | 50 | /** Multiple selected node keys */ 51 | checkedKeys: [] as Array, 52 | 53 | /** Single selected nodes */ 54 | selectedNode: null as TreeNode | null, 55 | 56 | /** Single selected node keys */ 57 | selectedKey: null as string | number | null, 58 | }, 59 | ``` 60 | 61 | **Note**: `checkedNodes` and `selectedNode` only include loaded nodes. The content of these two fields will be empty if `value` prop is set but there's no tree data; While `checkedKeys` and `selectedKey` contains node keys that is not yet loaded. 62 | -------------------------------------------------------------------------------- /site/en/api/vtree-search.md: -------------------------------------------------------------------------------- 1 | # VTreeSearch API 2 | 3 | ## VTreeSearch Props 4 | 5 | Note: You can use all the props of `VTree` in `VTreeSearch` 6 | 7 | | Prop | Description | Type | Default Value | 8 | | :------------------- | :---------------------------------------------------------------------------------------------------------------------- | :------------------------------------------- | :------------ | 9 | | searchPlaceholder | Placeholder of search input | `string` | '搜索关键字' | 10 | | showCheckAll | Whether to show check all checkbox | `boolean` | true | 11 | | showCheckedButton | Whether to show 'Checked' button | `boolean` | true | 12 | | checkedButtonText | Checked button text | `string` | '已选' | 13 | | showFooter | Whether to show footer | `boolean` | true | 14 | | searchMethod `2.0.2` | This method will be invoked when `search` event triggers if present, or else the internal search method will be invoked | `(keyword: string) => void \| Promise` | None | 15 | | searchLength | The length of search text that triggers a search | `number` | 1 | 16 | | searchDisabled | Disable search | `boolean` | false | 17 | | searchRemote | Enable remote search. Not effective when `searchMethod` is present | `boolean` | false | 18 | | searchDebounceTime | Search debounce time. Unit: ms | `number` | 300 | 19 | 20 | ## VTreeSearch Events 21 | 22 | Note: You can listen to all the events of `VTree` on `VTreeSearch` 23 | 24 | | Event | Description | Return Value | 25 | | :----- | :------------------- | :------------- | 26 | | search | Triggers when search | Search keyword | 27 | 28 | ## VTreeSearch Methods 29 | 30 | Note: You can use all the methods of `VTree` in `VTreeSearch` 31 | 32 | | Method | Description | Params | Return Value | 33 | | :----------- | :------------- | :---------------------------------------------------------------------------- | :-------------- | 34 | | clearKeyword | Clear keyword | None | `void` | 35 | | getKeyword | Get keyword | None | `string` | 36 | | search | Execute search | `keyword: string`: Search keyword, the default value is internal this.keyword | `Promise` | 37 | 38 | ## VTreeSearch Slots 39 | 40 | Note: You can pass all the slots of `VTree` to `VTreeSearch` 41 | 42 | | Name | Description | 43 | | :----------- | :------------------------------------------------------------ | 44 | | search-input | Search input | 45 | | actions | Action buttons. Append more action buttons after search input | 46 | | footer | Footer info | 47 | 48 | -------------------------------------------------------------------------------- /site/en/examples/node-manipulation.md: -------------------------------------------------------------------------------- 1 | # Node Manipulation {#node-manipulation} 2 | 3 | ## Custom Node {#custom-node} 4 | 5 | There are two ways of customizing nodes: 6 | 7 | 1. Use named slot `node` 8 | 2. Use `render` prop 9 | 10 | 11 | 12 | ## Drag and Drop {#drag-and-drop} 13 | 14 | Enable `draggable` and `droppable` 15 | 16 | 17 | 18 | ## Node Creation and Removal {#node-creation-and-removal} 19 | 20 | - Invoke `insertBefore`, `insertAfter` method of tree component to insert new node before and after tree nodes 21 | - Invoke `prepend`, `append` method of tree component to prepend or append to child list 22 | - Invoke `remove` to remove a node 23 | 24 | 25 | 26 | ## Update Node Title {#update-node-title} 27 | 28 | Invoke `updateNode` method to update some fields of tree node 29 | 30 | Invoke `updateNodes` to update multiple nodes 31 | 32 | 33 | 34 | ## Update Custom Field {#update-custom-field} 35 | 36 | Invoke `updateNode` method to update custom fields in tree node 37 | 38 | 39 | 40 | ## Reload Child Nodes {#reload-children} 41 | 42 | Invoke `updateNode` and pass a new `children` list to reload child nodes 43 | 44 | 45 | -------------------------------------------------------------------------------- /site/en/examples/performance.md: -------------------------------------------------------------------------------- 1 | # Performance {#performance} 2 | 3 | 1. Works fine in Chrome 4 | 2. There's a max height limitation of browser element/document. Issues may occur when the data is way too large 5 | 3. It takes time to generate nodes, so please be aware of the depth of the tree when you config 6 | 7 | 8 | -------------------------------------------------------------------------------- /site/en/examples/tree-drop.md: -------------------------------------------------------------------------------- 1 | # Tree Drop {#tree-drop} 2 | 3 | ## Basic Drop {#basic-drop} 4 | 5 | Use `VTreeDrop` component to use tree in a dropdown 6 | 7 | 8 | 9 | ## Customize Display of Input Content {#custom-drop-display} 10 | 11 | You can use `display` slot to customize the text value of input 12 | 13 | 14 | 15 | ## Customize the Whole Dropdown Input {#custom-drop-input} 16 | 17 | Use the default slot to replace the whole input component of the dropdown 18 | 19 | 20 | -------------------------------------------------------------------------------- /site/en/examples/tree-search.md: -------------------------------------------------------------------------------- 1 | # Tree Search {#tree-search} 2 | 3 | ## Local Search {#local-search} 4 | 5 | Use `VTreeSearch` component to search 6 | 7 | 8 | 9 | ## Remote Search {#remote-search} 10 | 11 | Use `searchRemote` along with `load` method to search from remote 12 | 13 | 14 | 15 | ## Actions Slot {#actions-slot} 16 | 17 | Use `actions` slot to customize action buttons 18 | 19 | 20 | -------------------------------------------------------------------------------- /site/en/examples/tree.md: -------------------------------------------------------------------------------- 1 | # Basic Usage {#basic-usage} 2 | 3 | ## Data Display {#data-display} 4 | 5 | `data` prop can be accepted. To prevent from performance issue, use `setData` when the data is large. 6 | 7 | 8 | 9 | ## Single Select {#selectable} 10 | 11 | Use `selectable` to enable single select 12 | 13 | 14 | 15 | ## Multiple Select {#checkable} 16 | 17 | Use `checkable` to enable multiple select 18 | 19 | 20 | 21 | ## Ignore Specific Nodes {#ignore-mode} 22 | 23 | Parent nodes or child nodes can be ignored in `v-model` and `getCheckedNodes` using `ignoreMode` prop. This prop is effective only when it's set initially. 24 | 25 | 26 | 27 | ## Node Cascade {#cascade} 28 | 29 | Use `cascade` to config if parent and child nodes are cascaded 30 | 31 | 32 | 33 | ## Single and Multiple Select {#selectable-and-checkable} 34 | 35 | When single and multiple select are enabled at the same time, the value of `v-model` is bound to multiple select value 36 | 37 | 38 | 39 | ## Expand Animation {#expand-animation} 40 | 41 | Use `animation` to enable animation when expand/collapse 42 | 43 | 44 | 45 | ## Connecting Line {#show-line} 46 | 47 | Use `showLine` to show lines between nodes 48 | 49 | Apart from `boolean`, the `showLine` prop also takes an object to config the type, width, color and polyline of the line 50 | 51 | 52 | 53 | ## Remote {#remote} 54 | 55 | Config `load` method to load data from remote. `data` prop will be used as root data if present; 56 | If there's no `data`, then `load` will be invoked to load root data with a `null` node parameter 57 | 58 | 59 | -------------------------------------------------------------------------------- /site/en/guide/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started {#getting-started} 2 | 3 | ## Try It Online {#try-it-online} 4 | 5 |
Refer to to try it online
6 | 7 | ## Install {#install} 8 | 9 | ```bash 10 | # npm 11 | npm install @wsfe/vue-tree 12 | 13 | # yarn 14 | yarn add @wsfe/vue-tree 15 | 16 | # pnpm 17 | pnpm add @wsfe/vue-tree 18 | ``` 19 | 20 | ## Import {#import} 21 | 22 | `@wsfe/vue-tree` Provides three components 23 | 24 | ```typescript 25 | import VTree, { VTreeSearch, VTreeDrop } from '@wsfe/vue-tree' 26 | ``` 27 | 28 | Import style 29 | 30 | ```css 31 | @import '~@wsfe/vue-tree/style.css'; 32 | ``` 33 | 34 | Import less to override variables 35 | 36 | ```less 37 | @import '~@wsfe/vue-tree/src/styles/index.less'; 38 | ``` 39 | -------------------------------------------------------------------------------- /site/en/guide/migration.md: -------------------------------------------------------------------------------- 1 | # Migrate from Old Version {#migrate-from-old-version} 2 | 3 | ## Vue Support {#vue-support} 4 | 5 | `@wsfe/vue-tree` is the upgraded version of `@wsfe/ctree`. The new version changed the exported module names and Less/CSS prefixes. 6 | 7 | Vue support info: 8 | 9 | - @wsfe/vue-tree 4.x: Vue3 10 | - @wsfe/vue-tree 3.x: Supports Vue2 and Vue3 using `vue-demi`, but it's not under maintenance due to compatibility issues 11 | - @wsfe/ctree: Vue2. Severe bug fixes only 12 | 13 | ## Migration {#migration} 14 | 15 | To migrate from `@wsfe/ctree` or `@wsfe/vue-tree` 3.x to `@wsfe/vue-tree` 4.x, follow these steps: 16 | 17 | 1. Update all Less variables and CSS related class prefix from `ctree` to `vtree` 18 | 2. Change the prefix of exported modules from `C` to `V` 19 | 20 | ```typescript 21 | import CTree, { CTreeSearch, CTreeDrop } from '@wsfe/ctree' // [!code --] 22 | import VTree, { VTreeSearch, VTreeDrop } from '@wsfe/vue-tree' // [!code ++] 23 | ``` 24 | -------------------------------------------------------------------------------- /site/en/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | 5 | hero: 6 | name: "Vue Tree" 7 | text: "Vue tree component optimized using virtual list" 8 | tagline: 🌲@wsfe/vue-tree 9 | actions: 10 | - theme: brand 11 | text: Getting Started 12 | link: /en/guide/getting-started 13 | - theme: alt 14 | text: API Document 15 | link: /en/api/vtree 16 | 17 | features: 18 | - icon: 🚀 19 | title: Virtual List 20 | details: Optimized using virtual list, handles large data loads 21 | - icon: 📝 22 | title: Rich Features 23 | details: Supports tree display, single select, multiple select, search, dropdown, remote loading and drap-and-drop, etc. 24 | - icon: 25 | title: Vue3 26 | details: Supports Vue3 27 | --- 28 | 29 | -------------------------------------------------------------------------------- /site/examples/node-manipulation.md: -------------------------------------------------------------------------------- 1 | # 节点操作 {#node-manipulation} 2 | 3 | ## 自定义节点 {#custom-node} 4 | 5 | 有两种自定义节点的方式: 6 | 7 | 1. 传入具名插槽 `node` 8 | 2. 使用 `render` Prop 9 | 10 | 11 | 12 | ## 拖拽 {#drag-and-drop} 13 | 14 | 启用 `draggable` 与 `droppable` 可实现拖拽功能 15 | 16 | 17 | 18 | ## 节点新增与删除 {#node-creation-and-removal} 19 | 20 | - 调用树组件的 `insertBefore`, `insertAfter` 方法,可在节点前后插入新的节点 21 | - 调用树组件的 `prepend`, `append` 方法,可插入新的节点到子节点列表的最前面或最后面 22 | - 调用树组件的 `remove` 方法,可移除节点 23 | 24 | 25 | 26 | ## 更新节点名称 {#update-node-title} 27 | 28 | 调用树组件的 `updateNode` 方法可更新节点部分字段 29 | 30 | 调用 `updateNodes` 可批量更新 31 | 32 | 33 | 34 | ## 更新自定义字段 {#update-custom-field} 35 | 36 | 调用树组件的 `updateNode` 方法更新自定义字段 37 | 38 | 39 | 40 | ## 重新加载子节点 {#reload-children} 41 | 42 | 调用 `updateNode` 传入新的 `children` 列表可以重新加载子节点 43 | 44 | 45 | -------------------------------------------------------------------------------- /site/examples/performance.md: -------------------------------------------------------------------------------- 1 | # 性能测试 {#performance} 2 | 3 | 1. 在 Chrome 下表现良好 4 | 2. 浏览器元素/文档是有最大高度限制的,过多数据会导致显示不正常 5 | 3. 生成节点比较耗时,请注意节点深度 6 | 7 | 8 | -------------------------------------------------------------------------------- /site/examples/tree-drop.md: -------------------------------------------------------------------------------- 1 | # 树下拉 {#tree-drop} 2 | 3 | ## 基础下拉 {#basic-drop} 4 | 5 | 使用 `VTreeDrop` 组件可实现树下拉 6 | 7 | 8 | 9 | ## 自定义输入框展示 {#custom-drop-display} 10 | 11 | 如果想自定义展示输入框选中的值,可使用 `display` 插槽 12 | 13 | 14 | 15 | ## 自定义整个输入框 {#custom-drop-input} 16 | 17 | 使用默认插槽可把整个输入框替换掉 18 | 19 | 20 | -------------------------------------------------------------------------------- /site/examples/tree-search.md: -------------------------------------------------------------------------------- 1 | # 树搜索 {#tree-search} 2 | 3 | ## 本地搜索 {#local-search} 4 | 5 | 使用 `VTreeSearch` 组件可实现树搜索功能 6 | 7 | 8 | 9 | ## 远程搜索 {#remote-search} 10 | 11 | 启用 `searchRemote` 并配合 `load` 方法,可实现远程搜索 12 | 13 | 14 | 15 | ## 操作插槽 {#actions-slot} 16 | 17 | 使用 `actions` 插槽可定制功能按钮 18 | 19 | 20 | -------------------------------------------------------------------------------- /site/examples/tree.md: -------------------------------------------------------------------------------- 1 | # 基本用法 {#basic-usage} 2 | 3 | ## 数据展示 {#data-display} 4 | 5 | 可直接传入 `data` Prop。当数据量较大时,为了防止 Vue 监听过多数据导致性能问题,可使用 `setData` 方法设置数据。 6 | 7 | 8 | 9 | ## 单选 {#selectable} 10 | 11 | 使用 `selectable` 启用单选功能 12 | 13 | 14 | 15 | ## 多选 {#checkable} 16 | 17 | 使用 `checkable` 启用单选功能 18 | 19 | 20 | 21 | ## 多选忽略特定节点 {#ignore-mode} 22 | 23 | 配置 `ignoreMode` 可在 `v-model` 和 `getCheckedNodes` 方法忽略父节点或者子节点,该 Prop 仅初始设置有效 24 | 25 | 26 | 27 | ## 父子节点级联 {#cascade} 28 | 29 | 配置 `cascade` 可指定父子节点是否级联 30 | 31 | 32 | 33 | ## 单选与多选并存 {#selectable-and-checkable} 34 | 35 | 当既可以单选又可以多选时, `v-model` 绑定的是多选的值 36 | 37 | 38 | 39 | ## 展开动画 {#expand-animation} 40 | 41 | 配置 `animation` 可开启展开收起动画 42 | 43 | 44 | 45 | ## 连接线 {#show-line} 46 | 47 | 配置 `showLine` 可开启连接线 48 | 49 | `showLine` 除了 `boolean`,还可以接收一个对象用于具体配置连接线的类型、宽度、颜色与是否启用折线 50 | 51 | 52 | 53 | ## 远程 {#remote} 54 | 55 | 设置 `load` 方法可以使用远程加载数据,如果有设置 `data`,则 `data` 数据作为根数据; 56 | 如果没有传 `data`,则初始化时调用 `load` 方法载入根数据,其中节点参数为 `null` 57 | 58 | 59 | -------------------------------------------------------------------------------- /site/guide/getting-started.md: -------------------------------------------------------------------------------- 1 | # 快速开始 {#getting-started} 2 | 3 | ## 在线尝试 {#try-it-online} 4 | 5 |
前往 在线尝试
6 | 7 | ## 安装 {#install} 8 | 9 | ```bash 10 | # npm 11 | npm install @wsfe/vue-tree 12 | 13 | # yarn 14 | yarn add @wsfe/vue-tree 15 | 16 | # pnpm 17 | pnpm add @wsfe/vue-tree 18 | ``` 19 | 20 | ## 引入 {#import} 21 | 22 | `@wsfe/vue-tree` 提供了三个组件 23 | 24 | ```typescript 25 | import VTree, { VTreeSearch, VTreeDrop } from '@wsfe/vue-tree' 26 | ``` 27 | 28 | 引入样式 29 | 30 | ```css 31 | @import '~@wsfe/vue-tree/style.css'; 32 | ``` 33 | 34 | 引入 less 以便于变量覆盖 35 | 36 | ```less 37 | @import '~@wsfe/vue-tree/src/styles/index.less'; 38 | ``` 39 | -------------------------------------------------------------------------------- /site/guide/migration.md: -------------------------------------------------------------------------------- 1 | # 从旧版迁移 {#migrate-from-old-version} 2 | 3 | ## Vue 支持情况 {#vue-support} 4 | 5 | `@wsfe/vue-tree` 是 `@wsfe/ctree` 的升级版,新旧版本之间除了包的名称,导出的模块与 Less/CSS 相关前缀也有所不同 6 | 7 | 对 Vue 的支持情况如下: 8 | 9 | - @wsfe/vue-tree 4.x: 支持 Vue3 10 | - @wsfe/vue-tree 3.x: 使用 `vue-demi` 同时支持 Vue2 与 Vue3,但存在 Vue2 兼容性问题,后续不会再维护 11 | - @wsfe/ctree: 支持 Vue2,后续仅提供重大 bug 修复 12 | 13 | ## 迁移 {#migration} 14 | 15 | 从 `@wsfe/ctree` 或者 `@wsfe/vue-tree` 3.x 迁移到 `@wsfe/vue-tree` 4.x 需按如下步骤修改: 16 | 17 | 1. 所有 Less 变量与 CSS 相关 class 前缀从 `ctree` 改为 `vtree` 18 | 2. 导出的包前缀从 `C` 改为 `V`,例如: 19 | 20 | ```typescript 21 | import CTree, { CTreeSearch, CTreeDrop } from '@wsfe/ctree' // [!code --] 22 | import VTree, { VTreeSearch, VTreeDrop } from '@wsfe/vue-tree' // [!code ++] 23 | ``` 24 | -------------------------------------------------------------------------------- /site/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | 5 | hero: 6 | name: "Vue Tree" 7 | text: "使用虚拟列表优化的 Vue 树组件" 8 | tagline: 🌲@wsfe/vue-tree 9 | actions: 10 | - theme: brand 11 | text: 开始使用 12 | link: /guide/getting-started 13 | - theme: alt 14 | text: API 文档 15 | link: /api/vtree 16 | 17 | features: 18 | - icon: 🚀 19 | title: 虚拟列表 20 | details: 使用虚拟列表优化,可加载大量数据 21 | - icon: 📝 22 | title: 丰富特性 23 | details: 支持树形展示,单多选,搜索,下拉,远程加载,拖拽等 24 | - icon: 25 | title: Vue3 26 | details: 支持 Vue3 27 | --- 28 | 29 | -------------------------------------------------------------------------------- /site/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@wsfe/vue-tree-site", 3 | "version": "0.1.0", 4 | "type": "module" 5 | } -------------------------------------------------------------------------------- /site/playground.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: page 3 | navbar: false 4 | sidebar: false 5 | title: Playground 6 | --- 7 | 8 | 9 | 10 | 11 | 12 | 19 | -------------------------------------------------------------------------------- /site/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, UserConfig } from 'vite' 2 | 3 | export default defineConfig((): UserConfig => { 4 | return { 5 | ssr: { 6 | noExternal: ['@vue/repl'], 7 | }, 8 | } 9 | }) 10 | -------------------------------------------------------------------------------- /src/components/LoadingIcon.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 22 | -------------------------------------------------------------------------------- /src/constants/events.ts: -------------------------------------------------------------------------------- 1 | import { IEventNames } from "../store/tree-event-target" 2 | 3 | 4 | export const TREE_NODE_EVENTS = [ 5 | 'expand', 6 | 'check', 7 | 'click', 8 | 'select', 9 | 'node-dblclick', 10 | 'node-right-click', 11 | 'node-dragstart', 12 | 'node-dragenter', 13 | 'node-dragover', 14 | 'node-dragleave', 15 | 'node-drop' 16 | ] 17 | 18 | export const STORE_EVENTS: Array = [ 19 | 'expand', 20 | 'select', 21 | 'unselect', 22 | 'selected-change', 23 | 'check', 24 | 'uncheck', 25 | 'checked-change', 26 | 'set-data' 27 | ] 28 | 29 | export const TREE_EVENTS = [...TREE_NODE_EVENTS, ...STORE_EVENTS] 30 | 31 | export const TREE_SEARCH_EVENTS = ['search', ...TREE_EVENTS] 32 | 33 | export const TREE_DROP_EVENTS = [ 34 | 'clear', 35 | 'dropdown-visible-change', 36 | ...TREE_SEARCH_EVENTS, 37 | ] 38 | -------------------------------------------------------------------------------- /src/constants/index.ts: -------------------------------------------------------------------------------- 1 | //#region ignoreMode 2 | export enum ignoreEnum { 3 | none = 'none', 4 | parents = 'parents', 5 | children = 'children' 6 | } 7 | //#endregion ignoreMode 8 | 9 | //#region API 10 | // Tree API 11 | export const TREE_API_METHODS = [ 12 | 'setData', 13 | 'setChecked', 14 | 'setCheckedKeys', 15 | 'checkAll', 16 | 'clearChecked', 17 | 'setSelected', 18 | 'clearSelected', 19 | 'setExpand', 20 | 'setExpandKeys', 21 | 'setExpandAll', 22 | 'getCheckedNodes', 23 | 'getCheckedKeys', 24 | 'getIndeterminateNodes', 25 | 'getSelectedNode', 26 | 'getSelectedKey', 27 | 'getExpandNodes', 28 | 'getExpandKeys', 29 | 'getCurrentVisibleNodes', 30 | 'getNode', 31 | 'getTreeData', 32 | 'getFlatData', 33 | 'getNodesCount', 34 | 'insertBefore', 35 | 'insertAfter', 36 | 'append', 37 | 'prepend', 38 | 'remove', 39 | 'filter', 40 | 'showCheckedNodes', 41 | 'loadRootNodes', 42 | 'updateNode', 43 | 'updateNodes', 44 | 'scrollTo' 45 | ] as const 46 | 47 | export const TREE_SEARCH_API_METHODS = [...TREE_API_METHODS, 'clearKeyword', 'getKeyword', 'search'] as const 48 | 49 | export enum placementEnum { 50 | 'bottom-start' = 'bottom-start', 51 | 'bottom-end' = 'bottom-end', 52 | 'bottom' = 'bottom', 53 | 'top-start' = 'top-start', 54 | 'top-end' = 'top-end', 55 | 'top' = 'top' 56 | } 57 | 58 | //#region Scroll position 59 | export enum verticalPositionEnum { 60 | top = 'top', 61 | center = 'center', 62 | bottom = 'bottom' 63 | } 64 | 65 | export type VerticalPositionType = keyof typeof verticalPositionEnum 66 | //#endregion Scroll position 67 | 68 | //#region Drag 69 | export enum dragHoverPartEnum { 70 | before = 'before', 71 | body = 'body', 72 | after = 'after' 73 | } 74 | //#endregion Drag 75 | 76 | export enum showLineType { 77 | // dotted = 'dotted', 78 | dashed = 'dashed', 79 | solid = 'solid', 80 | } 81 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import type { defineComponent } from 'vue' 3 | const Component: ReturnType 4 | export default Component 5 | } 6 | -------------------------------------------------------------------------------- /src/hooks/useExpandAnimation.ts: -------------------------------------------------------------------------------- 1 | import { Ref, nextTick, ref } from "vue" 2 | import { TreeNode } from "../store" 3 | import { TreeProps } from "../components/Tree.vue" 4 | 5 | type IUseExpandAnimationProps = Required> 9 | 10 | export const useExpandAnimation = (renderNodesRef: Ref, renderStartRef: Ref, props: IUseExpandAnimationProps) => { 11 | const expandAnimationStart = ref(false) 12 | const expandAnimationReady = ref(false) 13 | const expandNodeIndex = ref(-1) 14 | const expandNodeLevel = ref(-1) 15 | const expandNodeCurrentState = ref(false) 16 | const expandNodeNextState = ref(false) 17 | 18 | const expandRenderStart = ref(0) 19 | const expandTopNodes = ref([]) 20 | const expandMiddleNodes = ref([]) 21 | const expandBottomNodes = ref([]) 22 | 23 | const resetExpandAnimation = () => { 24 | expandAnimationStart.value = false 25 | expandAnimationReady.value = false 26 | expandNodeIndex.value = -1 27 | expandNodeLevel.value = -1 28 | 29 | expandRenderStart.value = 0 30 | expandTopNodes.value = [] 31 | expandMiddleNodes.value = [] 32 | expandBottomNodes.value = [] 33 | } 34 | 35 | const updateMiddleNodes = () => { 36 | const nodeToExpandLevel = expandNodeLevel.value 37 | const middleNodes: TreeNode[] = [] 38 | const renderNodesLength = renderNodesRef.value.length 39 | const expandRenderStartDiff = renderStartRef.value - expandRenderStart.value 40 | for (let i = expandNodeIndex.value - expandRenderStartDiff + 1; i < renderNodesLength; i++) { 41 | if (renderNodesRef.value[i]._level > nodeToExpandLevel) { 42 | middleNodes.push(renderNodesRef.value[i]) 43 | } else break 44 | } 45 | expandMiddleNodes.value = middleNodes 46 | } 47 | 48 | const updateBeforeExpand = (nodeToExpand: TreeNode) => { 49 | if (!props.animation) return 50 | resetExpandAnimation() 51 | 52 | const key = nodeToExpand[props.keyField] 53 | const index = renderNodesRef.value.findIndex((renderNode) => renderNode[props.keyField] === key) 54 | if (index > -1) { 55 | expandNodeIndex.value = index 56 | expandNodeLevel.value = nodeToExpand._level 57 | expandAnimationStart.value = true 58 | expandNodeCurrentState.value = nodeToExpand.expand 59 | expandNodeNextState.value = !nodeToExpand.expand 60 | expandRenderStart.value = renderStartRef.value 61 | 62 | if (expandNodeNextState.value) { 63 | expandBottomNodes.value = renderNodesRef.value.slice(expandNodeIndex.value + 1) 64 | } else { 65 | updateMiddleNodes() 66 | } 67 | } 68 | } 69 | 70 | const updateAfterExpand = () => { 71 | if (!props.animation) return 72 | 73 | if (!expandAnimationStart.value) { 74 | expandAnimationStart.value = false 75 | return 76 | } 77 | 78 | if (expandNodeIndex.value === -1) return 79 | 80 | nextTick(() => { 81 | const expandRenderStartDiff = renderStartRef.value - expandRenderStart.value 82 | expandTopNodes.value = renderNodesRef.value.slice(0, expandNodeIndex.value - expandRenderStartDiff + 1) 83 | if (expandNodeNextState.value) { 84 | updateMiddleNodes() 85 | } else { 86 | expandBottomNodes.value = renderNodesRef.value.slice(expandNodeIndex.value - expandRenderStartDiff + 1) 87 | } 88 | expandAnimationReady.value = true 89 | nextTick(() => { 90 | expandNodeCurrentState.value = !expandNodeCurrentState.value 91 | }) 92 | }) 93 | } 94 | 95 | const onExpandAnimationFinish = () => { 96 | resetExpandAnimation() 97 | } 98 | 99 | return { 100 | ready: expandAnimationReady, 101 | currentExpandState: expandNodeCurrentState, 102 | 103 | topNodes: expandTopNodes, 104 | middleNodes: expandMiddleNodes, 105 | bottomNodes: expandBottomNodes, 106 | 107 | updateBeforeExpand, 108 | updateAfterExpand, 109 | onExpandAnimationFinish, 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/hooks/useIframeResize.ts: -------------------------------------------------------------------------------- 1 | import { onBeforeUnmount, onMounted, ref } from "vue" 2 | 3 | export const useIframeResize = (resizeCallback: () => void) => { 4 | const iframe = ref() 5 | 6 | onMounted(() => { 7 | const $iframe = iframe.value 8 | if ($iframe?.contentWindow) { 9 | $iframe.contentWindow.addEventListener('resize', resizeCallback) 10 | } 11 | }) 12 | 13 | onBeforeUnmount(() => { 14 | const $iframe = iframe.value 15 | if ($iframe?.contentWindow) { 16 | $iframe.contentWindow.removeEventListener('resize', resizeCallback) 17 | } 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /src/hooks/useTreeCls.ts: -------------------------------------------------------------------------------- 1 | import { computed } from "vue" 2 | 3 | const prefixCls = 'vtree-tree' 4 | 5 | export { prefixCls as TREE_PREFIX_CLS } 6 | 7 | export const useTreeCls = () => { 8 | const wrapperCls = computed(() => { 9 | return [`${prefixCls}__wrapper`] 10 | }) 11 | const scrollAreaCls = computed(() => { 12 | return [`${prefixCls}__scroll-area`] 13 | }) 14 | const blockAreaCls = computed(() => { 15 | return [`${prefixCls}__block-area`] 16 | }) 17 | const emptyCls = computed(() => { 18 | return [`${prefixCls}__empty`] 19 | }) 20 | const emptyTextDefaultCls = computed(() => { 21 | return [`${prefixCls}__empty-text_default`] 22 | }) 23 | const loadingCls = computed(() => { 24 | return [`${prefixCls}__loading`] 25 | }) 26 | const loadingWrapperCls = computed(() => { 27 | return [`${prefixCls}__loading-wrapper`] 28 | }) 29 | const loadingIconCls = computed(() => { 30 | return [`${prefixCls}__loading-icon`] 31 | }) 32 | const iframeCls = computed(() => { 33 | return [`${prefixCls}__iframe`] 34 | }) 35 | 36 | return { 37 | wrapperCls, 38 | scrollAreaCls, 39 | blockAreaCls, 40 | emptyCls, 41 | emptyTextDefaultCls, 42 | loadingCls, 43 | loadingWrapperCls, 44 | loadingIconCls, 45 | iframeCls, 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/hooks/useTreeDropCls.ts: -------------------------------------------------------------------------------- 1 | import { Ref, computed } from 'vue' 2 | import { TREE_SEARCH_PREFIX_CLS as treeSearchPrefixCls } from './useTreeSearchCls' 3 | import { TreeDropProps } from '../components/TreeDrop.vue' 4 | 5 | const prefixCls = 'vtree-tree-drop' 6 | 7 | export { prefixCls as TREE_DROP_PREFIX_CLS } 8 | 9 | export const useTreeDropCls = (props: TreeDropProps, options: { 10 | dropdownVisible: Ref 11 | checkedCount: Ref 12 | selectedTitle: Ref 13 | }) => { 14 | const { 15 | dropdownVisible, 16 | checkedCount, 17 | selectedTitle, 18 | } = options 19 | 20 | const wrapperCls = computed(() => { 21 | return [`${prefixCls}__wrapper`] 22 | }) 23 | const referenceCls = computed(() => { 24 | return [`${prefixCls}__reference`] 25 | }) 26 | 27 | const displayInputCls = computed(() => { 28 | return [ 29 | `${treeSearchPrefixCls}__input`, 30 | `${prefixCls}__display-input`, 31 | { 32 | [`${prefixCls}__display-input_focus`]: dropdownVisible.value, 33 | [`${treeSearchPrefixCls}__input_disabled`]: props.dropDisabled 34 | } 35 | ] 36 | }) 37 | 38 | const displayInputTextCls = computed(() => { 39 | let showPlaceholder: boolean = false 40 | if (typeof props.dropPlaceholder === 'string') { 41 | if (props.checkable) showPlaceholder = checkedCount.value === 0 42 | else if (props.selectable) showPlaceholder = selectedTitle.value === '' 43 | } 44 | return [ 45 | `${prefixCls}__display-input-text`, 46 | { 47 | [`${prefixCls}__display-input-placeholder`]: showPlaceholder 48 | } 49 | ] 50 | }) 51 | 52 | const dropIconCls = computed(() => { 53 | return [ 54 | `${prefixCls}__display-icon-drop`, 55 | { 56 | [`${prefixCls}__display-icon-drop_active`]: dropdownVisible.value 57 | } 58 | ] 59 | }) 60 | 61 | const clearIconCls = computed(() => { 62 | return [`${prefixCls}__display-icon-clear`] 63 | }) 64 | 65 | const dropdownCls = computed(() => { 66 | const extraClassName = Array.isArray(props.dropdownClassName) 67 | ? props.dropdownClassName 68 | : [props.dropdownClassName] 69 | return [`${prefixCls}__dropdown`, ...extraClassName] 70 | }) 71 | 72 | return { 73 | wrapperCls, 74 | referenceCls, 75 | displayInputCls, 76 | displayInputTextCls, 77 | dropIconCls, 78 | clearIconCls, 79 | dropdownCls, 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/hooks/useTreeNodeCls.ts: -------------------------------------------------------------------------------- 1 | import { Ref, computed } from "vue" 2 | import { TreeNodeProps } from "../components/TreeNode.vue" 3 | 4 | const prefixCls = 'vtree-tree-node' 5 | 6 | export { prefixCls as TREE_NODE_PREFIX_CLS } 7 | 8 | export const useTreeNodeCls = (props: TreeNodeProps, dragoverRefs: { 9 | dragoverBody: Ref 10 | dragoverBefore: Ref 11 | dragoverAfter: Ref 12 | }) => { 13 | const { 14 | dragoverBody, 15 | dragoverBefore, 16 | dragoverAfter, 17 | } = dragoverRefs 18 | 19 | const indentWrapperCls = computed(() => { 20 | return [ 21 | `${prefixCls}__indent-wrapper`, 22 | ] 23 | }) 24 | const wrapperCls = computed(() => { 25 | return [ 26 | `${prefixCls}__wrapper`, 27 | { 28 | [`${prefixCls}__wrapper_is-leaf`]: props.data?.isLeaf, 29 | [`${prefixCls}_disabled`]: 30 | props.disableAll || props.data?.disabled 31 | }, 32 | // 复选 33 | { 34 | [`${prefixCls}_checked`]: props.checkable && props.data?.checked, 35 | [`${prefixCls}_indeterminate`]: props.checkable && props.data?.indeterminate 36 | }, 37 | // 单选 38 | { 39 | [`${prefixCls}_selected`]: props.data?.selected, 40 | } 41 | ] 42 | }) 43 | const nodeBodyCls = computed(() => { 44 | return [ 45 | `${prefixCls}__node-body`, 46 | { 47 | [`${prefixCls}__drop_active`]: dragoverBody.value 48 | } 49 | ] 50 | }) 51 | const dropBeforeCls = computed(() => { 52 | return [ 53 | `${prefixCls}__drop`, 54 | { 55 | [`${prefixCls}__drop_active`]: dragoverBefore.value 56 | } 57 | ] 58 | }) 59 | const dropAfterCls = computed(() => { 60 | return [ 61 | `${prefixCls}__drop`, 62 | { 63 | [`${prefixCls}__drop_active`]: dragoverAfter.value 64 | } 65 | ] 66 | }) 67 | // const squareCls = computed(() => { 68 | // return [`${prefixCls}__square`] 69 | // }) 70 | // 复选框图标 71 | const checkboxWrapperCls = computed(() => { 72 | return [`${prefixCls}__square`, `${prefixCls}__checkbox_wrapper`] 73 | }) 74 | const expandCls = computed(() => { 75 | return [ 76 | `${prefixCls}__square`, 77 | `${prefixCls}__expand`, 78 | { 79 | [`${prefixCls}__expand_active`]: props.data?.expand 80 | } 81 | ] 82 | }) 83 | const loadingIconCls = computed(() => { 84 | return [`${prefixCls}__loading-icon`] 85 | }) 86 | const checkboxCls = computed(() => { 87 | return [ 88 | `${prefixCls}__checkbox`, 89 | { 90 | [`${prefixCls}__checkbox_checked`]: props.data?.checked, 91 | [`${prefixCls}__checkbox_indeterminate`]: props.data?.indeterminate, 92 | [`${prefixCls}__checkbox_disabled`]: 93 | props.disableAll || props.data?.disabled 94 | } 95 | ] 96 | }) 97 | const titleCls = computed(() => { 98 | return [ 99 | `${prefixCls}__title`, 100 | { 101 | [`${prefixCls}__title_selected`]: props.data?.selected, 102 | [`${prefixCls}__title_disabled`]: 103 | props.disableAll || props.data?.disabled 104 | } 105 | ] 106 | }) 107 | 108 | return { 109 | indentWrapperCls, 110 | wrapperCls, 111 | nodeBodyCls, 112 | dropBeforeCls, 113 | dropAfterCls, 114 | checkboxWrapperCls, 115 | expandCls, 116 | loadingIconCls, 117 | checkboxCls, 118 | titleCls, 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/hooks/useTreeSearchCls.ts: -------------------------------------------------------------------------------- 1 | import { Ref, computed } from 'vue' 2 | import { TREE_NODE_PREFIX_CLS as treeNodePrefixCls } from './useTreeNodeCls' 3 | import { TreeSearchProps } from '../components/TreeSearch.vue' 4 | 5 | const prefixCls = 'vtree-tree-search' 6 | 7 | export { prefixCls as TREE_SEARCH_PREFIX_CLS } 8 | 9 | export const useTreeSearchCls = (props: TreeSearchProps, options: { 10 | checkAllStatus: { 11 | checked: boolean 12 | indeterminate: boolean 13 | disabled: boolean 14 | } 15 | isShowingChecked: Ref 16 | }) => { 17 | const { checkAllStatus, isShowingChecked } = options 18 | 19 | const wrapperCls = computed(() => { 20 | return [`${prefixCls}__wrapper`] 21 | }) 22 | const searchCls = computed(() => { 23 | return [`${prefixCls}__search`] 24 | }) 25 | const checkAllWrapperCls = computed(() => { 26 | return [`${prefixCls}__check-all-wrapper`] 27 | }) 28 | const checkboxCls = computed(() => { 29 | return [ 30 | `${prefixCls}__check-all`, 31 | `${treeNodePrefixCls}__checkbox`, 32 | { 33 | [`${treeNodePrefixCls}__checkbox_checked`]: checkAllStatus.checked, 34 | [`${treeNodePrefixCls}__checkbox_indeterminate`]: 35 | checkAllStatus.indeterminate, 36 | [`${treeNodePrefixCls}__checkbox_disabled`]: 37 | props.searchDisabled || checkAllStatus.disabled 38 | } 39 | ] 40 | }) 41 | const inputWrapperCls = computed(() => { 42 | return [`${prefixCls}__input-wrapper`] 43 | }) 44 | const inputCls = computed(() => { 45 | return [ 46 | `${prefixCls}__input`, 47 | { 48 | [`${prefixCls}__input_disabled`]: props.searchDisabled 49 | } 50 | ] 51 | }) 52 | const actionWrapperCls = computed(() => { 53 | return [`${prefixCls}__action-wrapper`] 54 | }) 55 | const checkedButtonCls = computed(() => { 56 | return [ 57 | `${prefixCls}__checked-button`, 58 | { 59 | [`${prefixCls}__checked-button_active`]: isShowingChecked.value 60 | } 61 | ] 62 | }) 63 | const treeWrapperCls = computed(() => { 64 | return [`${prefixCls}__tree-wrapper`] 65 | }) 66 | const footerCls = computed(() => { 67 | return [`${prefixCls}__footer`] 68 | }) 69 | 70 | return { 71 | wrapperCls, 72 | searchCls, 73 | checkAllWrapperCls, 74 | checkboxCls, 75 | inputWrapperCls, 76 | inputCls, 77 | actionWrapperCls, 78 | checkedButtonCls, 79 | treeWrapperCls, 80 | footerCls, 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/hooks/useVirtualList.ts: -------------------------------------------------------------------------------- 1 | import { ref } from "vue" 2 | import { TreeNode } from ".." 3 | import { INonReactiveData, TreeNodeKeyType } from "../types" 4 | import { VerticalPositionType, verticalPositionEnum } from "../constants" 5 | import { TreeProps } from "../components/Tree.vue" 6 | 7 | type IUseVirtualListProps = Required> 13 | 14 | export const useVirtualList = (nonReactive: INonReactiveData, props: IUseVirtualListProps) => { 15 | const scrollArea = ref() 16 | const renderNodes = ref([]) 17 | const blockLength = ref(0) 18 | const blockAreaHeight = ref(0) 19 | const topSpaceHeight = ref(0) 20 | const bottomSpaceHeight = ref(0) 21 | const renderAmount = ref(0) 22 | const renderAmountCache = ref(0) 23 | const renderStart = ref(0) 24 | const renderStartCache = ref(0) 25 | const debounceTimer = ref(undefined) 26 | 27 | /** 28 | * 重置空白与滚动高度 29 | */ 30 | const resetSpaceHeights = (): void => { 31 | topSpaceHeight.value = 0 32 | bottomSpaceHeight.value = 0 33 | if (scrollArea.value) scrollArea.value.scrollTop = 0 34 | } 35 | 36 | /** 37 | * 计算需要渲染的节点的数量,只要容器高度(clientHeight)不变,这个数量一般就不会变 38 | */ 39 | const updateRenderAmount = (): void => { 40 | const clientHeight = scrollArea.value.clientHeight 41 | renderAmount.value = Math.max( 42 | props.renderNodeAmount, 43 | Math.ceil(clientHeight / props.nodeMinHeight) + props.bufferNodeAmount 44 | ) 45 | } 46 | 47 | /** 48 | * 计算渲染的节点,基于 scrollTop 计算当前应该渲染哪些节点 49 | */ 50 | const updateRenderNodes = (isScroll: boolean = false): void => { 51 | if (blockLength.value > renderAmount.value) { 52 | const scrollTop = Math.max(scrollArea.value.scrollTop, 0) 53 | /** 当前滚动了多少节点 */ 54 | const scrollNodeAmount = Math.floor(scrollTop / props.nodeMinHeight) 55 | renderStart.value = 56 | Math.floor(scrollNodeAmount / props.bufferNodeAmount) * 57 | props.bufferNodeAmount 58 | } else { 59 | renderStart.value = 0 60 | } 61 | if ( 62 | isScroll && 63 | renderAmountCache.value === renderAmount.value && 64 | renderStartCache.value === renderStart.value 65 | ) 66 | return 67 | renderNodes.value = nonReactive.blockNodes 68 | .slice(renderStart.value, renderStart.value + renderAmount.value) 69 | .map(blockNode => { 70 | return Object.assign({}, blockNode, { 71 | _parent: null, 72 | children: [] 73 | }) 74 | }) as TreeNode[] 75 | topSpaceHeight.value = renderStart.value * props.nodeMinHeight 76 | bottomSpaceHeight.value = 77 | blockAreaHeight.value - 78 | (topSpaceHeight.value + renderNodes.value.length * props.nodeMinHeight) 79 | } 80 | 81 | /** 82 | * 计算渲染节点数量,并计算渲染节点 83 | */ 84 | const updateRender = (): void => { 85 | updateRenderAmount() 86 | updateRenderNodes() 87 | } 88 | 89 | /** 90 | * 计算可见节点 91 | */ 92 | const updateBlockNodes = (): void => { 93 | nonReactive.blockNodes = nonReactive.store.flatData.filter( 94 | node => node.visible 95 | ) 96 | updateBlockData() 97 | updateRender() 98 | } 99 | 100 | /** 101 | * 更新 block 数据相关信息 102 | */ 103 | const updateBlockData = (): void => { 104 | blockLength.value = nonReactive.blockNodes.length 105 | blockAreaHeight.value = props.nodeMinHeight * blockLength.value 106 | } 107 | 108 | const handleTreeScroll = (): void => { 109 | if (debounceTimer.value) { 110 | window.cancelAnimationFrame(debounceTimer.value) 111 | } 112 | renderAmountCache.value = renderAmount.value 113 | renderStartCache.value = renderStart.value 114 | debounceTimer.value = window.requestAnimationFrame( 115 | updateRenderNodes.bind(null, true) 116 | ) 117 | } 118 | 119 | /** 120 | * 滚动到指定节点位置 121 | * @param key 要滚动的节点 122 | * @param verticalPosition 滚动的垂直位置,可选为 'top', 'center', 'bottom' 或距离容器可视顶部距离的数字,默认为 'top' 123 | */ 124 | const scrollTo = ( 125 | key: TreeNodeKeyType, 126 | verticalPosition: VerticalPositionType | number = verticalPositionEnum.top 127 | ): void => { 128 | const node = nonReactive.store.mapData[key] 129 | if (!node || !node.visible) return 130 | let index: number = -1 131 | for (let i = 0; i < blockLength.value; i++) { 132 | if (nonReactive.blockNodes[i][props.keyField] === key) { 133 | index = i 134 | break 135 | } 136 | } 137 | if (index === -1) return 138 | let scrollTop = index * props.nodeMinHeight 139 | if (verticalPosition === verticalPositionEnum.center) { 140 | const clientHeight = scrollArea.value.clientHeight 141 | scrollTop = scrollTop - (clientHeight - props.nodeMinHeight) / 2 142 | } else if (verticalPosition === verticalPositionEnum.bottom) { 143 | const clientHeight = scrollArea.value.clientHeight 144 | scrollTop = scrollTop - (clientHeight - props.nodeMinHeight) 145 | } else if (typeof verticalPosition === 'number') { 146 | scrollTop = scrollTop - verticalPosition 147 | } 148 | if (scrollArea.value) scrollArea.value.scrollTop = scrollTop 149 | } 150 | 151 | return { 152 | scrollArea, 153 | renderNodes, 154 | blockLength, 155 | blockAreaHeight, 156 | topSpaceHeight, 157 | bottomSpaceHeight, 158 | renderAmount, 159 | renderAmountCache, 160 | renderStart, 161 | renderStartCache, 162 | resetSpaceHeights, 163 | updateRenderAmount, 164 | updateRenderNodes, 165 | updateRender, 166 | updateBlockNodes, 167 | updateBlockData, 168 | handleTreeScroll, 169 | scrollTo, 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import VTree from './components/Tree.vue' 2 | export { default as VTreeNode } from './components/TreeNode.vue' 3 | export { default as VTreeSearch } from './components/TreeSearch.vue' 4 | export { default as VTreeDrop } from './components/TreeDrop.vue' 5 | export { TreeNode } from './store' 6 | export { default as TreeStore } from './store' 7 | import './styles/index.less' 8 | export * from './types' 9 | 10 | export default VTree 11 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import TreeStore from './tree-store' 2 | import TreeNode from './tree-node' 3 | 4 | export default TreeStore 5 | 6 | export { TreeNode } 7 | -------------------------------------------------------------------------------- /src/store/tree-event-target.ts: -------------------------------------------------------------------------------- 1 | import { TreeNodeKeyType } from '../types' 2 | import TreeNode from './tree-node' 3 | 4 | interface IListenersMap { 5 | [eventName: string]: Function[] 6 | } 7 | 8 | type NodeGeneralListenerType = (node: TreeNode) => void 9 | 10 | export type ListenerType = IEventNames[T] 11 | 12 | export interface IEventNames { 13 | 'set-data': () => void 14 | 'visible-data-change': () => void 15 | 'render-data-change': () => void 16 | expand: NodeGeneralListenerType 17 | select: NodeGeneralListenerType 18 | unselect: NodeGeneralListenerType 19 | 'selected-change': ( 20 | node: TreeNode | null, 21 | key: TreeNodeKeyType | null 22 | ) => void 23 | check: NodeGeneralListenerType 24 | uncheck: NodeGeneralListenerType 25 | 'checked-change': (nodes: TreeNode[], keys: TreeNodeKeyType[]) => void 26 | } 27 | 28 | export default class TreeEventTarget { 29 | /** 事件 listeners */ 30 | private listenersMap: IListenersMap = {} 31 | 32 | on( 33 | eventName: T, 34 | listener: ListenerType | Array> 35 | ): void { 36 | if (!this.listenersMap[eventName]) { 37 | this.listenersMap[eventName] = [] 38 | } 39 | let listeners: Array> = [] 40 | if (!Array.isArray(listener)) { 41 | listeners = [listener] 42 | } else { 43 | listeners = listener 44 | } 45 | listeners.forEach(listener => { 46 | if (this.listenersMap[eventName].indexOf(listener) === -1) { 47 | this.listenersMap[eventName].push(listener) 48 | } 49 | }) 50 | } 51 | 52 | off( 53 | eventName: T, 54 | listener?: ListenerType 55 | ): void { 56 | if (!this.listenersMap[eventName]) return 57 | if (!listener) { 58 | this.listenersMap[eventName] = [] 59 | } else { 60 | const index = this.listenersMap[eventName].indexOf(listener) 61 | if (index > -1) { 62 | this.listenersMap[eventName].splice(index, 1) 63 | } 64 | } 65 | } 66 | 67 | emit( 68 | eventName: T, 69 | ...args: Parameters 70 | ): void { 71 | if (!this.listenersMap[eventName]) return 72 | const length: number = this.listenersMap[eventName].length 73 | for (let i: number = 0; i < length; i++) { 74 | this.listenersMap[eventName][i](...args) 75 | } 76 | } 77 | 78 | disposeListeners(): void { 79 | for (const eventName in this.listenersMap) { 80 | this.listenersMap[eventName] = [] 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/store/tree-node.ts: -------------------------------------------------------------------------------- 1 | import { TreeNodeKeyType } from '../types' 2 | 3 | interface IKeyOption { 4 | [key: string]: TreeNodeKeyType 5 | } 6 | 7 | export interface ITreeNodeOptions extends IKeyOption { 8 | [key: string]: any 9 | } 10 | 11 | const notAllowOverrideList: string[] = [ 12 | '_level', 13 | '_filterVisible', 14 | '_parent', 15 | '_loading', 16 | '_loaded', 17 | '_remote', 18 | '_keyField', 19 | 'children', 20 | 'setChildren' 21 | ] 22 | 23 | export default class TreeNode { 24 | //#region Properties 25 | 26 | [key: string]: any | TreeNodeKeyType 27 | 28 | /** 节点层级 */ 29 | _level: number = 0 30 | 31 | /** 多选是否选中 */ 32 | checked: boolean = false 33 | 34 | /** 单选是否选中 */ 35 | selected: boolean = false 36 | 37 | /** 是否半选状态 */ 38 | indeterminate: boolean = false 39 | 40 | /** 是否禁用 */ 41 | disabled: boolean = false 42 | 43 | /** 是否展开 */ 44 | expand: boolean = false 45 | 46 | /** 是否可见 */ 47 | visible: boolean = true 48 | 49 | /** 过滤后是否可见,如果为 false 则在其他可见情况下也是不可见的 */ 50 | _filterVisible: boolean = true 51 | 52 | /** 父节点 */ 53 | _parent: null | TreeNode = null 54 | 55 | /** 子节点 */ 56 | children: TreeNode[] = [] 57 | 58 | /** 是否是子节点 */ 59 | isLeaf: boolean = false 60 | 61 | /** 节点是否正在加载 */ 62 | _loading: boolean = false 63 | 64 | /** 子节点是否已加载 */ 65 | _loaded: boolean = false 66 | 67 | //#endregion Properties 68 | 69 | constructor( 70 | options: ITreeNodeOptions, 71 | parent: null | TreeNode = null, 72 | readonly _keyField: string = 'id', 73 | readonly _remote: boolean = false 74 | ) { 75 | for (let option in options) { 76 | if (notAllowOverrideList.indexOf(option) === -1) { 77 | this[option] = options[option] 78 | } 79 | } 80 | 81 | if (this[_keyField] == null) { 82 | // 如果没有 id 字段,随机赋值一个 83 | this[_keyField] = Math.random().toString(36).substring(2) 84 | } 85 | 86 | this._parent = parent 87 | 88 | if (this._parent) { 89 | this._level = this._parent._level + 1 90 | } 91 | 92 | this.visible = 93 | this._parent === null || (this._parent.expand && this._parent.visible) 94 | 95 | if (Array.isArray(options.children)) { 96 | this.setChildren(options.children) 97 | } 98 | 99 | if (this.children.length) { 100 | this._loaded = true 101 | } 102 | 103 | if (!this._remote) { 104 | this.isLeaf = !this.children.length 105 | } 106 | } 107 | 108 | /** 109 | * 设置子节点 110 | * @param children 子节点数据数组 111 | */ 112 | setChildren(children: ITreeNodeOptions[]): void { 113 | this.children = children.map(child => { 114 | return new TreeNode( 115 | Object.assign({}, child), 116 | this, 117 | this._keyField, 118 | this._remote 119 | ) 120 | }) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/styles/index.less: -------------------------------------------------------------------------------- 1 | body { 2 | -webkit-tap-highlight-color: transparent; // 为了解决移动端,点击展开icon有凸显背景色的问题 3 | } 4 | @import 'variables'; 5 | @import 'loading-icon'; 6 | @import 'tree'; 7 | @import 'tree-search'; 8 | @import 'tree-drop'; 9 | -------------------------------------------------------------------------------- /src/styles/loading-icon.less: -------------------------------------------------------------------------------- 1 | #vtree-loading-icon-styles (@size: 30px, @color: @vtree-color-primary) { 2 | display: inline-block; 3 | width: @size; 4 | height: @size; 5 | animation: vtree-animation-spin 2s linear infinite; 6 | 7 | .@{vtree-prefix}-loading-icon__circle { 8 | stroke: @color; 9 | stroke-linecap: round; 10 | animation: vtree-animation-svg-circle-spin 1.5s ease-in-out infinite; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/styles/tree-drop.less: -------------------------------------------------------------------------------- 1 | #vtree-tree-drop-styles () { 2 | // prefix 3 | @tree-drop-prefix: ~'@{vtree-prefix}-tree-drop'; 4 | @tree-search-prefix: ~'@{vtree-prefix}-tree-search'; 5 | @tree-dropdown-prefix: ~'@{vtree-prefix}-dropdown'; 6 | 7 | // TreeDrop 8 | .@{tree-drop-prefix} { 9 | // 容器 10 | &__wrapper { 11 | position: relative; 12 | } 13 | 14 | // 下拉框触发区域 15 | &__reference { 16 | cursor: pointer; 17 | } 18 | 19 | // 触发区域 20 | &__display { 21 | // 输入框 22 | &-input { 23 | position: relative; 24 | user-select: none; 25 | padding-right: 20px; 26 | 27 | &-text { 28 | display: block; 29 | height: 100%; 30 | line-height: 22px; 31 | } 32 | 33 | &-placeholder { 34 | color: @vtree-color-input-placeholder; 35 | } 36 | 37 | &_focus { 38 | border-color: @vtree-color-input-border; 39 | box-shadow: 0 0 0 2px fade(@vtree-color-primary, 20%); 40 | } 41 | } 42 | 43 | // 图标 44 | &-icon { 45 | @icon-size: 14px; 46 | .icon () { 47 | position: absolute; 48 | top: 8px; 49 | right: 3px; 50 | width: @icon-size; 51 | height: @icon-size; 52 | box-sizing: border-box; 53 | } 54 | 55 | // 下拉图标 56 | &-drop { 57 | @scale: 0.5; 58 | @translate: -(@icon-size / 2) * @scale; 59 | @transform-rest: scale(@scale) translateX(@translate) 60 | translateY(@translate); 61 | 62 | .icon(); 63 | border: 3px solid @vtree-color-sub; 64 | border-top: none; 65 | border-left: none; 66 | transform: rotate(45deg) @transform-rest; 67 | transition: transform 0.2s ease-in-out; 68 | 69 | &_active { 70 | transform: rotate(225deg) @transform-rest; 71 | } 72 | } 73 | 74 | // 清除图标 75 | &-clear { 76 | @scale: 0.9; 77 | .pseudo () { 78 | @thick: 1px; 79 | @offset: ((@icon-size - @thick) / 2); 80 | @diff: 7px; 81 | 82 | content: ''; 83 | width: @thick; 84 | height: (@icon-size - @diff); 85 | display: block; 86 | background-color: #fff; 87 | position: absolute; 88 | top: (@diff / 2); 89 | left: @offset; 90 | } 91 | 92 | .icon(); 93 | border-radius: 50%; 94 | background-color: @vtree-color-sub; 95 | overflow: hidden; 96 | transform: scale(@scale) rotate(45deg); 97 | display: none; 98 | 99 | &::before { 100 | .pseudo(); 101 | } 102 | 103 | &::after { 104 | .pseudo(); 105 | transform: rotate(90deg); 106 | } 107 | } 108 | } 109 | 110 | @media(any-hover:hover) { 111 | &-input:hover &-icon-clear { 112 | display: block; 113 | } 114 | } 115 | } 116 | 117 | // 下拉框 118 | &__dropdown { 119 | position: absolute; 120 | top: -999px; 121 | left: -999px; 122 | box-shadow: 0 1px 6px fade(#000, 20%); 123 | border-radius: 4px; 124 | margin: 5px 0; 125 | padding: 5px 0; 126 | transform-origin: center top 0px; 127 | background-color: #fff; 128 | z-index: 9999; 129 | 130 | .@{tree-search-prefix} { 131 | // TreeSearch 搜索区域 132 | &__search { 133 | border-bottom: 1px solid @vtree-color-border; 134 | padding-bottom: 5px; 135 | height: 38px; 136 | } 137 | 138 | // TreeSearch 树区域 139 | &__tree-wrapper { 140 | padding-top: 5px; 141 | } 142 | } 143 | } 144 | } 145 | 146 | // Dropdown animation 147 | .@{tree-dropdown-prefix} { 148 | &-enter-from, 149 | &-leave-to { 150 | opacity: 0; 151 | transform: scaleY(0.8); 152 | } 153 | 154 | &-enter-active, 155 | &-leave-active { 156 | transition: opacity 0.3s, transform 0.3s; 157 | } 158 | } 159 | } 160 | 161 | #vtree-tree-drop-styles(); 162 | -------------------------------------------------------------------------------- /src/styles/tree-search.less: -------------------------------------------------------------------------------- 1 | #vtree-tree-search-styles () { 2 | // prefix 3 | @tree-search-prefix: ~'@{vtree-prefix}-tree-search'; 4 | 5 | // TreeSearch 6 | .@{tree-search-prefix} { 7 | @search-height: 42px; 8 | 9 | .search-wrapper () { 10 | // height: @search-height; 11 | display: flex; 12 | align-items: center; 13 | justify-content: center; 14 | } 15 | 16 | // 容器 17 | &__wrapper { 18 | width: 100%; 19 | height: 100%; 20 | position: relative; 21 | display: flex; 22 | flex-direction: column; 23 | } 24 | 25 | // 搜索区域 26 | &__search { 27 | height: @search-height; 28 | display: flex; 29 | box-sizing: border-box; 30 | } 31 | 32 | // 全选框容器 33 | &__check-all-wrapper { 34 | .search-wrapper(); 35 | justify-content: initial; 36 | width: 40px; 37 | } 38 | 39 | // 全选框 40 | &__check-all { 41 | margin-left: 23px; 42 | } 43 | 44 | // 搜索框容器 45 | &__input-wrapper { 46 | .search-wrapper(); 47 | padding-left: 5px; 48 | flex: 1; 49 | } 50 | 51 | // 搜索框 52 | &__input { 53 | color: @vtree-color-content; 54 | box-sizing: border-box; 55 | width: 100%; 56 | height: 32px; 57 | border: 1px solid @vtree-color-border; 58 | border-radius: 4px; 59 | padding: 4px 7px; 60 | outline: none; 61 | transition: border 0.2s ease-in-out, box-shadow 0.2s ease-in-out; 62 | 63 | &::placeholder { 64 | color: @vtree-color-input-placeholder; 65 | } 66 | 67 | @media(any-hover:hover) { 68 | &:hover { 69 | border-color: @vtree-color-input-border; 70 | } 71 | } 72 | 73 | &:focus { 74 | border-color: @vtree-color-input-border; 75 | box-shadow: 0 0 0 2px fade(@vtree-color-primary, 20%); 76 | } 77 | 78 | &_disabled { 79 | cursor: not-allowed; 80 | color: @vtree-color-input-disabled; 81 | background-color: @vtree-color-input-background-disabled; 82 | 83 | @media(any-hover:hover) { 84 | &:hover { 85 | border-color: @vtree-color-border; 86 | } 87 | } 88 | } 89 | } 90 | 91 | // 按钮容器 92 | &__action-wrapper { 93 | .search-wrapper(); 94 | padding: 0 10px; 95 | } 96 | 97 | // 已选按钮 98 | &__checked-button { 99 | cursor: pointer; 100 | 101 | @media(any-hover:hover) { 102 | &:hover { 103 | color: @vtree-color-light-primary; 104 | } 105 | } 106 | 107 | &_active { 108 | color: @vtree-color-primary; 109 | } 110 | } 111 | 112 | // 树区域 113 | &__tree-wrapper { 114 | width: 100%; 115 | flex: 1; 116 | overflow-y: auto; 117 | } 118 | 119 | // 底部信息 120 | &__footer { 121 | padding: 5px; 122 | } 123 | } 124 | } 125 | 126 | #vtree-tree-search-styles(); 127 | -------------------------------------------------------------------------------- /src/styles/variables.less: -------------------------------------------------------------------------------- 1 | @vtree-prefix: ~'vtree'; 2 | 3 | // Colors 4 | // Thanks to iviewui 5 | // https://www.iviewui.com/components/color 6 | @vtree-color-primary: #2d8cf0; 7 | @vtree-color-light-primary: #5cadff; 8 | @vtree-color-dark-primary: #2b85e4; 9 | 10 | @vtree-color-info: #2db7f5; 11 | @vtree-color-success: #19be6b; 12 | @vtree-color-warning: #ff9900; 13 | @vtree-color-error: #ed4014; 14 | 15 | @vtree-color-title: #17233d; 16 | @vtree-color-content: #515a6e; 17 | @vtree-color-sub: #808695; 18 | @vtree-color-disabled: #c5c8ce; 19 | @vtree-color-border: #dcdee2; 20 | @vtree-color-divider: #e8eaec; 21 | @vtree-color-background: #f8f8f9; 22 | 23 | @vtree-color-input-border: #57a3f3; 24 | @vtree-color-input-background-disabled: #f3f3f3; 25 | @vtree-color-input-disabled: #ccc; 26 | @vtree-color-input-placeholder: #c5c8ce; 27 | 28 | // Animations 29 | @keyframes vtree-animation-spin { 30 | from { 31 | // 使用 translate3d 强行启用 GPU 加速,缓解 CPU 计算 SVG 的压力 32 | transform: rotate(0deg) translate3d(0, 0, 0); 33 | } 34 | to { 35 | transform: rotate(360deg) translate3d(0, 0, 0); 36 | } 37 | } 38 | 39 | @keyframes vtree-animation-svg-circle-spin { 40 | 0% { 41 | stroke-dasharray: 1 130; 42 | stroke-dashoffset: 0; 43 | } 44 | 50% { 45 | stroke-dasharray: 90 130; 46 | stroke-dashoffset: -30; 47 | } 48 | 100% { 49 | stroke-dasharray: 90 130; 50 | stroke-dashoffset: -124; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | import { placementEnum, ignoreEnum, showLineType } from '../constants' 2 | 3 | import TreeStore, { TreeNode } from '../store' 4 | 5 | export type PlacementType = keyof typeof placementEnum 6 | 7 | export type TreeNodeKeyType = string | number 8 | 9 | export type GetNodeFn = (key: TreeNodeKeyType) => TreeNode | null 10 | 11 | export type IgnoreType = keyof typeof ignoreEnum 12 | 13 | export type LoadFn = (node: null | TreeNode, resolve: Function, reject: Function) => any 14 | 15 | export interface TreeDropSlotProps { 16 | /** 多选选中的节点 */ 17 | checkedNodes: TreeNode[] 18 | /** 多选选中的节点 key */ 19 | checkedKeys: TreeNodeKeyType[] 20 | /** 单选选中的节点 */ 21 | selectedNode?: TreeNode 22 | 23 | /** 单选选中的节点 key */ 24 | selectedKey?: TreeNodeKeyType 25 | } 26 | 27 | export type AnyPropsArrayType = Array<{ [key: string]: any }> 28 | 29 | export interface INonReactiveData { 30 | store: TreeStore 31 | blockNodes: TreeNode[] 32 | } 33 | 34 | // Utils to generate types like 1 | 2 | 3 35 | type Enumerate = Acc['length'] extends N 36 | ? Acc[number] 37 | : Enumerate 38 | 39 | type IntRange = Exclude, Enumerate> 40 | 41 | export interface ShowLine { 42 | /** 连接线宽度,svg stroke-width, 默认 1px */ 43 | width?: number 44 | type?: showLineType 45 | color?: string 46 | polyline?: boolean 47 | dashDensity?: IntRange<1, 11> 48 | } 49 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { Ref } from "vue"; 2 | 3 | export const getVTreeMethods = >(apiMethods: readonly T[], ref: Ref): Pick => { 4 | return apiMethods.reduce((prev, cur) => { 5 | const fn = (...args: any[]) => { 6 | const value = ref.value?.[cur] 7 | if (typeof value !== 'function') return 8 | return value(...args) 9 | } 10 | prev[cur] = fn as K[T] 11 | return prev 12 | }, {} as Pick) 13 | } 14 | 15 | type PickReadonly = { 16 | readonly [P in K]: T[P]; 17 | }; 18 | 19 | export const pickReadonly = ( 20 | obj: Readonly, 21 | ...keys: K[] 22 | ): PickReadonly => { 23 | const picked: Partial> = {} 24 | keys.forEach(key => { 25 | if (obj.hasOwnProperty(key)) { 26 | picked[key] = obj[key] 27 | } 28 | }) 29 | return picked as PickReadonly 30 | } 31 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tests/tree-data-generator.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker' 2 | 3 | export interface ITreeNodeData { 4 | title?: string | number 5 | id?: string | number 6 | checked?: boolean 7 | indeterminate?: boolean 8 | selected?: boolean 9 | disabled?: boolean 10 | children?: ITreeNodeData[] 11 | [key: string]: any 12 | } 13 | 14 | interface IGeneratorOptions { 15 | treeDepth?: number 16 | nodesPerLevel?: number 17 | sameIdTitle?: boolean 18 | inOrder?: boolean 19 | forceString?: boolean 20 | useNanoID?: boolean 21 | } 22 | 23 | const genRandomStr = ({ index, useNanoID }: { index?: number; useNanoID?: boolean }): string => { 24 | if (useNanoID) { 25 | return faker.string.nanoid() 26 | } 27 | const randomStr = faker.person.lastName() 28 | return index == null ? randomStr : `${randomStr} - (${index})` 29 | } 30 | 31 | export default ({ 32 | treeDepth = 5, 33 | nodesPerLevel, 34 | sameIdTitle = false, 35 | inOrder = false, 36 | forceString = false, 37 | useNanoID = false, 38 | }: IGeneratorOptions = {}): { data: ITreeNodeData[]; total: number } => { 39 | let data: ITreeNodeData[] = [] 40 | let total = 0 41 | let orderCount = 0 42 | const genNodeData = (root: ITreeNodeData[], level: number = 0): void => { 43 | if (level >= treeDepth) return 44 | const len: number = nodesPerLevel 45 | ? nodesPerLevel 46 | : Math.floor(Math.random() * 100) 47 | for (let i: number = 0; i < len; i++) { 48 | let title = inOrder ? orderCount : genRandomStr({ index: orderCount }) 49 | if (forceString) title = title.toString() 50 | const id = sameIdTitle ? title : genRandomStr({ index: orderCount, useNanoID }) 51 | const node = { 52 | title, 53 | id, 54 | children: [] 55 | } 56 | root.push(node) 57 | total++ 58 | orderCount++ 59 | genNodeData(node.children, level + 1) 60 | } 61 | } 62 | genNodeData(data) 63 | return { 64 | data, 65 | total 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /tests/unit/tree-search.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | 3 | import { shallowMount, mount } from '@vue/test-utils' 4 | import VTreeSearch from '../../src/components/TreeSearch.vue' 5 | import VTree from '../../src/components/Tree.vue' 6 | import treeDataGenerator, { ITreeNodeData } from '../tree-data-generator' 7 | import TreeStore, { TreeNode } from '../../src/store' 8 | 9 | //#region 通用方法 10 | 11 | const genData = (extra: object = {}) => { 12 | return treeDataGenerator( 13 | Object.assign( 14 | { 15 | treeDepth: 3, 16 | nodesPerLevel: 5, 17 | sameIdTitle: true 18 | }, 19 | extra 20 | ) 21 | ) 22 | } 23 | 24 | const asyncLoadData = ( 25 | node: TreeNode | null, 26 | resolve: Function, 27 | reject: Function 28 | ) => { 29 | setTimeout(() => { 30 | let result = [] as any[] 31 | if (node === null) { 32 | result = genData({ 33 | treeDepth: 1, 34 | nodesPerLevel: 5, 35 | sameIdTitle: true 36 | }).data 37 | } else { 38 | result = genData({ 39 | treeDepth: 1, 40 | nodesPerLevel: 2, 41 | sameIdTitle: true 42 | }).data 43 | } 44 | resolve(result) 45 | }, 100) 46 | } 47 | 48 | //#endregion 通用方法 49 | 50 | describe('树搜索测试', () => { 51 | it('本地搜索', () => new Promise(done => { 52 | const data = genData({ inOrder: true }).data 53 | const wrapper = mount(VTreeSearch as any, { 54 | propsData: { data } 55 | }) 56 | const vm = wrapper.vm 57 | const tree = wrapper.findComponent({ ref: 'treeRef' }).vm 58 | 59 | const input = wrapper.find('.vtree-tree-search__input') 60 | const inputElement = input.element as HTMLInputElement 61 | inputElement.value = '30' 62 | input.trigger('input') 63 | 64 | setTimeout(() => { 65 | vm.$nextTick(() => { 66 | expect( 67 | (tree as any).nonReactive.store.flatData 68 | .filter((node: TreeNode) => node.visible) 69 | .map((node: TreeNode) => node.id) 70 | ).toEqual([0, 25, 30, 124, 125, 130]) 71 | 72 | inputElement.value = '' 73 | input.trigger('input') 74 | 75 | setTimeout(() => { 76 | vm.$nextTick(() => { 77 | expect( 78 | (tree as any).nonReactive.store.flatData 79 | .filter((node: TreeNode) => node.visible) 80 | .map((node: TreeNode) => node.id) 81 | ).toEqual( 82 | (tree as any).nonReactive.store.flatData.map( 83 | (node: TreeNode) => node.id 84 | ) 85 | ) 86 | 87 | done() 88 | }) 89 | }, 300) 90 | }) 91 | }, 300) 92 | })) 93 | }) 94 | 95 | describe('树远程搜索增强测试包', () => { 96 | it('远程搜索', () => new Promise(done => { 97 | const times = [3, 2, 5] 98 | let index = 0 99 | const load = ( 100 | node: TreeNode | null, 101 | resolve: Function, 102 | reject: Function 103 | ) => { 104 | setTimeout(() => { 105 | const data = genData({ 106 | inOrder: true, 107 | forceString: true, 108 | nodesPerLevel: times[index] 109 | }).data 110 | resolve(data) 111 | }, 10) 112 | } 113 | const wrapper = mount(VTreeSearch as any, { 114 | propsData: { 115 | load, 116 | searchRemote: true, 117 | checkable: true, 118 | modelValue: ['93', '124'] 119 | }, 120 | attrs: { 121 | 'onUpdate:modelValue': (emitValue: Array) => { 122 | wrapper.setProps({ modelValue: emitValue }) 123 | } 124 | } 125 | }) as any 126 | const vm = wrapper.vm 127 | const treeWrapper = wrapper.findComponent({ ref: 'treeRef' }) as any 128 | 129 | const input = wrapper.find('.vtree-tree-search__input') 130 | const inputElement = input.element as HTMLInputElement 131 | inputElement.value = '30' 132 | 133 | const initValue = [ 134 | '93', 135 | '94', 136 | '95', 137 | '96', 138 | '97', 139 | '98', 140 | '99', 141 | '100', 142 | '101', 143 | '102', 144 | '103', 145 | '104', 146 | '105', 147 | '106', 148 | '107', 149 | '108', 150 | '109', 151 | '110', 152 | '111', 153 | '112', 154 | '113', 155 | '114', 156 | '115', 157 | '116', 158 | '117', 159 | '118', 160 | '119', 161 | '120', 162 | '121', 163 | '122', 164 | '123', 165 | '124', 166 | '125', 167 | '126', 168 | '127', 169 | '128', 170 | '129', 171 | '130', 172 | '131', 173 | '132', 174 | '133', 175 | '134', 176 | '135', 177 | '136', 178 | '137', 179 | '138', 180 | '139', 181 | '140', 182 | '141', 183 | '142', 184 | '143', 185 | '144', 186 | '145', 187 | '146', 188 | '147', 189 | '148', 190 | '149', 191 | '150', 192 | '151', 193 | '152', 194 | '153', 195 | '154' 196 | ] 197 | 198 | setTimeout(() => { 199 | vm.$nextTick(() => { 200 | // index = 0 201 | expect(treeWrapper.props('modelValue')).toEqual(['93', '124']) 202 | 203 | index = 1 204 | 205 | input.trigger('input') 206 | 207 | setTimeout(() => { 208 | vm.$nextTick(() => { 209 | expect(treeWrapper.props('modelValue')).toEqual(['93', '124']) 210 | setTimeout(() => { 211 | vm.setChecked('4', true) 212 | vm.$nextTick(() => { 213 | expect(treeWrapper.vm.modelValue).toEqual([ 214 | '4', 215 | '5', 216 | '6', 217 | '93', 218 | '124' 219 | ]) 220 | 221 | index = 2 222 | 223 | input.trigger('input') 224 | 225 | setTimeout(() => { 226 | vm.$nextTick(() => { 227 | // 5 228 | expect(treeWrapper.props('modelValue')).toEqual( 229 | ['4', '5', '6'].concat(initValue) 230 | ) 231 | 232 | done() 233 | }) 234 | }, 340) 235 | }) 236 | }, 50) 237 | }) 238 | }, 320) 239 | }) 240 | }, 15) 241 | })) 242 | }) 243 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "target": "ESNext", 5 | "useDefineForClassFields": true, 6 | "module": "ESNext", 7 | "moduleResolution": "Node", 8 | "strict": true, 9 | "noImplicitAny": true, 10 | "jsx": "preserve", 11 | "jsxImportSource": "vue", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "esModuleInterop": true, 15 | "lib": ["ESNext", "DOM"], 16 | "skipLibCheck": true, 17 | "declaration": true, 18 | "rootDir": "./src", 19 | "declarationDir": "types" 20 | }, 21 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], 22 | "references": [{ "path": "./tsconfig.node.json" }] 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "linterOptions": { 7 | "exclude": [ 8 | "node_modules/**" 9 | ] 10 | }, 11 | "rules": { 12 | "quotemark": [true, "single"], 13 | "indent": [true, "spaces", 2], 14 | "interface-name": false, 15 | "ordered-imports": false, 16 | "object-literal-sort-keys": false, 17 | "no-consecutive-blank-lines": false, 18 | "semicolon": [true, "never"], 19 | "space-before-function-paren": false, 20 | "curly": false, 21 | "no-unused-expression": false, 22 | "max-line-length": false, 23 | "member-access": false, 24 | "forin": false, 25 | "prefer-const": false, 26 | "no-shadowed-variable": false, 27 | "object-literal-shorthand": false, 28 | "member-ordering": false, 29 | "no-empty": false, 30 | "variable-name": false, 31 | "ban-types": false 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /types/components/LoadingIcon.vue.d.ts: -------------------------------------------------------------------------------- 1 | declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly>, {}, {}>; 2 | export default _default; 3 | -------------------------------------------------------------------------------- /types/components/TreeNode.vue.d.ts: -------------------------------------------------------------------------------- 1 | import { TreeNode } from '../store'; 2 | import { TreeProps } from './Tree.vue'; 3 | import { GetNodeFn } from '../types'; 4 | type PickedProps = Required> & Pick; 5 | export type TreeNodeProps = PickedProps & { 6 | data: TreeNode; 7 | getNode: GetNodeFn; 8 | noSiblingNodeMap: Record; 9 | }; 10 | declare const _default: __VLS_WithTemplateSlots, {}, unknown, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, { 11 | [x: string]: (...args: any[]) => void; 12 | }, string, import("vue").PublicProps, Readonly>>, {}, {}>, { 13 | default?(_: { 14 | node: TreeNode; 15 | }): any; 16 | }>; 17 | export default _default; 18 | type __VLS_WithTemplateSlots = T & { 19 | new (): { 20 | $slots: S; 21 | }; 22 | }; 23 | type __VLS_NonUndefinedable = T extends undefined ? never : T; 24 | type __VLS_TypePropsToOption = { 25 | [K in keyof T]-?: {} extends Pick ? { 26 | type: import('vue').PropType<__VLS_NonUndefinedable>; 27 | } : { 28 | type: import('vue').PropType; 29 | required: true; 30 | }; 31 | }; 32 | -------------------------------------------------------------------------------- /types/const.d.ts: -------------------------------------------------------------------------------- 1 | import { IEventNames } from "./store/tree-store"; 2 | export declare enum ignoreEnum { 3 | none = "none", 4 | parents = "parents", 5 | children = "children" 6 | } 7 | export declare const TREE_API_METHODS: readonly ["setData", "setChecked", "setCheckedKeys", "checkAll", "clearChecked", "setSelected", "clearSelected", "setExpand", "setExpandKeys", "setExpandAll", "getCheckedNodes", "getCheckedKeys", "getIndeterminateNodes", "getSelectedNode", "getSelectedKey", "getExpandNodes", "getExpandKeys", "getCurrentVisibleNodes", "getNode", "getTreeData", "getFlatData", "getNodesCount", "insertBefore", "insertAfter", "append", "prepend", "remove", "filter", "showCheckedNodes", "loadRootNodes", "scrollTo"]; 8 | export declare const TREE_SEARCH_API_METHODS: readonly ["setData", "setChecked", "setCheckedKeys", "checkAll", "clearChecked", "setSelected", "clearSelected", "setExpand", "setExpandKeys", "setExpandAll", "getCheckedNodes", "getCheckedKeys", "getIndeterminateNodes", "getSelectedNode", "getSelectedKey", "getExpandNodes", "getExpandKeys", "getCurrentVisibleNodes", "getNode", "getTreeData", "getFlatData", "getNodesCount", "insertBefore", "insertAfter", "append", "prepend", "remove", "filter", "showCheckedNodes", "loadRootNodes", "scrollTo", "clearKeyword", "getKeyword", "search"]; 9 | export declare enum placementEnum { 10 | 'bottom-start' = "bottom-start", 11 | 'bottom-end' = "bottom-end", 12 | 'bottom' = "bottom", 13 | 'top-start' = "top-start", 14 | 'top-end' = "top-end", 15 | 'top' = "top" 16 | } 17 | export declare enum verticalPositionEnum { 18 | top = "top", 19 | center = "center", 20 | bottom = "bottom" 21 | } 22 | export type VerticalPositionType = keyof typeof verticalPositionEnum; 23 | export declare enum dragHoverPartEnum { 24 | before = "before", 25 | body = "body", 26 | after = "after" 27 | } 28 | export declare const TREE_NODE_EVENTS: string[]; 29 | export declare const storeEvents: Array; 30 | -------------------------------------------------------------------------------- /types/constants/events.d.ts: -------------------------------------------------------------------------------- 1 | import { IEventNames } from "../store/tree-event-target"; 2 | export declare const TREE_NODE_EVENTS: string[]; 3 | export declare const STORE_EVENTS: Array; 4 | export declare const TREE_EVENTS: string[]; 5 | export declare const TREE_SEARCH_EVENTS: string[]; 6 | export declare const TREE_DROP_EVENTS: string[]; 7 | -------------------------------------------------------------------------------- /types/constants/index.d.ts: -------------------------------------------------------------------------------- 1 | export declare enum ignoreEnum { 2 | none = "none", 3 | parents = "parents", 4 | children = "children" 5 | } 6 | export declare const TREE_API_METHODS: readonly ["setData", "setChecked", "setCheckedKeys", "checkAll", "clearChecked", "setSelected", "clearSelected", "setExpand", "setExpandKeys", "setExpandAll", "getCheckedNodes", "getCheckedKeys", "getIndeterminateNodes", "getSelectedNode", "getSelectedKey", "getExpandNodes", "getExpandKeys", "getCurrentVisibleNodes", "getNode", "getTreeData", "getFlatData", "getNodesCount", "insertBefore", "insertAfter", "append", "prepend", "remove", "filter", "showCheckedNodes", "loadRootNodes", "updateNode", "updateNodes", "scrollTo"]; 7 | export declare const TREE_SEARCH_API_METHODS: readonly ["setData", "setChecked", "setCheckedKeys", "checkAll", "clearChecked", "setSelected", "clearSelected", "setExpand", "setExpandKeys", "setExpandAll", "getCheckedNodes", "getCheckedKeys", "getIndeterminateNodes", "getSelectedNode", "getSelectedKey", "getExpandNodes", "getExpandKeys", "getCurrentVisibleNodes", "getNode", "getTreeData", "getFlatData", "getNodesCount", "insertBefore", "insertAfter", "append", "prepend", "remove", "filter", "showCheckedNodes", "loadRootNodes", "updateNode", "updateNodes", "scrollTo", "clearKeyword", "getKeyword", "search"]; 8 | export declare enum placementEnum { 9 | 'bottom-start' = "bottom-start", 10 | 'bottom-end' = "bottom-end", 11 | 'bottom' = "bottom", 12 | 'top-start' = "top-start", 13 | 'top-end' = "top-end", 14 | 'top' = "top" 15 | } 16 | export declare enum verticalPositionEnum { 17 | top = "top", 18 | center = "center", 19 | bottom = "bottom" 20 | } 21 | export type VerticalPositionType = keyof typeof verticalPositionEnum; 22 | export declare enum dragHoverPartEnum { 23 | before = "before", 24 | body = "body", 25 | after = "after" 26 | } 27 | export declare enum showLineType { 28 | dashed = "dashed", 29 | solid = "solid" 30 | } 31 | -------------------------------------------------------------------------------- /types/hooks/useExpandAnimation.d.ts: -------------------------------------------------------------------------------- 1 | import { Ref } from "vue"; 2 | import { TreeNode } from "../store"; 3 | import { TreeProps } from "../components/Tree.vue"; 4 | type IUseExpandAnimationProps = Required>; 5 | export declare const useExpandAnimation: (renderNodesRef: Ref, renderStartRef: Ref, props: IUseExpandAnimationProps) => { 6 | ready: Ref; 7 | currentExpandState: Ref; 8 | topNodes: Ref<{ 9 | [x: string]: any; 10 | _level: number; 11 | checked: boolean; 12 | selected: boolean; 13 | indeterminate: boolean; 14 | disabled: boolean; 15 | expand: boolean; 16 | visible: boolean; 17 | _filterVisible: boolean; 18 | _parent: any | null; 19 | children: any[]; 20 | isLeaf: boolean; 21 | _loading: boolean; 22 | _loaded: boolean; 23 | readonly _keyField: string; 24 | readonly _remote: boolean; 25 | setChildren: (children: import("../store/tree-node").ITreeNodeOptions[]) => void; 26 | }[]>; 27 | middleNodes: Ref<{ 28 | [x: string]: any; 29 | _level: number; 30 | checked: boolean; 31 | selected: boolean; 32 | indeterminate: boolean; 33 | disabled: boolean; 34 | expand: boolean; 35 | visible: boolean; 36 | _filterVisible: boolean; 37 | _parent: any | null; 38 | children: any[]; 39 | isLeaf: boolean; 40 | _loading: boolean; 41 | _loaded: boolean; 42 | readonly _keyField: string; 43 | readonly _remote: boolean; 44 | setChildren: (children: import("../store/tree-node").ITreeNodeOptions[]) => void; 45 | }[]>; 46 | bottomNodes: Ref<{ 47 | [x: string]: any; 48 | _level: number; 49 | checked: boolean; 50 | selected: boolean; 51 | indeterminate: boolean; 52 | disabled: boolean; 53 | expand: boolean; 54 | visible: boolean; 55 | _filterVisible: boolean; 56 | _parent: any | null; 57 | children: any[]; 58 | isLeaf: boolean; 59 | _loading: boolean; 60 | _loaded: boolean; 61 | readonly _keyField: string; 62 | readonly _remote: boolean; 63 | setChildren: (children: import("../store/tree-node").ITreeNodeOptions[]) => void; 64 | }[]>; 65 | updateBeforeExpand: (nodeToExpand: TreeNode) => void; 66 | updateAfterExpand: () => void; 67 | onExpandAnimationFinish: () => void; 68 | }; 69 | export {}; 70 | -------------------------------------------------------------------------------- /types/hooks/useIframeResize.d.ts: -------------------------------------------------------------------------------- 1 | export declare const useIframeResize: (resizeCallback: () => void) => void; 2 | -------------------------------------------------------------------------------- /types/hooks/usePublicTreeAPI.d.ts: -------------------------------------------------------------------------------- 1 | import { AnyPropsArrayType, INonReactiveData, IgnoreType, TreeNodeKeyType } from "../types"; 2 | import { TreeNode } from ".."; 3 | import { ITreeNodeOptions } from "../store/tree-node"; 4 | import { FilterFunctionType } from "../store/tree-store"; 5 | import { TreeProps } from "../components/Tree.vue"; 6 | type IUsePublicTreeAPIProps = Required> & Pick; 7 | export declare const usePublicTreeAPI: (nonReactive: INonReactiveData, props: IUsePublicTreeAPIProps, options: { 8 | resetSpaceHeights: () => void; 9 | updateExpandedKeys: () => void; 10 | updateBlockData: () => void; 11 | updateRender: () => void; 12 | }) => { 13 | unloadCheckedNodes: import("vue").Ref<{ 14 | [x: string]: any; 15 | _level: number; 16 | checked: boolean; 17 | selected: boolean; 18 | indeterminate: boolean; 19 | disabled: boolean; 20 | expand: boolean; 21 | visible: boolean; 22 | _filterVisible: boolean; 23 | _parent: any | null; 24 | children: any[]; 25 | isLeaf: boolean; 26 | _loading: boolean; 27 | _loaded: boolean; 28 | readonly _keyField: string; 29 | readonly _remote: boolean; 30 | setChildren: (children: ITreeNodeOptions[]) => void; 31 | }[]>; 32 | isRootLoading: import("vue").Ref; 33 | setData: (data: AnyPropsArrayType) => void; 34 | setChecked: (key: TreeNodeKeyType, value: boolean) => void; 35 | setCheckedKeys: (keys: TreeNodeKeyType[], value: boolean) => void; 36 | checkAll: () => void; 37 | clearChecked: () => void; 38 | setSelected: (key: TreeNodeKeyType, value: boolean) => void; 39 | clearSelected: () => void; 40 | setExpand: (key: TreeNodeKeyType, value: boolean, expandParent?: boolean) => void; 41 | setExpandKeys: (keys: TreeNodeKeyType[], value: boolean) => void; 42 | setExpandAll: (value: boolean) => void; 43 | getCheckedNodes: (ignoreMode?: IgnoreType) => TreeNode[]; 44 | getCheckedKeys: (ignoreMode?: IgnoreType) => TreeNodeKeyType[]; 45 | getIndeterminateNodes: () => TreeNode[]; 46 | getSelectedNode: () => TreeNode | null; 47 | getSelectedKey: () => TreeNodeKeyType | null; 48 | getExpandNodes: () => TreeNode[]; 49 | getExpandKeys: () => TreeNodeKeyType[]; 50 | getCurrentVisibleNodes: () => TreeNode[]; 51 | getNode: (key: TreeNodeKeyType) => TreeNode | null; 52 | getTreeData: () => TreeNode[]; 53 | getFlatData: () => TreeNode[]; 54 | getNodesCount: () => number; 55 | insertBefore: (insertedNode: TreeNodeKeyType | ITreeNodeOptions, referenceKey: TreeNodeKeyType) => TreeNode | null; 56 | insertAfter: (insertedNode: TreeNodeKeyType | ITreeNodeOptions, referenceKey: TreeNodeKeyType) => TreeNode | null; 57 | append: (insertedNode: TreeNodeKeyType | ITreeNodeOptions, parentKey: TreeNodeKeyType) => TreeNode | null; 58 | prepend: (insertedNode: TreeNodeKeyType | ITreeNodeOptions, parentKey: TreeNodeKeyType) => TreeNode | null; 59 | remove: (removedKey: TreeNodeKeyType) => TreeNode | null; 60 | filter: (keyword: string, filterMethod?: FilterFunctionType) => void; 61 | showCheckedNodes: (showUnloadCheckedNodes?: boolean) => void; 62 | loadRootNodes: () => Promise; 63 | updateNode: (key: TreeNodeKeyType, newNode: ITreeNodeOptions) => void; 64 | updateNodes: (newNodes: ITreeNodeOptions[]) => void; 65 | }; 66 | export {}; 67 | -------------------------------------------------------------------------------- /types/hooks/useTreeCls.d.ts: -------------------------------------------------------------------------------- 1 | declare const prefixCls = "vtree-tree"; 2 | export { prefixCls as TREE_PREFIX_CLS }; 3 | export declare const useTreeCls: () => { 4 | wrapperCls: import("vue").ComputedRef; 5 | scrollAreaCls: import("vue").ComputedRef; 6 | blockAreaCls: import("vue").ComputedRef; 7 | emptyCls: import("vue").ComputedRef; 8 | emptyTextDefaultCls: import("vue").ComputedRef; 9 | loadingCls: import("vue").ComputedRef; 10 | loadingWrapperCls: import("vue").ComputedRef; 11 | loadingIconCls: import("vue").ComputedRef; 12 | iframeCls: import("vue").ComputedRef; 13 | }; 14 | -------------------------------------------------------------------------------- /types/hooks/useTreeDropCls.d.ts: -------------------------------------------------------------------------------- 1 | import { Ref } from 'vue'; 2 | import { TreeDropProps } from '../components/TreeDrop.vue'; 3 | declare const prefixCls = "vtree-tree-drop"; 4 | export { prefixCls as TREE_DROP_PREFIX_CLS }; 5 | export declare const useTreeDropCls: (props: TreeDropProps, options: { 6 | dropdownVisible: Ref; 7 | checkedCount: Ref; 8 | selectedTitle: Ref; 9 | }) => { 10 | wrapperCls: import("vue").ComputedRef; 11 | referenceCls: import("vue").ComputedRef; 12 | displayInputCls: import("vue").ComputedRef<(string | { 13 | "vtree-tree-drop__display-input_focus": boolean; 14 | "vtree-tree-search__input_disabled": boolean | undefined; 15 | })[]>; 16 | displayInputTextCls: import("vue").ComputedRef<(string | { 17 | "vtree-tree-drop__display-input-placeholder": boolean; 18 | })[]>; 19 | dropIconCls: import("vue").ComputedRef<(string | { 20 | "vtree-tree-drop__display-icon-drop_active": boolean; 21 | })[]>; 22 | clearIconCls: import("vue").ComputedRef; 23 | dropdownCls: import("vue").ComputedRef<(string | undefined)[]>; 24 | }; 25 | -------------------------------------------------------------------------------- /types/hooks/useTreeNodeCls.d.ts: -------------------------------------------------------------------------------- 1 | import { Ref } from "vue"; 2 | import { TreeNodeProps } from "../components/TreeNode.vue"; 3 | declare const prefixCls = "vtree-tree-node"; 4 | export { prefixCls as TREE_NODE_PREFIX_CLS }; 5 | export declare const useTreeNodeCls: (props: TreeNodeProps, dragoverRefs: { 6 | dragoverBody: Ref; 7 | dragoverBefore: Ref; 8 | dragoverAfter: Ref; 9 | }) => { 10 | indentWrapperCls: import("vue").ComputedRef; 11 | wrapperCls: import("vue").ComputedRef<(string | { 12 | "vtree-tree-node__wrapper_is-leaf": boolean; 13 | "vtree-tree-node_disabled": boolean; 14 | "vtree-tree-node_checked"?: undefined; 15 | "vtree-tree-node_indeterminate"?: undefined; 16 | "vtree-tree-node_selected"?: undefined; 17 | } | { 18 | "vtree-tree-node_checked": boolean; 19 | "vtree-tree-node_indeterminate": boolean; 20 | "vtree-tree-node__wrapper_is-leaf"?: undefined; 21 | "vtree-tree-node_disabled"?: undefined; 22 | "vtree-tree-node_selected"?: undefined; 23 | } | { 24 | "vtree-tree-node_selected": boolean; 25 | "vtree-tree-node__wrapper_is-leaf"?: undefined; 26 | "vtree-tree-node_disabled"?: undefined; 27 | "vtree-tree-node_checked"?: undefined; 28 | "vtree-tree-node_indeterminate"?: undefined; 29 | })[]>; 30 | nodeBodyCls: import("vue").ComputedRef<(string | { 31 | "vtree-tree-node__drop_active": boolean; 32 | })[]>; 33 | dropBeforeCls: import("vue").ComputedRef<(string | { 34 | "vtree-tree-node__drop_active": boolean; 35 | })[]>; 36 | dropAfterCls: import("vue").ComputedRef<(string | { 37 | "vtree-tree-node__drop_active": boolean; 38 | })[]>; 39 | checkboxWrapperCls: import("vue").ComputedRef; 40 | expandCls: import("vue").ComputedRef<(string | { 41 | "vtree-tree-node__expand_active": boolean; 42 | })[]>; 43 | loadingIconCls: import("vue").ComputedRef; 44 | checkboxCls: import("vue").ComputedRef<(string | { 45 | "vtree-tree-node__checkbox_checked": boolean; 46 | "vtree-tree-node__checkbox_indeterminate": boolean; 47 | "vtree-tree-node__checkbox_disabled": boolean; 48 | })[]>; 49 | titleCls: import("vue").ComputedRef<(string | { 50 | "vtree-tree-node__title_selected": boolean; 51 | "vtree-tree-node__title_disabled": boolean; 52 | })[]>; 53 | }; 54 | -------------------------------------------------------------------------------- /types/hooks/useTreeSearchCls.d.ts: -------------------------------------------------------------------------------- 1 | import { Ref } from 'vue'; 2 | import { TreeSearchProps } from '../components/TreeSearch.vue'; 3 | declare const prefixCls = "vtree-tree-search"; 4 | export { prefixCls as TREE_SEARCH_PREFIX_CLS }; 5 | export declare const useTreeSearchCls: (props: TreeSearchProps, options: { 6 | checkAllStatus: { 7 | checked: boolean; 8 | indeterminate: boolean; 9 | disabled: boolean; 10 | }; 11 | isShowingChecked: Ref; 12 | }) => { 13 | wrapperCls: import("vue").ComputedRef; 14 | searchCls: import("vue").ComputedRef; 15 | checkAllWrapperCls: import("vue").ComputedRef; 16 | checkboxCls: import("vue").ComputedRef<(string | { 17 | "vtree-tree-node__checkbox_checked": boolean; 18 | "vtree-tree-node__checkbox_indeterminate": boolean; 19 | "vtree-tree-node__checkbox_disabled": boolean; 20 | })[]>; 21 | inputWrapperCls: import("vue").ComputedRef; 22 | inputCls: import("vue").ComputedRef<(string | { 23 | "vtree-tree-search__input_disabled": boolean | undefined; 24 | })[]>; 25 | actionWrapperCls: import("vue").ComputedRef; 26 | checkedButtonCls: import("vue").ComputedRef<(string | { 27 | "vtree-tree-search__checked-button_active": boolean; 28 | })[]>; 29 | treeWrapperCls: import("vue").ComputedRef; 30 | footerCls: import("vue").ComputedRef; 31 | }; 32 | -------------------------------------------------------------------------------- /types/hooks/useVirtualList.d.ts: -------------------------------------------------------------------------------- 1 | import { INonReactiveData, TreeNodeKeyType } from "../types"; 2 | import { VerticalPositionType } from "../constants"; 3 | import { TreeProps } from "../components/Tree.vue"; 4 | type IUseVirtualListProps = Required>; 5 | export declare const useVirtualList: (nonReactive: INonReactiveData, props: IUseVirtualListProps) => { 6 | scrollArea: import("vue").Ref; 7 | renderNodes: import("vue").Ref<{ 8 | [x: string]: any; 9 | _level: number; 10 | checked: boolean; 11 | selected: boolean; 12 | indeterminate: boolean; 13 | disabled: boolean; 14 | expand: boolean; 15 | visible: boolean; 16 | _filterVisible: boolean; 17 | _parent: any | null; 18 | children: any[]; 19 | isLeaf: boolean; 20 | _loading: boolean; 21 | _loaded: boolean; 22 | readonly _keyField: string; 23 | readonly _remote: boolean; 24 | setChildren: (children: import("../store/tree-node").ITreeNodeOptions[]) => void; 25 | }[]>; 26 | blockLength: import("vue").Ref; 27 | blockAreaHeight: import("vue").Ref; 28 | topSpaceHeight: import("vue").Ref; 29 | bottomSpaceHeight: import("vue").Ref; 30 | renderAmount: import("vue").Ref; 31 | renderAmountCache: import("vue").Ref; 32 | renderStart: import("vue").Ref; 33 | renderStartCache: import("vue").Ref; 34 | resetSpaceHeights: () => void; 35 | updateRenderAmount: () => void; 36 | updateRenderNodes: (isScroll?: boolean) => void; 37 | updateRender: () => void; 38 | updateBlockNodes: () => void; 39 | updateBlockData: () => void; 40 | handleTreeScroll: () => void; 41 | scrollTo: (key: TreeNodeKeyType, verticalPosition?: VerticalPositionType | number) => void; 42 | }; 43 | export {}; 44 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | import VTree from './components/Tree.vue'; 2 | export { default as VTreeNode } from './components/TreeNode.vue'; 3 | export { default as VTreeSearch } from './components/TreeSearch.vue'; 4 | export { default as VTreeDrop } from './components/TreeDrop.vue'; 5 | export { TreeNode } from './store'; 6 | export { default as TreeStore } from './store'; 7 | import './styles/index.less'; 8 | export * from './types'; 9 | export default VTree; 10 | -------------------------------------------------------------------------------- /types/store/index.d.ts: -------------------------------------------------------------------------------- 1 | import TreeStore from './tree-store'; 2 | import TreeNode from './tree-node'; 3 | export default TreeStore; 4 | export { TreeNode }; 5 | -------------------------------------------------------------------------------- /types/store/tree-event-target.d.ts: -------------------------------------------------------------------------------- 1 | import { TreeNodeKeyType } from '../types'; 2 | import TreeNode from './tree-node'; 3 | type NodeGeneralListenerType = (node: TreeNode) => void; 4 | export type ListenerType = IEventNames[T]; 5 | export interface IEventNames { 6 | 'set-data': () => void; 7 | 'visible-data-change': () => void; 8 | 'render-data-change': () => void; 9 | expand: NodeGeneralListenerType; 10 | select: NodeGeneralListenerType; 11 | unselect: NodeGeneralListenerType; 12 | 'selected-change': (node: TreeNode | null, key: TreeNodeKeyType | null) => void; 13 | check: NodeGeneralListenerType; 14 | uncheck: NodeGeneralListenerType; 15 | 'checked-change': (nodes: TreeNode[], keys: TreeNodeKeyType[]) => void; 16 | } 17 | export default class TreeEventTarget { 18 | /** 事件 listeners */ 19 | private listenersMap; 20 | on(eventName: T, listener: ListenerType | Array>): void; 21 | off(eventName: T, listener?: ListenerType): void; 22 | emit(eventName: T, ...args: Parameters): void; 23 | disposeListeners(): void; 24 | } 25 | export {}; 26 | -------------------------------------------------------------------------------- /types/store/tree-node.d.ts: -------------------------------------------------------------------------------- 1 | import { TreeNodeKeyType } from '../types'; 2 | interface IKeyOption { 3 | [key: string]: TreeNodeKeyType; 4 | } 5 | export interface ITreeNodeOptions extends IKeyOption { 6 | [key: string]: any; 7 | } 8 | export default class TreeNode { 9 | readonly _keyField: string; 10 | readonly _remote: boolean; 11 | [key: string]: any | TreeNodeKeyType; 12 | /** 节点层级 */ 13 | _level: number; 14 | /** 多选是否选中 */ 15 | checked: boolean; 16 | /** 单选是否选中 */ 17 | selected: boolean; 18 | /** 是否半选状态 */ 19 | indeterminate: boolean; 20 | /** 是否禁用 */ 21 | disabled: boolean; 22 | /** 是否展开 */ 23 | expand: boolean; 24 | /** 是否可见 */ 25 | visible: boolean; 26 | /** 过滤后是否可见,如果为 false 则在其他可见情况下也是不可见的 */ 27 | _filterVisible: boolean; 28 | /** 父节点 */ 29 | _parent: null | TreeNode; 30 | /** 子节点 */ 31 | children: TreeNode[]; 32 | /** 是否是子节点 */ 33 | isLeaf: boolean; 34 | /** 节点是否正在加载 */ 35 | _loading: boolean; 36 | /** 子节点是否已加载 */ 37 | _loaded: boolean; 38 | constructor(options: ITreeNodeOptions, parent?: null | TreeNode, _keyField?: string, _remote?: boolean); 39 | /** 40 | * 设置子节点 41 | * @param children 子节点数据数组 42 | */ 43 | setChildren(children: ITreeNodeOptions[]): void; 44 | } 45 | export {}; 46 | -------------------------------------------------------------------------------- /types/types.d.ts: -------------------------------------------------------------------------------- 1 | import { placementEnum, ignoreEnum } from './const'; 2 | import { TreeNode } from './store'; 3 | export type PlacementType = keyof typeof placementEnum; 4 | export type TreeNodeKeyType = string | number; 5 | export type GetNodeFn = (key: TreeNodeKeyType) => TreeNode | null; 6 | export type IgnoreType = keyof typeof ignoreEnum; 7 | export interface TreeDropSlotProps { 8 | /** 多选选中的节点 */ 9 | checkedNodes: TreeNode[]; 10 | /** 多选选中的节点 key */ 11 | checkedKeys: TreeNodeKeyType[]; 12 | /** 单选选中的节点 */ 13 | selectedNode?: TreeNode; 14 | /** 单选选中的节点 key */ 15 | selectedKey?: TreeNodeKeyType; 16 | } 17 | -------------------------------------------------------------------------------- /types/types/index.d.ts: -------------------------------------------------------------------------------- 1 | import { placementEnum, ignoreEnum, showLineType } from '../constants'; 2 | import TreeStore, { TreeNode } from '../store'; 3 | export type PlacementType = keyof typeof placementEnum; 4 | export type TreeNodeKeyType = string | number; 5 | export type GetNodeFn = (key: TreeNodeKeyType) => TreeNode | null; 6 | export type IgnoreType = keyof typeof ignoreEnum; 7 | export type LoadFn = (node: null | TreeNode, resolve: Function, reject: Function) => any; 8 | export interface TreeDropSlotProps { 9 | /** 多选选中的节点 */ 10 | checkedNodes: TreeNode[]; 11 | /** 多选选中的节点 key */ 12 | checkedKeys: TreeNodeKeyType[]; 13 | /** 单选选中的节点 */ 14 | selectedNode?: TreeNode; 15 | /** 单选选中的节点 key */ 16 | selectedKey?: TreeNodeKeyType; 17 | } 18 | export type AnyPropsArrayType = Array<{ 19 | [key: string]: any; 20 | }>; 21 | export interface INonReactiveData { 22 | store: TreeStore; 23 | blockNodes: TreeNode[]; 24 | } 25 | type Enumerate = Acc['length'] extends N ? Acc[number] : Enumerate; 26 | type IntRange = Exclude, Enumerate>; 27 | export interface ShowLine { 28 | /** 连接线宽度,svg stroke-width, 默认 1px */ 29 | width?: number; 30 | type?: showLineType; 31 | color?: string; 32 | polyline?: boolean; 33 | dashDensity?: IntRange<1, 11>; 34 | } 35 | export {}; 36 | -------------------------------------------------------------------------------- /types/utils.d.ts: -------------------------------------------------------------------------------- 1 | import { Ref } from "vue"; 2 | export declare const getVTreeMethods: >(apiMethods: readonly T[], ref: Ref) => Pick; 3 | type PickReadonly = { 4 | readonly [P in K]: T[P]; 5 | }; 6 | export declare const pickReadonly: (obj: Readonly, ...keys: K[]) => PickReadonly; 7 | export {}; 8 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, UserConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | import { resolve, join } from 'path' 4 | 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig((): UserConfig => { 8 | return { 9 | plugins: [vue()], 10 | server: { 11 | open:true, 12 | hmr:true 13 | }, 14 | build: { 15 | lib: { 16 | entry: resolve(__dirname,'src/index.ts'), 17 | name:'Vtree', 18 | fileName: 'vue-tree' 19 | }, 20 | rollupOptions: { 21 | external: ['vue'], 22 | output: { 23 | dir: join(__dirname, 'dist'), 24 | exports: 'named', 25 | globals: { 26 | vue: 'Vue', 27 | } 28 | } 29 | } 30 | } 31 | } 32 | }) -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, mergeConfig } from 'vitest/config' 2 | import viteConfig from './vite.config' 3 | 4 | export default defineConfig(configEnv => mergeConfig( 5 | viteConfig(configEnv), 6 | defineConfig({ 7 | test: { 8 | environment: 'happy-dom', 9 | }, 10 | }) 11 | )) 12 | --------------------------------------------------------------------------------