From 3e315120b31516d4833c3dc48973e42a0e84010f Mon Sep 17 00:00:00 2001 From: liuzaijiang <530604689@qq.com> Date: Fri, 3 Dec 2021 09:24:30 +0800 Subject: [PATCH] feat(comp: tree-select): add tree-select comp --- .../components/config/src/defaultConfig.ts | 10 + packages/components/config/src/types.ts | 10 + packages/components/default.less | 1 + packages/components/icon/demo/all.ts | 2 + packages/components/icon/src/definitions.ts | 24 +- packages/components/icon/src/dependencies.ts | 4 + packages/components/index.ts | 2 + .../components/style/variable/prefix.less | 2 + .../__snapshots__/treeSelect.spec.ts.snap | 146 ++++ .../tree-select/__tests__/treeSelect.spec.ts | 719 ++++++++++++++++++ .../components/tree-select/demo/AsynLoad.md | 14 + .../components/tree-select/demo/AsynLoad.vue | 47 ++ packages/components/tree-select/demo/Basic.md | 14 + .../components/tree-select/demo/Basic.vue | 55 ++ .../components/tree-select/demo/Cascade.md | 12 + .../components/tree-select/demo/Cascade.vue | 40 + .../tree-select/demo/CheckStrategy.md | 12 + .../tree-select/demo/CheckStrategy.vue | 64 ++ .../components/tree-select/demo/Controlled.md | 12 + .../tree-select/demo/Controlled.vue | 62 ++ .../components/tree-select/demo/CustomKey.md | 12 + .../components/tree-select/demo/CustomKey.vue | 57 ++ .../tree-select/demo/CustomLabel.md | 14 + .../tree-select/demo/CustomLabel.vue | 62 ++ .../components/tree-select/demo/Disabled.md | 10 + .../components/tree-select/demo/Disabled.vue | 49 ++ .../components/tree-select/demo/DragDrop.md | 18 + .../components/tree-select/demo/DragDrop.vue | 134 ++++ .../components/tree-select/demo/Multiple.md | 12 + .../components/tree-select/demo/Multiple.vue | 49 ++ .../components/tree-select/demo/Overlay.md | 14 + .../components/tree-select/demo/Overlay.vue | 64 ++ .../components/tree-select/demo/Search.md | 12 + .../components/tree-select/demo/Search.vue | 70 ++ .../components/tree-select/demo/ShowLine.md | 12 + .../components/tree-select/demo/ShowLine.vue | 99 +++ packages/components/tree-select/demo/Size.md | 14 + packages/components/tree-select/demo/Size.vue | 56 ++ .../components/tree-select/demo/Virtual.md | 14 + .../components/tree-select/demo/Virtual.vue | 32 + .../components/tree-select/docs/Index.en.md | 31 + .../components/tree-select/docs/Index.zh.md | 108 +++ packages/components/tree-select/index.ts | 16 + .../components/tree-select/src/TreeSelect.tsx | 150 ++++ .../src/composables/useAccessor.ts | 27 + .../src/composables/useDataSource.ts | 131 ++++ .../src/composables/useGetNodeKey.ts | 36 + .../src/composables/useInputState.ts | 92 +++ .../src/composables/useOverlayProps.ts | 57 ++ .../src/composables/useSelectedState.ts | 72 ++ .../tree-select/src/content/Content.tsx | 240 ++++++ packages/components/tree-select/src/token.ts | 44 ++ .../tree-select/src/trigger/Input.tsx | 50 ++ .../tree-select/src/trigger/Item.tsx | 46 ++ .../tree-select/src/trigger/Selector.tsx | 101 +++ .../tree-select/src/trigger/Trigger.tsx | 107 +++ packages/components/tree-select/src/types.ts | 117 +++ .../components/tree-select/style/index.less | 161 ++++ .../components/tree-select/style/mixin.less | 21 + .../tree-select/style/multiple.less | 110 +++ .../components/tree-select/style/single.less | 57 ++ .../tree-select/style/themes/default.less | 72 ++ .../tree-select/style/themes/default.ts | 6 + packages/components/tree/src/Tree.tsx | 4 +- packages/components/tree/src/node/Indent.tsx | 8 +- .../components/tree/src/node/TreeNode.tsx | 16 +- packages/components/tree/src/types.ts | 1 + scripts/gulp/icons/assets/tree-expand.svg | 1 + scripts/gulp/icons/assets/tree-unexpand.svg | 1 + 69 files changed, 3920 insertions(+), 19 deletions(-) create mode 100644 packages/components/tree-select/__tests__/__snapshots__/treeSelect.spec.ts.snap create mode 100644 packages/components/tree-select/__tests__/treeSelect.spec.ts create mode 100644 packages/components/tree-select/demo/AsynLoad.md create mode 100644 packages/components/tree-select/demo/AsynLoad.vue create mode 100644 packages/components/tree-select/demo/Basic.md create mode 100644 packages/components/tree-select/demo/Basic.vue create mode 100644 packages/components/tree-select/demo/Cascade.md create mode 100644 packages/components/tree-select/demo/Cascade.vue create mode 100644 packages/components/tree-select/demo/CheckStrategy.md create mode 100644 packages/components/tree-select/demo/CheckStrategy.vue create mode 100644 packages/components/tree-select/demo/Controlled.md create mode 100644 packages/components/tree-select/demo/Controlled.vue create mode 100644 packages/components/tree-select/demo/CustomKey.md create mode 100644 packages/components/tree-select/demo/CustomKey.vue create mode 100644 packages/components/tree-select/demo/CustomLabel.md create mode 100644 packages/components/tree-select/demo/CustomLabel.vue create mode 100644 packages/components/tree-select/demo/Disabled.md create mode 100644 packages/components/tree-select/demo/Disabled.vue create mode 100644 packages/components/tree-select/demo/DragDrop.md create mode 100644 packages/components/tree-select/demo/DragDrop.vue create mode 100644 packages/components/tree-select/demo/Multiple.md create mode 100644 packages/components/tree-select/demo/Multiple.vue create mode 100644 packages/components/tree-select/demo/Overlay.md create mode 100644 packages/components/tree-select/demo/Overlay.vue create mode 100644 packages/components/tree-select/demo/Search.md create mode 100644 packages/components/tree-select/demo/Search.vue create mode 100644 packages/components/tree-select/demo/ShowLine.md create mode 100644 packages/components/tree-select/demo/ShowLine.vue create mode 100644 packages/components/tree-select/demo/Size.md create mode 100644 packages/components/tree-select/demo/Size.vue create mode 100644 packages/components/tree-select/demo/Virtual.md create mode 100644 packages/components/tree-select/demo/Virtual.vue create mode 100644 packages/components/tree-select/docs/Index.en.md create mode 100644 packages/components/tree-select/docs/Index.zh.md create mode 100644 packages/components/tree-select/index.ts create mode 100644 packages/components/tree-select/src/TreeSelect.tsx create mode 100644 packages/components/tree-select/src/composables/useAccessor.ts create mode 100644 packages/components/tree-select/src/composables/useDataSource.ts create mode 100644 packages/components/tree-select/src/composables/useGetNodeKey.ts create mode 100644 packages/components/tree-select/src/composables/useInputState.ts create mode 100644 packages/components/tree-select/src/composables/useOverlayProps.ts create mode 100644 packages/components/tree-select/src/composables/useSelectedState.ts create mode 100644 packages/components/tree-select/src/content/Content.tsx create mode 100644 packages/components/tree-select/src/token.ts create mode 100644 packages/components/tree-select/src/trigger/Input.tsx create mode 100644 packages/components/tree-select/src/trigger/Item.tsx create mode 100644 packages/components/tree-select/src/trigger/Selector.tsx create mode 100644 packages/components/tree-select/src/trigger/Trigger.tsx create mode 100644 packages/components/tree-select/src/types.ts create mode 100644 packages/components/tree-select/style/index.less create mode 100644 packages/components/tree-select/style/mixin.less create mode 100644 packages/components/tree-select/style/multiple.less create mode 100644 packages/components/tree-select/style/single.less create mode 100644 packages/components/tree-select/style/themes/default.less create mode 100644 packages/components/tree-select/style/themes/default.ts create mode 100644 scripts/gulp/icons/assets/tree-expand.svg create mode 100644 scripts/gulp/icons/assets/tree-unexpand.svg diff --git a/packages/components/config/src/defaultConfig.ts b/packages/components/config/src/defaultConfig.ts index f4af90674..68f28d649 100644 --- a/packages/components/config/src/defaultConfig.ts +++ b/packages/components/config/src/defaultConfig.ts @@ -53,6 +53,7 @@ import type { TimeRangePickerConfig, TooltipConfig, TreeConfig, + TreeSelectConfig, } from './types' import { numFormatter } from './numFormatter' @@ -190,6 +191,14 @@ const timeRangePicker: TimeRangePickerConfig = { format: 'HH:mm:ss', } +const treeSelect: TreeSelectConfig = { + size: 'md', + suffix: 'down', + childrenKey: 'children', + labelKey: 'label', + nodeKey: 'key', +} + // --------------------- Data Display --------------------- const avatar: AvatarConfig = { gap: 4, @@ -385,6 +394,7 @@ export const defaultConfig: GlobalConfig = { textarea, timePicker, timeRangePicker, + treeSelect, // Data Display avatar, badge, diff --git a/packages/components/config/src/types.ts b/packages/components/config/src/types.ts index 020dd886a..2df94386b 100644 --- a/packages/components/config/src/types.ts +++ b/packages/components/config/src/types.ts @@ -167,6 +167,15 @@ export interface SelectConfig { valueKey: string } +export interface TreeSelectConfig { + size: FormSize + suffix: string + childrenKey: string + labelKey: string + nodeKey: string + target?: PortalTargetType +} + export interface TimePickerConfig { borderless: boolean clearable: boolean @@ -407,6 +416,7 @@ export interface GlobalConfig { radio: RadioConfig rate: RateConfig select: SelectConfig + treeSelect: TreeSelectConfig timePicker: TimePickerConfig timeRangePicker: TimeRangePickerConfig // Data Display diff --git a/packages/components/default.less b/packages/components/default.less index f0aa28b2d..cc32058bc 100644 --- a/packages/components/default.less +++ b/packages/components/default.less @@ -56,4 +56,5 @@ @import './timeline/style/themes/default.less'; @import './tooltip/style/themes/default.less'; @import './tree/style/themes/default.less'; +@import './tree-select/style/themes/default.less'; @import './typography/style/themes/default.less'; diff --git a/packages/components/icon/demo/all.ts b/packages/components/icon/demo/all.ts index 320dfc3d4..057ecfc40 100644 --- a/packages/components/icon/demo/all.ts +++ b/packages/components/icon/demo/all.ts @@ -171,6 +171,8 @@ export const allIcons = [ 'thunderbolt', 'tool', 'transmit', + 'tree-expand', + 'tree-unexpand', 'unexpand', 'unlock', 'up', diff --git a/packages/components/icon/src/definitions.ts b/packages/components/icon/src/definitions.ts index 02d1f29ab..1d244e77e 100644 --- a/packages/components/icon/src/definitions.ts +++ b/packages/components/icon/src/definitions.ts @@ -1,10 +1,10 @@ -/** - * @license - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE - */ - +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + export const Alert = { name: 'alert', svg: '', @@ -865,6 +865,16 @@ export const Transmit = { svg: '', } +export const TreeExpand = { + name: 'tree-expand', + svg: '', +} + +export const TreeUnexpand = { + name: 'tree-unexpand', + svg: '', +} + export const Unexpand = { name: 'unexpand', svg: '', diff --git a/packages/components/icon/src/dependencies.ts b/packages/components/icon/src/dependencies.ts index 4262dfa74..470667b5e 100644 --- a/packages/components/icon/src/dependencies.ts +++ b/packages/components/icon/src/dependencies.ts @@ -41,6 +41,8 @@ import { RotateRight, Search, StarFilled, + TreeExpand, + TreeUnexpand, User, VerticalAlignTop, ZoomIn, @@ -82,6 +84,8 @@ export const IDUX_ICON_DEPENDENCIES: IconDefinition[] = [ Search, // Select StarFilled, // Rate User, // Avatar + TreeExpand, // TreeSelect + TreeUnexpand, // TreeSelect VerticalAlignTop, // BackTop ZoomIn, // Image ZoomOut, // Image diff --git a/packages/components/index.ts b/packages/components/index.ts index a59524386..7e2f78c33 100644 --- a/packages/components/index.ts +++ b/packages/components/index.ts @@ -58,6 +58,7 @@ import { IxTimePicker, IxTimeRangePicker } from '@idux/components/time-picker' import { IxTimeline, IxTimelineItem } from '@idux/components/timeline' import { IxTooltip } from '@idux/components/tooltip' import { IxTree } from '@idux/components/tree' +import { IxTreeSelect } from '@idux/components/tree-select' import { IxTypography } from '@idux/components/typography' import { version } from '@idux/components/version' @@ -141,6 +142,7 @@ const components = [ IxTimelineItem, IxTooltip, IxTree, + IxTreeSelect, ] const directives: Record = { diff --git a/packages/components/style/variable/prefix.less b/packages/components/style/variable/prefix.less index 7a4797306..84f1cbf51 100644 --- a/packages/components/style/variable/prefix.less +++ b/packages/components/style/variable/prefix.less @@ -61,6 +61,8 @@ @textarea-prefix: ~'@{idux-prefix}-textarea'; @time-picker-prefix: ~'@{idux-prefix}-time-picker'; @time-range-picker-prefix: ~'@{idux-prefix}-time-range-picker'; +@tree-select-prefix: ~'@{idux-prefix}-tree-select'; +@tree-select-option-prefix: ~'@{idux-prefix}-tree-select-option'; // Feedback @alert-prefix: ~'@{idux-prefix}-alert'; diff --git a/packages/components/tree-select/__tests__/__snapshots__/treeSelect.spec.ts.snap b/packages/components/tree-select/__tests__/__snapshots__/treeSelect.spec.ts.snap new file mode 100644 index 000000000..e3320742b --- /dev/null +++ b/packages/components/tree-select/__tests__/__snapshots__/treeSelect.spec.ts.snap @@ -0,0 +1,146 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TreeSelect multiple work render work 1`] = ` +"
+ +
+ +" +`; + +exports[`TreeSelect single work custom keys work 1`] = ` +"
+ +
+ +" +`; + +exports[`TreeSelect single work render work 1`] = ` +"
+ +
+ +" +`; + +exports[`TreeSelect single work searchFn work 1`] = ` +"
+ +
+" +`; + +exports[`TreeSelect single work searchFn work 2`] = ` +"
+ +
+" +`; + +exports[`TreeSelect single work searchFn work 3`] = ` +"
+ +
+" +`; + +exports[`TreeSelect single work searchable work 1`] = ` +"
+ +
+ +" +`; + +exports[`TreeSelect single work searchable work 2`] = ` +"
+ +
+ +" +`; diff --git a/packages/components/tree-select/__tests__/treeSelect.spec.ts b/packages/components/tree-select/__tests__/treeSelect.spec.ts new file mode 100644 index 000000000..5fb417ebd --- /dev/null +++ b/packages/components/tree-select/__tests__/treeSelect.spec.ts @@ -0,0 +1,719 @@ +import type { TreeSelectNode } from '@idux/components/tree-select' + +import { MountingOptions, flushPromises, mount } from '@vue/test-utils' +import { VNode, h } from 'vue' + +import { isElementVisible, renderWork, wait } from '@tests' + +import { IxEmpty } from '@idux/components/empty' +import { IxIcon } from '@idux/components/icon' + +import TreeSelect from '../src/TreeSelect' +import Content from '../src/content/Content' +import { TreeSelectProps } from '../src/types' + +const defaultDataSource = [ + { + label: 'Node 0', + key: '0', + children: [ + { + label: 'Node 0-0', + key: '0-0', + }, + { + label: 'Node 0-1', + key: '0-1', + }, + { + label: 'Node 0-2', + key: '0-2', + }, + ], + }, +] + +const defaultSingleValue = '0' +const defaultMultipleValue = ['0'] +const expandedKeys = ['0'] + +describe('TreeSelect', () => { + afterEach(() => { + if (document.querySelector('.ix-tree-select-overlay')) { + document.querySelector('.ix-tree-select-overlay')!.innerHTML = '' + } + }) + + describe('single work', () => { + const TreeSelectMount = (options?: MountingOptions>) => { + const { props, ...rest } = options || {} + return mount(TreeSelect, { + ...rest, + props: { dataSource: defaultDataSource, value: defaultSingleValue, expandedKeys, ...props }, + attachTo: 'body', + }) + } + + renderWork(TreeSelect, { + props: { open: true, dataSource: defaultDataSource, value: defaultSingleValue }, + attachTo: 'body', + }) + + test('v-model:value work', async () => { + const onUpdateValue = jest.fn() + const onChange = jest.fn() + const wrapper = TreeSelectMount({ props: { open: true, value: '0', 'onUpdate:value': onUpdateValue, onChange } }) + + expect(wrapper.find('.ix-tree-select-selector-item').text()).toBe('Node 0') + + await wrapper.setProps({ value: undefined }) + + expect(wrapper.find('.ix-tree-select-selector-item').exists()).toBe(false) + + const allNodes = wrapper.findComponent(Content).findAll('.ix-tree-node-content') + + await allNodes[0].trigger('click') + + expect(onUpdateValue).toBeCalledWith('0') + expect(onChange).toBeCalledWith('0', undefined, { + children: [ + { key: '0-0', label: 'Node 0-0' }, + { key: '0-1', label: 'Node 0-1' }, + { key: '0-2', label: 'Node 0-2' }, + ], + key: '0', + label: 'Node 0', + }) + + await wrapper.setProps({ value: '0' }) + await allNodes[1].trigger('click') + + expect(onUpdateValue).toBeCalledWith('0-0') + expect(onChange).toBeCalledWith('0-0', '0', { key: '0-0', label: 'Node 0-0' }) + }) + + test('v-model:expandedKeys work', async () => { + const onUpdateExpandedKeys = jest.fn() + const onExpandedChange = jest.fn() + const wrapper = TreeSelectMount({ + props: { open: true, expandedKeys: [], 'onUpdate:expandedKeys': onUpdateExpandedKeys, onExpandedChange }, + }) + + const allNodes = wrapper.findComponent(Content).findAll('.ix-tree-node') + + expect(allNodes[0].classes()).not.toContain('ix-tree-node-expanded') + + await allNodes[0].find('.ix-tree-node-expand').trigger('click') + + expect(onUpdateExpandedKeys).toBeCalledWith(['0']) + expect(onExpandedChange).toBeCalledWith( + ['0'], + [ + { + children: [ + { key: '0-0', label: 'Node 0-0' }, + { key: '0-1', label: 'Node 0-1' }, + { key: '0-2', label: 'Node 0-2' }, + ], + key: '0', + label: 'Node 0', + }, + ], + ) + }) + + test('loadedKeys work', async () => { + const loadChildren = (node: TreeSelectNode) => { + return new Promise(resolve => { + setTimeout(() => { + const parentKey = node.key as string + const children = [ + { label: `Child ${parentKey}-0 `, key: `${parentKey}-0` }, + { label: `Child ${parentKey}-1 `, key: `${parentKey}-1` }, + ] + resolve(children) + }, 50) + }) + } + + const wrapper = TreeSelectMount({ + props: { + open: true, + dataSource: [ + { key: '0', label: '0' }, + { key: '1', label: '1', isLeaf: true }, + ], + expandedKeys: undefined, + loadChildren, + loadedKeys: ['0'], + }, + }) + + let allNodes = wrapper.findComponent(Content).findAll('.ix-tree-node') + + expect(allNodes.length).toBe(2) + + await allNodes[0].find('.ix-tree-node-expand').trigger('click') + + expect(allNodes[0].find('.ix-tree-node-expand').find('.ix-icon-loading').exists()).toBe(false) + + await wait(50) + + allNodes = wrapper.findComponent(Content).findAll('.ix-tree-node') + + expect(allNodes.length).toBe(2) + + // await wrapper.setProps({ loadedKeys: [] }) + + // await allNodes[0].find('.ix-tree-node-expand').trigger('click') + + // expect(allNodes[0].find('.ix-tree-node-expand').find('.ix-icon-loading').exists()).toBe(true) + + // await wait(50) + + // allNodes = wrapper.findComponent(Content).findAll('.ix-tree-node') + + // expect(allNodes.length).toBe(4) + }) + + test('v-model:open work', async () => { + const wrapper = TreeSelectMount({ props: { open: true } }) + + expect(wrapper.find('.ix-tree-select-opened').exists()).toBe(true) + expect(wrapper.findComponent(Content).isVisible()).toBe(true) + + await wrapper.setProps({ open: false }) + + expect(wrapper.find('.ix-tree-select-opened').exists()).toBe(false) + expect(wrapper.findComponent(Content).isVisible()).toBe(false) + }) + + test('autofocus work', async () => { + const wrapper = TreeSelectMount({ props: { autofocus: true } }) + await flushPromises() + + expect(wrapper.find('.ix-tree-select-opened').exists()).toBe(true) + expect(wrapper.findComponent(Content).isVisible()).toBe(true) + }) + + test('custom keys work', async () => { + const dataSource = [ + { + text: 'Node 0', + value: '0', + options: [ + { + text: 'Node 0-0', + value: '0-0', + }, + { + text: 'Node 0-1', + value: '0-1', + }, + { + text: 'Node 0-2', + value: '0-2', + }, + ], + }, + ] + + const wrapper = TreeSelectMount({ + props: { value: '0', open: true, dataSource, childrenKey: 'options', labelKey: 'text', nodeKey: 'value' }, + }) + + expect(wrapper.find('.ix-tree-select-selector-item').text()).toBe('Node 0') + expect(wrapper.html()).toMatchSnapshot() + }) + + test('clearable work', async () => { + const onUpdateValue = jest.fn() + const wrapper = TreeSelectMount({ props: { clearable: true, 'onUpdate:value': onUpdateValue } }) + + expect(wrapper.find('.ix-tree-select-clearable').exists()).toBe(true) + expect(wrapper.find('.ix-tree-select-selector-item').text()).toBe('Node 0') + + await wrapper.find('.ix-tree-select-selector-clear').trigger('click') + + expect(wrapper.find('.ix-tree-select-clear').exists()).toBe(false) + expect(onUpdateValue).toBeCalledWith(undefined) + + await wrapper.setProps({ value: '0-0', clearable: false }) + + expect(wrapper.find('.ix-tree-select-clearable').exists()).toBe(false) + expect(wrapper.find('.ix-tree-select-selector-item').text()).toBe('Node 0-0') + }) + + test('disabled work', async () => { + const wrapper = TreeSelectMount({ props: { disabled: true } }) + + expect(wrapper.find('.ix-tree-select-disabled').exists()).toBe(true) + + // await wrapper.find('.ix-select').trigger('click') + + // expect(wrapper.find('.ix-select-opened').exists()).toBe(false) + + await wrapper.setProps({ disabled: false }) + + expect(wrapper.find('.ix-tree-select-disabled').exists()).toBe(false) + + // await wrapper.find('.ix-select').trigger('click') + + // expect(wrapper.find('.ix-select-opened').exists()).toBe(true) + }) + + test('readonly work', async () => { + const wrapper = TreeSelectMount({ props: { readonly: true } }) + + expect(wrapper.find('.ix-tree-select-readonly').exists()).toBe(true) + + // await wrapper.find('.ix-select').trigger('click') + + // expect(wrapper.find('.ix-select-opened').exists()).toBe(false) + + await wrapper.setProps({ readonly: false }) + + expect(wrapper.find('.ix-tree-select-readonly').exists()).toBe(false) + + // await wrapper.find('.ix-select').trigger('click') + + // expect(wrapper.find('.ix-select-opened').exists()).toBe(true) + }) + + test('overlayClassName work', async () => { + const wrapper = TreeSelectMount({ props: { open: true, overlayClassName: 'test-class' } }) + + expect(isElementVisible(document.querySelector('.test-class'))).toBe(true) + + await wrapper.setProps({ overlayClassName: undefined }) + + expect(isElementVisible(document.querySelector('.test-class'))).toBe(false) + }) + + test('overlayRender work', async () => { + const overlayRender = (children: VNode[]) => { + return [children, h('div', { class: 'custom-render-div' })] + } + const wrapper = TreeSelectMount({ props: { open: true, overlayRender } }) + + expect(wrapper.findComponent(Content).find('.custom-render-div').exists()).toBe(true) + }) + + test('searchable work', async () => { + const onExpandedChange = jest.fn() + const wrapper = TreeSelectMount({ props: { open: true, searchable: true, onExpandedChange } }) + + const input = wrapper.find('input') + input.setValue('invalid value') + + expect(wrapper.html()).toMatchSnapshot() + + await wrapper.setProps({ searchable: 'overlay' }) + + let overlaySearchWrapper = wrapper.findComponent(Content).find('.ix-tree-select-overlay-search-wrapper') + + expect(overlaySearchWrapper.exists()).toBe(true) + expect(overlaySearchWrapper.find('.ix-button').exists()).toBe(true) + + await overlaySearchWrapper.find('input').setValue('l') + + expect(wrapper.html()).toMatchSnapshot() + + await overlaySearchWrapper.find('.ix-button').trigger('click') + expect(onExpandedChange).toBeCalled() + + await wrapper.setProps({ searchable: false }) + + overlaySearchWrapper = wrapper.findComponent(Content).find('.ix-tree-select-overlay-search-wrapper') + + expect(input.element.style.opacity).toBe('0') + expect(overlaySearchWrapper.exists()).toBe(false) + }) + + test('searchFn work', async () => { + const wrapper = TreeSelectMount({ + props: { + searchValue: 'node', + searchFn: (node, searchValue) => { + if (searchValue === 'node') { + return false + } + if (searchValue === 'all') { + return true + } + return node.key === '0' + }, + }, + }) + + expect(wrapper.html()).toMatchSnapshot() + + const input = wrapper.find('input') + await input.setValue('all') + + expect(wrapper.html()).toMatchSnapshot() + + // only math 0 + await input.setValue('test') + + expect(wrapper.html()).toMatchSnapshot() + }) + + test('size work', async () => { + const wrapper = TreeSelectMount({ props: { size: 'lg' } }) + + expect(wrapper.find('.ix-tree-select-lg').exists()).toBe(true) + + await wrapper.setProps({ size: 'sm' }) + + expect(wrapper.find('.ix-tree-select-sm').exists()).toBe(true) + + await wrapper.setProps({ size: undefined }) + + expect(wrapper.find('.ix-tree-select-md').exists()).toBe(true) + }) + + test('clearable work', async () => { + const wrapper = TreeSelectMount({ props: { clearable: true } }) + + expect(wrapper.find('.ix-tree-select-selector-clear').exists()).toBe(true) + }) + }) + + describe('multiple work', () => { + const TreeSelectMount = (options?: MountingOptions>) => { + const { props, ...rest } = options || {} + return mount(TreeSelect, { + ...rest, + props: { dataSource: defaultDataSource, value: defaultSingleValue, multiple: true, expandedKeys, ...props }, + attachTo: 'body', + }) + } + + renderWork(TreeSelect, { + props: { open: true, dataSource: defaultDataSource, value: defaultMultipleValue }, + attachTo: 'body', + }) + + test('v-model:value work', async () => { + const onUpdateValue = jest.fn() + const onChange = jest.fn() + const wrapper = TreeSelectMount({ + props: { open: true, value: ['0', '0-0'], 'onUpdate:value': onUpdateValue, onChange }, + }) + + expect(wrapper.findAll('.ix-tree-select-selector-item').length).toBe(2) + + await wrapper.setProps({ value: ['0'] }) + + expect(wrapper.findAll('.ix-tree-select-selector-item').length).toBe(1) + + const allNodes = wrapper.findComponent(Content).findAll('.ix-tree-node-content') + + await allNodes[1].trigger('click') + expect(onUpdateValue).toBeCalledWith(['0', '0-0']) + expect(onChange).toBeCalledWith( + ['0', '0-0'], + ['0'], + [ + { + children: [ + { key: '0-0', label: 'Node 0-0' }, + { key: '0-1', label: 'Node 0-1' }, + { key: '0-2', label: 'Node 0-2' }, + ], + key: '0', + label: 'Node 0', + }, + { key: '0-0', label: 'Node 0-0' }, + ], + ) + }) + + test('maxLabelCount work', async () => { + const wrapper = TreeSelectMount({ props: { maxLabelCount: 2, value: ['0', '0-0', '0-1'] } }) + + let items = wrapper.findAll('.ix-tree-select-selector-item') + + expect(items[0].text()).toBe('Node 0') + expect(items[1].text()).toBe('Node 0-0') + expect(items[2].text()).toBe('+ 1 ...') + + await wrapper.setProps({ maxLabelCount: 3 }) + + items = wrapper.findAll('.ix-tree-select-selector-item') + + expect(items[0].text()).toBe('Node 0') + expect(items[1].text()).toBe('Node 0-0') + expect(items[2].text()).toBe('Node 0-1') + }) + + test('checkable work', async () => { + const wrapper = TreeSelectMount({ props: { open: true, checkable: true } }) + + expect(wrapper.findComponent(Content).find('.ix-checkbox').exists()).toBe(true) + + await wrapper.setProps({ multiple: false }) + + expect(wrapper.findComponent(Content).find('.ix-checkbox').exists()).toBe(false) + }) + }) + + describe('slot work', () => { + const TreeSelectMount = (options?: MountingOptions>) => { + const { props, ...rest } = options || {} + return mount(TreeSelect, { + ...rest, + props: { dataSource: defaultDataSource, value: defaultSingleValue, expandedKeys, ...props }, + attachTo: 'body', + }) + } + + test('empty work', async () => { + let emptyText = 'empty text' + const wrapper = TreeSelectMount({ props: { open: true, empty: emptyText, dataSource: [] } }) + + expect(wrapper.findComponent(Content).find('.ix-empty-description').text()).toBe(emptyText) + + emptyText = 'empty text 2' + await wrapper.setProps({ empty: { description: emptyText } }) + + expect(wrapper.findComponent(Content).find('.ix-empty-description').text()).toBe(emptyText) + }) + + test('empty slot work', async () => { + const wrapper = TreeSelectMount({ + props: { open: true, empty: 'empty text', dataSource: [] }, + slots: { empty: () => h(IxEmpty, { description: 'empty slot' }) }, + }) + + expect(wrapper.findComponent(Content).find('.ix-empty-description').text()).toBe('empty slot') + }) + + test('placeholder work', async () => { + const wrapper = TreeSelectMount({ props: { value: undefined, placeholder: 'placeholder' } }) + + expect(wrapper.find('.ix-tree-select-selector-placeholder').text()).toBe('placeholder') + + await wrapper.setProps({ value: 'tom' }) + + expect(wrapper.find('.ix-tree-select-selector-placeholder').exists()).toBe(false) + + await wrapper.setProps({ value: undefined }) + + expect(wrapper.find('.ix-tree-select-selector-placeholder').text()).toBe('placeholder') + + await wrapper.setProps({ placeholder: 'place' }) + + expect(wrapper.find('.ix-tree-select-selector-placeholder').text()).toBe('place') + }) + + test('placeholder slot work', async () => { + const wrapper = TreeSelectMount({ + props: { value: undefined, placeholder: 'placeholder' }, + slots: { placeholder: () => 'placeholder slot' }, + }) + + expect(wrapper.find('.ix-tree-select-selector-placeholder').text()).toBe('placeholder slot') + }) + + test('suffix work', async () => { + const wrapper = TreeSelectMount({ props: { suffix: 'up' } }) + + expect(wrapper.find('.ix-icon-up').exists()).toBe(true) + + await wrapper.setProps({ suffix: undefined }) + + expect(wrapper.find('.ix-icon-down').exists()).toBe(true) + }) + + test('suffix slot work', async () => { + const wrapper = TreeSelectMount({ props: { suffix: 'down' }, slots: { suffix: () => h(IxIcon, { name: 'up' }) } }) + + expect(wrapper.find('.ix-icon-up').exists()).toBe(true) + }) + + test('label slot work', async () => { + const wrapper = TreeSelectMount({ + props: { + dataSource: [ + { + label: 'Node 0', + key: '0', + icon: 'github', + children: [ + { + label: 'Node 0-0', + key: '0-0', + icon: 'github', + }, + { + label: 'Node 0-1', + key: '0-1', + icon: 'github', + }, + { + label: 'Node 0-2', + key: '0-2', + icon: 'github', + }, + ], + }, + ], + }, + slots: { label: rawNode => h('div', [h(IxIcon, { name: rawNode.icon }), h('span', rawNode.label)]) }, + }) + + expect(wrapper.find('.ix-icon-github').exists()).toBe(true) + expect(wrapper.find('.ix-tree-select-selector-item').text()).toBe('Node 0') + }) + + test('treeLabel slot work', async () => { + const wrapper = TreeSelectMount({ + props: { + open: true, + dataSource: [ + { + label: 'Node 0', + key: '0', + icon: 'github', + children: [ + { + label: 'Node 0-0', + key: '0-0', + icon: 'github', + }, + { + label: 'Node 0-1', + key: '0-1', + icon: 'github', + }, + { + label: 'Node 0-2', + key: '0-2', + icon: 'github', + }, + ], + }, + ], + }, + slots: { treeLabel: ({ node }) => h('div', [h(IxIcon, { name: node.icon }), h('span', node.label)]) }, + }) + + expect(wrapper.findComponent(Content).find('.ix-icon-github').exists()).toBe(true) + expect(wrapper.findComponent(Content).find('.ix-tree-node-content-label').text()).toBe('Node 0') + }) + + test('treePrefix && treeSuffix slot work', async () => { + const wrapper = TreeSelectMount({ + props: { + open: true, + dataSource: [ + { + label: 'Node 0', + key: '0', + treePrefix: 'github', + treeSuffix: 'appstore', + children: [ + { + label: 'Node 0-0', + key: '0-0', + treePrefix: 'github', + treeSuffix: 'appstore', + }, + { + label: 'Node 0-1', + key: '0-1', + treePrefix: 'github', + treeSuffix: 'appstore', + }, + { + label: 'Node 0-2', + key: '0-2', + treePrefix: 'github', + treeSuffix: 'appstore', + }, + ], + }, + ], + }, + slots: { + treePrefix: ({ node }) => h('div', [h(IxIcon, { name: node.treePrefix }), h('span', node.label)]), + treeSuffix: ({ node }) => h('div', [h(IxIcon, { name: node.treeSuffix }), h('span', node.label)]), + }, + }) + + expect(wrapper.findComponent(Content).find('.ix-icon-github').exists()).toBe(true) + expect(wrapper.findComponent(Content).find('.ix-icon-appstore').exists()).toBe(true) + expect(wrapper.findComponent(Content).find('.ix-tree-node-content-label').text()).toBe('Node 0') + }) + + test('expandIcon work', async () => { + const wrapper = TreeSelectMount({ + props: { open: true, expandIcon: 'up' }, + }) + + expect(wrapper.findComponent(Content).find('.ix-tree-node-expand').find('.ix-icon-up').exists()).toBe(true) + + await wrapper.setProps({ expandIcon: 'down' }) + + expect(wrapper.findComponent(Content).find('.ix-tree-node-expand').find('.ix-icon-up').exists()).toBe(false) + expect(wrapper.findComponent(Content).find('.ix-tree-node-expand').find('.ix-icon-down').exists()).toBe(true) + }) + + test('expandIcon slot work', async () => { + const onUpdateExpandedKeys = jest.fn() + const wrapper = TreeSelectMount({ + props: { open: true, expandIcon: 'right', 'onUpdate:expandedKeys': onUpdateExpandedKeys }, + slots: { + expandIcon: ({ expanded }: { expanded: boolean }) => h(IxIcon, { name: expanded ? 'down' : 'up' }), + }, + }) + + const allNodes = wrapper.findComponent(Content).findAll('.ix-tree-node') + + expect(allNodes[0].find('.ix-icon-down').exists()).toBe(true) + + await allNodes[0].find('.ix-tree-node-expand').trigger('click') + + expect(onUpdateExpandedKeys).toBeCalledWith([]) + + await wrapper.setProps({ expandedKeys: ['0-0', '0-1'] }) + + expect(wrapper.findComponent(Content).find('.ix-tree-node-expand').find('.ix-icon-up').exists()).toBe(true) + expect(wrapper.findComponent(Content).find('.ix-tree-node-expand').find('.ix-icon-down').exists()).toBe(false) + }) + + test('leafLineIcon slot work', async () => { + const wrapper = TreeSelectMount({ + props: { open: true, showLine: true }, + slots: { + leafLineIcon: () => h(IxIcon, { name: 'up' }), + }, + }) + + expect(wrapper.findComponent(Content).findAll('.ix-tree-node')[1].find('.ix-icon-up').exists()).toBe(true) + + await wrapper.setProps({ showLine: false }) + + expect(wrapper.findComponent(Content).findAll('.ix-tree-node')[1].find('.ix-icon-up').exists()).toBe(false) + }) + + test('maxLabel slot work', async () => { + const wrapper = TreeSelectMount({ + props: { + open: true, + value: ['0', '0-0'], + multiple: true, + checkable: true, + maxLabelCount: 1, + }, + slots: { + maxLabel: moreNodes => h('span', moreNodes.length), + }, + }) + + expect(wrapper.findAll('.ix-tree-select-selector-item-label')[1].text()).toBe('1') + }) + }) +}) diff --git a/packages/components/tree-select/demo/AsynLoad.md b/packages/components/tree-select/demo/AsynLoad.md new file mode 100644 index 000000000..2d0bc2a24 --- /dev/null +++ b/packages/components/tree-select/demo/AsynLoad.md @@ -0,0 +1,14 @@ +--- +title: + zh: 异步加载 + en: Asynchronously load +order: 7 +--- + +## zh + +点击展开节点,动态加载数据。 + +## en + +To load data asynchronously when click to expand a treeNode. diff --git a/packages/components/tree-select/demo/AsynLoad.vue b/packages/components/tree-select/demo/AsynLoad.vue new file mode 100644 index 000000000..2bfaa4c32 --- /dev/null +++ b/packages/components/tree-select/demo/AsynLoad.vue @@ -0,0 +1,47 @@ + + + diff --git a/packages/components/tree-select/demo/Basic.md b/packages/components/tree-select/demo/Basic.md new file mode 100644 index 000000000..817a7ee7c --- /dev/null +++ b/packages/components/tree-select/demo/Basic.md @@ -0,0 +1,14 @@ +--- +title: + zh: 基本使用 + en: Basic usage +order: 0 +--- + +## zh + +最简单的用法。 + +## en + +The simplest usage. diff --git a/packages/components/tree-select/demo/Basic.vue b/packages/components/tree-select/demo/Basic.vue new file mode 100644 index 000000000..643bdaaf5 --- /dev/null +++ b/packages/components/tree-select/demo/Basic.vue @@ -0,0 +1,55 @@ + + + diff --git a/packages/components/tree-select/demo/Cascade.md b/packages/components/tree-select/demo/Cascade.md new file mode 100644 index 000000000..ffb1540c2 --- /dev/null +++ b/packages/components/tree-select/demo/Cascade.md @@ -0,0 +1,12 @@ +--- +title: + zh: 级联 + en: Cascade +order: 3 +--- + +## zh + +仅当`multiple`和`checkable`同时为`true`时才生效 + +## en diff --git a/packages/components/tree-select/demo/Cascade.vue b/packages/components/tree-select/demo/Cascade.vue new file mode 100644 index 000000000..e914b3c55 --- /dev/null +++ b/packages/components/tree-select/demo/Cascade.vue @@ -0,0 +1,40 @@ + + + diff --git a/packages/components/tree-select/demo/CheckStrategy.md b/packages/components/tree-select/demo/CheckStrategy.md new file mode 100644 index 000000000..dabe57dfa --- /dev/null +++ b/packages/components/tree-select/demo/CheckStrategy.md @@ -0,0 +1,12 @@ +--- +title: + zh: 勾选策略 + en: CheckStrategy +order: 4 +--- + +## zh + +使用参考 [Tree](/components/tree/zh#components-tree-demo-CheckStrategy) + +## en diff --git a/packages/components/tree-select/demo/CheckStrategy.vue b/packages/components/tree-select/demo/CheckStrategy.vue new file mode 100644 index 000000000..afc34a5e4 --- /dev/null +++ b/packages/components/tree-select/demo/CheckStrategy.vue @@ -0,0 +1,64 @@ + + + + diff --git a/packages/components/tree-select/demo/Controlled.md b/packages/components/tree-select/demo/Controlled.md new file mode 100644 index 000000000..c4c00cb13 --- /dev/null +++ b/packages/components/tree-select/demo/Controlled.md @@ -0,0 +1,12 @@ +--- +title: + zh: 受控 + en: Controlled +order: 1 +--- + +## zh + +受控示例 + +## en diff --git a/packages/components/tree-select/demo/Controlled.vue b/packages/components/tree-select/demo/Controlled.vue new file mode 100644 index 000000000..d6174345a --- /dev/null +++ b/packages/components/tree-select/demo/Controlled.vue @@ -0,0 +1,62 @@ + + + diff --git a/packages/components/tree-select/demo/CustomKey.md b/packages/components/tree-select/demo/CustomKey.md new file mode 100644 index 000000000..7d3a6d91a --- /dev/null +++ b/packages/components/tree-select/demo/CustomKey.md @@ -0,0 +1,12 @@ +--- +title: + zh: 自定义 keys + en: CustomKeys +order: 1 +--- + +## zh + +可以通过设置 `labelKey`, `nodeKey` 和 `childrenKey` 来进行数据转换。 + +## en diff --git a/packages/components/tree-select/demo/CustomKey.vue b/packages/components/tree-select/demo/CustomKey.vue new file mode 100644 index 000000000..f99fe4970 --- /dev/null +++ b/packages/components/tree-select/demo/CustomKey.vue @@ -0,0 +1,57 @@ + + + diff --git a/packages/components/tree-select/demo/CustomLabel.md b/packages/components/tree-select/demo/CustomLabel.md new file mode 100644 index 000000000..c6790a9c2 --- /dev/null +++ b/packages/components/tree-select/demo/CustomLabel.md @@ -0,0 +1,14 @@ +--- +title: + zh: 自定义回填标签 + en: Custom backfill label +order: 8 +--- + +## zh + +通过 `label` 和 `maxLabel` 插槽自定义回填的标签。 + +## en + +Via the `label` or `maxLabel` slot custom the label. diff --git a/packages/components/tree-select/demo/CustomLabel.vue b/packages/components/tree-select/demo/CustomLabel.vue new file mode 100644 index 000000000..dcca256c5 --- /dev/null +++ b/packages/components/tree-select/demo/CustomLabel.vue @@ -0,0 +1,62 @@ + + diff --git a/packages/components/tree-select/demo/Disabled.md b/packages/components/tree-select/demo/Disabled.md new file mode 100644 index 000000000..fc0099948 --- /dev/null +++ b/packages/components/tree-select/demo/Disabled.md @@ -0,0 +1,10 @@ +--- +order: 3 +title: + zh: 禁用 + en: Disabled +--- + +## zh + +## en diff --git a/packages/components/tree-select/demo/Disabled.vue b/packages/components/tree-select/demo/Disabled.vue new file mode 100644 index 000000000..2c3554e6b --- /dev/null +++ b/packages/components/tree-select/demo/Disabled.vue @@ -0,0 +1,49 @@ + + + + diff --git a/packages/components/tree-select/demo/DragDrop.md b/packages/components/tree-select/demo/DragDrop.md new file mode 100644 index 000000000..b505958c3 --- /dev/null +++ b/packages/components/tree-select/demo/DragDrop.md @@ -0,0 +1,18 @@ +--- +title: + zh: 拖拽 + en: Drag and drop +order: 6 +--- + +## zh + +将节点拖拽到其他节点内部或前后。 + +可以通过设置 `droppable` 来自定义放置规则。 + +## en + +Drag treeNode to insert after the other treeNode or insert into the other parent TreeNode. + +You can customize placement rules by setting `droppable`. diff --git a/packages/components/tree-select/demo/DragDrop.vue b/packages/components/tree-select/demo/DragDrop.vue new file mode 100644 index 000000000..eda8c8dcd --- /dev/null +++ b/packages/components/tree-select/demo/DragDrop.vue @@ -0,0 +1,134 @@ + + + diff --git a/packages/components/tree-select/demo/Multiple.md b/packages/components/tree-select/demo/Multiple.md new file mode 100644 index 000000000..77df8c4c6 --- /dev/null +++ b/packages/components/tree-select/demo/Multiple.md @@ -0,0 +1,12 @@ +--- +title: + zh: 多选 + en: Multiple +order: 2 +--- + +## zh + +可以通过`checkable`属性来控制多选时是否显示勾选框 + +## en diff --git a/packages/components/tree-select/demo/Multiple.vue b/packages/components/tree-select/demo/Multiple.vue new file mode 100644 index 000000000..8758469cc --- /dev/null +++ b/packages/components/tree-select/demo/Multiple.vue @@ -0,0 +1,49 @@ + + + + diff --git a/packages/components/tree-select/demo/Overlay.md b/packages/components/tree-select/demo/Overlay.md new file mode 100644 index 000000000..c9291d897 --- /dev/null +++ b/packages/components/tree-select/demo/Overlay.md @@ -0,0 +1,14 @@ +--- +title: + zh: 自定义下拉菜单 + en: Custom dropdown menu +order: 10 +--- + +## zh + +使用 `overlayRender` 对下拉菜单进行自由扩展。 + +## en + +Via `overlayRender` to custom the dropdown menu. diff --git a/packages/components/tree-select/demo/Overlay.vue b/packages/components/tree-select/demo/Overlay.vue new file mode 100644 index 000000000..760118d31 --- /dev/null +++ b/packages/components/tree-select/demo/Overlay.vue @@ -0,0 +1,64 @@ + + + diff --git a/packages/components/tree-select/demo/Search.md b/packages/components/tree-select/demo/Search.md new file mode 100644 index 000000000..225d5d72c --- /dev/null +++ b/packages/components/tree-select/demo/Search.md @@ -0,0 +1,12 @@ +--- +title: + zh: 可搜索 + en: Searchable +order: 8 +--- + +## zh + +`searchable`为 `true`时在输入框进行搜索,为`overlay`时,搜索功能将内置到浮层 + +## en diff --git a/packages/components/tree-select/demo/Search.vue b/packages/components/tree-select/demo/Search.vue new file mode 100644 index 000000000..f7e08806f --- /dev/null +++ b/packages/components/tree-select/demo/Search.vue @@ -0,0 +1,70 @@ + + + + diff --git a/packages/components/tree-select/demo/ShowLine.md b/packages/components/tree-select/demo/ShowLine.md new file mode 100644 index 000000000..47ea0e830 --- /dev/null +++ b/packages/components/tree-select/demo/ShowLine.md @@ -0,0 +1,12 @@ +--- +title: + zh: 连接线 + en: Line +order: 5 +--- + +## zh + +显示连接线 + +## en diff --git a/packages/components/tree-select/demo/ShowLine.vue b/packages/components/tree-select/demo/ShowLine.vue new file mode 100644 index 000000000..e2040cd38 --- /dev/null +++ b/packages/components/tree-select/demo/ShowLine.vue @@ -0,0 +1,99 @@ + + + + diff --git a/packages/components/tree-select/demo/Size.md b/packages/components/tree-select/demo/Size.md new file mode 100644 index 000000000..4b93b0529 --- /dev/null +++ b/packages/components/tree-select/demo/Size.md @@ -0,0 +1,14 @@ +--- +order: 3 +title: + zh: 尺寸 + en: Sizes +--- + +## zh + +选择器共有 3 种尺寸:大、中、小,通过设置 `size` 来使用不同的尺寸,默认为中。 + +## en + +There are three sizes in the input box: `sm`, `md`, and `lg`, using different sizes by setting `size`, which defaults to `md`. diff --git a/packages/components/tree-select/demo/Size.vue b/packages/components/tree-select/demo/Size.vue new file mode 100644 index 000000000..2898be223 --- /dev/null +++ b/packages/components/tree-select/demo/Size.vue @@ -0,0 +1,56 @@ + + + diff --git a/packages/components/tree-select/demo/Virtual.md b/packages/components/tree-select/demo/Virtual.md new file mode 100644 index 000000000..d996ddd46 --- /dev/null +++ b/packages/components/tree-select/demo/Virtual.md @@ -0,0 +1,14 @@ +--- +title: + zh: 虚拟滚动 + en: Virtual scroll +order: 9 +--- + +## zh + +当节点过多时,可以设置 `virtual` 来开启虚拟滚动。 + +## en + +When there are too many nodes, you can set `virtual` to enable virtual scrolling. diff --git a/packages/components/tree-select/demo/Virtual.vue b/packages/components/tree-select/demo/Virtual.vue new file mode 100644 index 000000000..a64a355f3 --- /dev/null +++ b/packages/components/tree-select/demo/Virtual.vue @@ -0,0 +1,32 @@ + + + diff --git a/packages/components/tree-select/docs/Index.en.md b/packages/components/tree-select/docs/Index.en.md new file mode 100644 index 000000000..9f44963ba --- /dev/null +++ b/packages/components/tree-select/docs/Index.en.md @@ -0,0 +1,31 @@ +--- +category: components +type: Data Entry +title: TreeSelect +subtitle: +order: 0 +--- + + + +## API + +### IxTreeSelect + +#### TreeSelectProps + +| Name | Description | Type | Default | Global Config | Remark | +| --- | --- | --- | --- | --- | --- | +| - | - | - | - | ✅ | - | + +#### TreeSelectSlots + +| Name | Description | Parameter Type | Remark | +| --- | --- | --- | --- | +| - | - | - | - | + +#### TreeSelectMethods + +| Name | Description | Parameter Type | Remark | +| --- | --- | --- | --- | +| - | - | - | - | diff --git a/packages/components/tree-select/docs/Index.zh.md b/packages/components/tree-select/docs/Index.zh.md new file mode 100644 index 000000000..bb8251188 --- /dev/null +++ b/packages/components/tree-select/docs/Index.zh.md @@ -0,0 +1,108 @@ +--- +category: components +type: 数据录入 +title: TreeSelect 树型选择 +subtitle: +order: 0 +--- + + + +## API + +### IxTreeSelect + +#### TreeSelectProps + +| 名称 | 说明 | 类型 | 默认值 | 全局配置 | 备注 | +| --- | --- | --- | --- | --- | --- | +| `control` | 控件控制器 | `string \| number \| AbstractControl` | - | - | 配合 `@idux/cdk/forms` 使用, 参考 [Form](/components/form/zh) | +| `v-model:value` | 选中节点的 `key` 数组 | `VKey[]` | - | - | - | +| `v-model:expandedKeys` | 展开节点的 `key` 数组 | `VKey[]` | - | - | - | +| `v-model:loadedKeys` | 已经加载完毕的节点的 `key` 数组 | `VKey[]` | - | - | - | +| `v-model:open` | 下拉菜单是否展开 | `boolean` | - | - | - | +| `autofocus` | 默认获取焦点 | `boolean` | `false` | - | - | +| `cascade` | 是否开启级联功能 | `boolean` | `false` | - | 仅在 `multiple` 和 `checkable` 为 `true` 时生效 | +| `clearable` | 是否显示清除图标 | `boolean` | `false` | - | - | +| `checkable` | 是否显示选择框 | `boolean` | `false` | - | 仅在 `multiple` 为 `true` 时生效 | +| `childrenKey` | 替代[TreeSelectNode](#TreeSelectNode)中的`children`字段 | `string` | `children` | ✅ | - | +| `checkStrategy` | 勾选策略 | `'all' \| 'parent' \| 'child'` | `'all'` | - | 设置勾选策略来指定显示的勾选节点,`all` 表示显示全部选中节点;`parent` 表示只显示父节点(当父节点下所有子节点都选中时);`child` 表示只显示子节点,仅当`cascade`为`true`时,`parent`和`child`才生效 | +| `dataSource` | 树型数据数组,参见[TreeSelectNode](#TreeSelectNode) | `TreeSelectNode[]` | `[]` | - | - | +| `disabled` | 是否禁用状态 | `boolean` | `false` | - | - | +| `draggable` | 是否允许拖拽节点 | `boolean` | `false` | - | - | +| `droppable` | 是否允许放置节点,参见[TreeDroppable](/components/tree/zh#TreeDroppable) | `TreeDroppable` | - | - | - | +| `empty` | 空数据时的内容 | `string \|` [EmptyProps](/components/empty/zh#EmptyProps) | - | - | - | +| `expandIcon` | 树组件中的展开图标 | `string` | `right` | ✅ | - | +| `maxLabelCount` | 最多显示多少个标签 | `number` | - | - | - | +| `multiple` | 多选模式 | `boolean` | `false` | - | - | +| `nodeKey` | 替代[TreeSelectNode](#TreeSelectNode)中的`key`字段 | `string \| (node: TreeSelectNode) => VKey` | `key` | ✅ | - +| `labelKey` | 替代[TreeSelectNode](#TreeSelectNode)中的`label`字段 | `string` | `label` | ✅ | - +| `leafLineIcon` | 叶子节点的图标,用于替换默认的连接线 | `string` | - | - | 仅在 `showLine` 时生效 | +| `loadChildren` | 加载子节点数据 | `(node: TreeSelectNode) => Promise` | - | - | - | +| `searchFn` | 搜索函数 | `(node: TreeSelectNode, searchValue?: string) => boolean` | - | - | - +| `searchable` | 是否开启搜索功能 | `boolean \| 'overlay'` | - | - | 当为 `true` 时搜索功能集成在选择器上,当为 `overlay` 时,搜索功能集成在悬浮层上 +| `showLine` | 是否显示连接线 | `boolean` | `false` | ✅ | - | +| `size` | 设置选择器大小 | `'sm' \| 'md' \| 'lg'` | `md` | ✅ | - | +| `suffix` | 设置后缀图标 | `string \| #suffix` | `down` | ✅ | - | +| `target` | 自定义浮层容器节点 | `string \| HTMLElement \| () => string \| HTMLElement` | - | ✅ | - | +| `treeDisabled` | 树的禁用节点的函数 | 参考 [Tree](/components/tree/zh#API) | - | - | - | +| `virtual` | 是否开启虚拟滚动 | `boolean` | `false` | - | - | +| `overlayClassName` | 下拉菜单的 `class` | `string` | - | - | - | +| `overlayRender` | 自定义下拉菜单内容的渲染 | `(children:VNode[]) => VNodeTypes` | - | - | - | +| `placeholder` | 选择框默认文本 | `string` | - | - | - | +| `readonly` | 只读模式 | `boolean` | - | - | - | +| `onChange` | 选择值发生变化时触发 | `(value: any, oldValue: any, node: TreeSelectNode \| TreeSelectNode[] => void` | - | - | - | +| `onCheck` | 选择框勾选状态发生变化时触发 | `(checked: boolean, node: TreeSelectNode) => void` | - | - | - | +| `onDragStart` | `dragstart` 触发时调用 | `(options: TreeDragDropOptions) => void` | - | - | - | +| `onDragEnd` | `dragend` 触发时调用 | `(options: TreeDragDropOptions) => void` | - | - | - | +| `onDragEnter` | `dragenter` 触发时调用 | `(options: TreeDragDropOptions) => void` | - | - | - | +| `onDragLeave` | `dragleave` 触发时调用 | `(options: TreeDragDropOptions) => void` | - | - | - | +| `onDragOver` | `dragover` 触发时调用 | `(options: TreeDragDropOptions) => void` | - | - | - | +| `onDrop` | `drop` 触发时调用 | `(options: TreeDragDropOptions) => void` | - | - | - | +| `onExpand` | 点击展开图标时触发 | `(expanded: boolean, node: TreeSelectNode) => void` | - | - | - | +| `onExpandedChange` | 展开状态发生变化时触发 | `(expendedKeys: VKey[], expendedNodes: TreeSelectNode[]) => void` | - | - | - | +| `onLoaded` | 子节点加载完毕时触发 | `(loadedKeys: VKey[], node: TreeSelectNode) => void` | - | - | - | +| `onSearchedChange` | 搜索状态发生变化时调用 | `(searchedKeys: VKey[], searchedNodes: TreeSelectNode[]) => void` | - | - | - | +| `onSelect` | 选中状态发生变化时触发 | `(selected: boolean, node: TreeSelectNode) => void` | - | - | - | +| `onNodeClick` | 节点点击事件 | `(evt: Event, node: TreeSelectNode) => void` | - | - | - | +| `onNodeContextmenu` | 节点右击事件 | `(evt: Event, node: TreeSelectNode) => void` | - | - | - | +| `onScroll` | 滚动事件 | `(evt: Event) => void` | - | - | - | +| `onScrolledChange` | 滚动的位置发生变化 | `(startIndex: number, endIndex: number, visibleNodes: TreeSelectNode[]) => void` | - | - | 仅 `virtual` 模式下可用 | +| `onScrolledBottom` | 滚动到底部时触发 | `() => void` | - | - | 仅 `virtual` 模式下可用 | + +##### TreeSelectNode + +| 名称 | 说明 | 类型 | 默认值 | 全局配置 | 备注 | +| --- | --- | --- | --- | --- | --- | +| `additional` | 节点的扩展属性 | `object` | - | - | 可以用于设置节点的 `class`, `style` 或者其他属性 | +| `children` | 子节点数据 | `TreeNode[]` | - | - | - | +| `disabled` | 禁用节点 | `boolean \| TreeNodeDisabled` | - | - | 为 `true` 时,优先级高于 `TreeProps` | +| `isLeaf` | 设置为叶子节点 | `boolean` | - | - | 为 `false`,且设置了 `loadChildren` 时会强制将其作为父节点 | +| `key` | 节点的唯一标识 | `VKey` | - | - | - | +| `label` | 节点的文本 | `string` | - | - | - | +| `prefix` | 前缀图标 | `string` | - | - | - | +| `suffix` | 后缀图标 | `string` | - | - | - | + +#### TreeSelectSlots + +| 名称 | 说明 | 参数类型 | 备注 | +| --- | --- | --- | --- | +| `empty` | 自定义当下拉列表为空时显示的内容 | - | - | +| `expandIcon` | 节点展开图标 | `{key: VKey, expanded: boolean, node: TreeSelectNode}` | - | +| `label` | 自定义选中的标签 | `{node: RawNode}` | `RawNode`为用户传入的数据结构 | +| `leafLineIcon` | 叶子节点的图标,用于替换默认的连接线 | - | 仅在 `showLine 时生效` | +| `maxLabel` | 自定义超出最多显示多少个标签的内容 | `{nodes: RawNode[]}` | 参数为超出的数组 | +| `suffix` | 后缀图标 | - | - | +| `placeholder` | 选择框默认文本 | - | - | +| `treeLabel` | 自定义节点的文本 | `{node: TreeSelectNode}` | - | +| `treePrefix` | 自定义节点的前缀图标 | `{key: VKey, selected: boolean, node: TreeSelectNode}` | - | +| `treeSuffix` | 自定义节点的后缀图标 | `{key: VKey, selected: boolean, node: TreeSelectNode}` | - | + +#### TreeSelectMethods + +| 名称 | 说明 | 参数类型 | 备注 | +| --- | --- | --- | --- | +| `blur` | 失去焦点 | - | - | +| `focus` | 获取焦点 | - | - | +| `scrollTo` | 滚动到指定位置 | `(option?: number \| VirtualScrollToOptions) => void` | 仅 `virtual` 模式下可用 | +| `setExpandAll` | 控制树节点是否全部展开 | `(isAll: boolean) => void` | - | diff --git a/packages/components/tree-select/index.ts b/packages/components/tree-select/index.ts new file mode 100644 index 000000000..96c2a4e51 --- /dev/null +++ b/packages/components/tree-select/index.ts @@ -0,0 +1,16 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import type { TreeSelectComponent } from './src/types' + +import TreeSelect from './src/TreeSelect' + +const IxTreeSelect = TreeSelect as unknown as TreeSelectComponent + +export { IxTreeSelect } + +export type { TreeSelectInstance, TreeSelectPublicProps as TreeSelectProps, TreeSelectNode } from './src/types' diff --git a/packages/components/tree-select/src/TreeSelect.tsx b/packages/components/tree-select/src/TreeSelect.tsx new file mode 100644 index 000000000..0c519abbe --- /dev/null +++ b/packages/components/tree-select/src/TreeSelect.tsx @@ -0,0 +1,150 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import type { TreeSelectNode } from './types' +import type { VirtualScrollToFn } from '@idux/cdk/scroll' +import type { VKey } from '@idux/cdk/utils' +import type { TreeInstance } from '@idux/components/tree' + +import { computed, defineComponent, normalizeClass, provide, ref, watch } from 'vue' + +import { useSharedFocusMonitor } from '@idux/cdk/a11y' +import { callEmit, useControlledProp } from '@idux/cdk/utils' +import { ɵOverlay } from '@idux/components/_private/overlay' +import { useGlobalConfig } from '@idux/components/config' +import { useFormElement } from '@idux/components/utils' + +import { useAccessor } from './composables/useAccessor' +import { useMergeNodes } from './composables/useDataSource' +import { useGetNodeKey } from './composables/useGetNodeKey' +import { useInputState } from './composables/useInputState' +import { useOverlayProps } from './composables/useOverlayProps' +import { useSelectedState } from './composables/useSelectedState' +import Content from './content/Content' +import { treeSelectToken } from './token' +import Trigger from './trigger/Trigger' +import { treeSelectProps } from './types' + +const defaultOffset: [number, number] = [0, 8] + +export default defineComponent({ + name: 'IxTreeSelect', + inheritAttrs: false, + props: treeSelectProps, + setup(props, { attrs, expose, slots }) { + const common = useGlobalConfig('common') + const mergedPrefixCls = computed(() => `${common.prefixCls}-tree-select`) + const config = useGlobalConfig('treeSelect') + const getNodeKey = useGetNodeKey(props, config) + const searchValue = ref('') + const [expandedKeys, setExpandedKeys] = useControlledProp(props, 'expandedKeys', () => []) + const focusMonitor = useSharedFocusMonitor() + const { elementRef: inputRef, focus, blur } = useFormElement() + const { accessor, isDisabled } = useAccessor() + const inputStateContext = useInputState(props, inputRef, accessor, searchValue) + const { clearInput } = inputStateContext + const { mergedNodeMap } = useMergeNodes(props, getNodeKey, config) + + const selectedStateContext = useSelectedState(props, accessor, mergedNodeMap) + + const triggerRef = ref() + const { overlayRef, overlayStyle, overlayOpened, setOverlayOpened } = useOverlayProps(props, triggerRef) + + const treeRef = ref() + const scrollTo: VirtualScrollToFn = options => { + treeRef.value?.scrollTo(options) + } + const setExpandAll = (isAll: boolean) => { + const _expendedKeys: VKey[] = [] + const _expendedNodes: TreeSelectNode[] = [] + if (isAll) { + mergedNodeMap.value.forEach(node => { + if (!node.isLeaf) { + _expendedKeys.push(node.key) + _expendedNodes.push(node.rawNode) + } + }) + } + callEmit(props.onExpandedChange, _expendedKeys, _expendedNodes) + setExpandedKeys(_expendedKeys) + } + + expose({ focus, blur, scrollTo, setExpandAll }) + + const handleNodeClick = () => { + if (props.multiple) { + focus() + clearInput() + } else { + setOverlayOpened(false) + } + } + + provide(treeSelectToken, { + props, + slots, + config, + getNodeKey, + expandedKeys, + mergedPrefixCls, + mergedNodeMap, + focusMonitor, + triggerRef, + treeRef, + inputRef, + overlayOpened, + accessor, + isDisabled, + searchValue, + setExpandedKeys, + setExpandAll, + handleNodeClick, + setOverlayOpened, + ...selectedStateContext, + ...inputStateContext, + }) + + watch(overlayOpened, opened => { + opened ? focus() : blur() + clearInput() + }) + + const classes = computed(() => { + const { overlayClassName } = props + const prefixCls = mergedPrefixCls.value + return normalizeClass({ + [`${prefixCls}-overlay`]: true, + [overlayClassName || '']: !!overlayClassName, + }) + }) + + const target = computed(() => props.target ?? config.target ?? `${mergedPrefixCls.value}-overlay-container`) + + return () => { + const renderTrigger = () => + const renderContent = () => + const overlayProps = { 'onUpdate:visible': setOverlayOpened } + + return ( + <ɵOverlay + ref={overlayRef} + {...overlayProps} + v-slots={{ default: renderTrigger, content: renderContent }} + visible={overlayOpened.value} + class={classes.value} + style={overlayStyle.value} + target={target.value} + offset={defaultOffset} + disabled={isDisabled.value || props.readonly} + clickOutside + placement="bottom" + trigger="manual" + /> + ) + } + }, +}) diff --git a/packages/components/tree-select/src/composables/useAccessor.ts b/packages/components/tree-select/src/composables/useAccessor.ts new file mode 100644 index 000000000..7e01ad343 --- /dev/null +++ b/packages/components/tree-select/src/composables/useAccessor.ts @@ -0,0 +1,27 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import type { FormAccessor } from '@idux/cdk/forms' +import type { ComputedRef } from 'vue' + +import { computed } from 'vue' + +import { useValueAccessor } from '@idux/cdk/forms' +import { useFormItemRegister } from '@idux/components/form' + +export interface AccessorContext { + accessor: FormAccessor + isDisabled: ComputedRef +} + +export function useAccessor(): AccessorContext { + const { accessor, control } = useValueAccessor() + useFormItemRegister(control) + const isDisabled = computed(() => accessor.disabled.value) + + return { accessor, isDisabled } +} diff --git a/packages/components/tree-select/src/composables/useDataSource.ts b/packages/components/tree-select/src/composables/useDataSource.ts new file mode 100644 index 000000000..ecc69e6cf --- /dev/null +++ b/packages/components/tree-select/src/composables/useDataSource.ts @@ -0,0 +1,131 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import type { TreeSelectNode, TreeSelectNodeDisabled, TreeSelectProps } from '../types' +import type { GetNodeKey } from './useGetNodeKey' +import type { VKey } from '@idux/cdk/utils' +import type { TreeSelectConfig } from '@idux/components/config' +import type { ComputedRef } from 'vue' + +import { computed } from 'vue' + +import { isBoolean } from 'lodash-es' + +export interface MergedNode { + children?: MergedNode[] + label: string + isLeaf: boolean + key: VKey + parentKey?: VKey + rawNode: TreeSelectNode + checkDisabled?: boolean + dragDisabled?: boolean + dropDisabled?: boolean + selectDisabled?: boolean +} + +export function useMergeNodes( + props: TreeSelectProps, + getNodeKey: ComputedRef, + config: TreeSelectConfig, +): { + mergedNodeMap: ComputedRef> +} { + const mergedNodeMap = computed(() => { + const map = new Map() + const nodes = covertMergeNodes(props, getNodeKey, props.dataSource, config) + covertMergedNodeMap(nodes, map) + return map + }) + + return { mergedNodeMap } +} + +export function covertMergeNodes( + props: TreeSelectProps, + getNodeKey: ComputedRef, + nodes: TreeSelectNode[], + config: TreeSelectConfig, + parentKey?: VKey, +): MergedNode[] { + const getKey = getNodeKey.value + + const { childrenKey = config.childrenKey, labelKey = config.labelKey, treeDisabled, loadChildren } = props + + return nodes.map(option => + covertMergeNode(option, getKey, treeDisabled, childrenKey, labelKey, !!loadChildren, parentKey), + ) +} + +function covertMergeNode( + rawNode: TreeSelectNode, + getKey: GetNodeKey, + disabled: ((node: TreeSelectNode) => boolean | TreeSelectNodeDisabled) | undefined, + childrenKey: string, + labelKey: string, + hasLoad: boolean, + parentKey?: VKey, +): MergedNode { + const key = getKey(rawNode) + const { check, drag, drop, select } = covertDisabled(rawNode, disabled) + const subNodes = (rawNode as Record)[childrenKey] as TreeSelectNode[] | undefined + const label = rawNode[labelKey] as string + const children = subNodes?.map(subNode => + covertMergeNode(subNode, getKey, disabled, childrenKey, labelKey, hasLoad, key), + ) + return { + children, + label, + key, + isLeaf: rawNode.isLeaf ?? !(children?.length || hasLoad), + parentKey, + rawNode, + checkDisabled: check, + dragDisabled: drag, + dropDisabled: drop, + selectDisabled: select, + } +} + +function covertDisabled( + option: TreeSelectNode, + disabled?: (option: TreeSelectNode) => boolean | TreeSelectNodeDisabled, +) { + const optionDisabled = option.disabled + if (isBoolean(optionDisabled)) { + return { check: optionDisabled, drag: optionDisabled, drop: optionDisabled, select: optionDisabled } + } else { + // In treeSelect , check and select are combined into one option + let { drag, drop, select } = optionDisabled ?? {} + let check + if (disabled) { + const treeDisabled = disabled(option) + if (isBoolean(treeDisabled)) { + check ??= treeDisabled + drag ??= treeDisabled + drop ??= treeDisabled + select ??= treeDisabled + } else { + drag ??= treeDisabled.drag + drop ??= treeDisabled.drop + select ??= treeDisabled.select + check ??= treeDisabled.select + } + } + return { check, drag, drop, select } + } +} + +export function covertMergedNodeMap(MergedNodes: MergedNode[], map: Map): void { + MergedNodes.forEach(item => { + const { key, children } = item + map.set(key, item) + if (children) { + covertMergedNodeMap(children, map) + } + }) +} diff --git a/packages/components/tree-select/src/composables/useGetNodeKey.ts b/packages/components/tree-select/src/composables/useGetNodeKey.ts new file mode 100644 index 000000000..885eb241e --- /dev/null +++ b/packages/components/tree-select/src/composables/useGetNodeKey.ts @@ -0,0 +1,36 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import type { TreeSelectNode, TreeSelectProps } from '../types' +import type { VKey } from '@idux/cdk/utils' +import type { TreeSelectConfig } from '@idux/components/config' +import type { ComputedRef } from 'vue' + +import { computed } from 'vue' + +import { isString } from 'lodash-es' + +import { Logger } from '@idux/cdk/utils' + +export type GetNodeKey = (rawNode: TreeSelectNode) => VKey + +export function useGetNodeKey(props: TreeSelectProps, config: TreeSelectConfig): ComputedRef { + return computed(() => { + const nodeKey = props.nodeKey ?? config.nodeKey + if (isString(nodeKey)) { + return (rawNode: TreeSelectNode) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const key = (rawNode as any)[nodeKey] + if (__DEV__ && key === undefined) { + Logger.warn('components/treeSelect', 'Each data in dataSource should have a unique `key` prop.') + } + return key + } + } + return nodeKey + }) +} diff --git a/packages/components/tree-select/src/composables/useInputState.ts b/packages/components/tree-select/src/composables/useInputState.ts new file mode 100644 index 000000000..7ea0215dd --- /dev/null +++ b/packages/components/tree-select/src/composables/useInputState.ts @@ -0,0 +1,92 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { TreeSelectProps } from '../types' +import type { FormAccessor } from '@idux/cdk/forms' +import type { Ref } from 'vue' + +import { onMounted, ref } from 'vue' + +import { callEmit } from '@idux/cdk/utils' + +export interface InputStateContext { + mirrorRef: Ref + inputWidth: Ref + isFocused: Ref + handleInput: (evt: Event) => void + handleFocus: (evt: FocusEvent) => void + handleBlur: (evt: FocusEvent) => void + clearInput: () => void +} + +export function useInputState( + props: TreeSelectProps, + inputRef: Ref, + accessor: FormAccessor, + searchValue: Ref, +): InputStateContext { + const mirrorRef = ref() + const inputWidth = ref('') + const isFocused = ref(false) + + const syncMirrorWidth = () => { + if (props.multiple) { + const inputElement = inputRef.value + const mirrorElement = mirrorRef.value + if (inputElement && mirrorElement) { + // don't remove the space char, this is placeholder. + mirrorElement.innerText = ` ${inputElement.value}` + inputWidth.value = `${mirrorElement.scrollWidth}px` + } + } + } + + const handleInput = (evt: Event) => { + if (props.searchable === true) { + const { value } = evt.target as HTMLInputElement + if (value !== searchValue.value) { + searchValue.value = value + } + syncMirrorWidth() + } + } + + const handleFocus = (evt: FocusEvent) => { + isFocused.value = true + + callEmit(props.onFocus, evt) + } + + const handleBlur = (evt: FocusEvent) => { + isFocused.value = false + callEmit(props.onBlur, evt) + accessor.markAsBlurred?.() + } + + const clearInput = () => { + const inputElement = inputRef.value + if (inputElement) { + inputElement.value = '' + } + searchValue.value = '' + syncMirrorWidth() + } + + onMounted(() => syncMirrorWidth()) + + return { + mirrorRef, + inputWidth, + isFocused, + handleInput, + handleFocus, + handleBlur, + clearInput, + } +} diff --git a/packages/components/tree-select/src/composables/useOverlayProps.ts b/packages/components/tree-select/src/composables/useOverlayProps.ts new file mode 100644 index 000000000..232f65815 --- /dev/null +++ b/packages/components/tree-select/src/composables/useOverlayProps.ts @@ -0,0 +1,57 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import type { TreeSelectProps } from '../types' +import type { ɵOverlayInstance } from '@idux/components/_private/overlay' +import type { CSSProperties, ComputedRef, Ref } from 'vue' + +import { computed, onBeforeUnmount, onMounted, ref, watchEffect } from 'vue' + +import { convertCssPixel, offResize, onResize, useControlledProp } from '@idux/cdk/utils' + +export interface OverlayPropsContext { + overlayRef: Ref<ɵOverlayInstance | undefined> + overlayStyle: ComputedRef + overlayOpened: ComputedRef + setOverlayOpened: (open: boolean) => void +} + +export function useOverlayProps( + props: TreeSelectProps, + triggerRef: Ref, +): OverlayPropsContext { + const overlayRef = ref<ɵOverlayInstance>() + const overlayWidth = ref() + const overlayStyle = computed(() => ({ width: overlayWidth.value })) + const [overlayOpened, setOverlayOpened] = useControlledProp(props, 'open', false) + + const updatePopper = () => { + overlayWidth.value = convertCssPixel(triggerRef.value?.getBoundingClientRect().width) + + overlayRef.value?.updatePopper() + } + + onMounted(() => { + if (props.autofocus) { + setOverlayOpened(true) + } + + watchEffect(() => { + if (overlayOpened.value) { + updatePopper() + } + }) + + onResize(triggerRef.value!, updatePopper) + }) + + onBeforeUnmount(() => { + offResize(triggerRef.value!, updatePopper) + }) + + return { overlayRef, overlayStyle, overlayOpened, setOverlayOpened } +} diff --git a/packages/components/tree-select/src/composables/useSelectedState.ts b/packages/components/tree-select/src/composables/useSelectedState.ts new file mode 100644 index 000000000..7bbd16428 --- /dev/null +++ b/packages/components/tree-select/src/composables/useSelectedState.ts @@ -0,0 +1,72 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { TreeSelectNode, TreeSelectProps } from '../types' +import type { MergedNode } from './useDataSource' +import type { FormAccessor } from '@idux/cdk/forms' +import type { VKey } from '@idux/cdk/utils' +import type { ComputedRef } from 'vue' + +import { computed, toRaw } from 'vue' + +import { callEmit, convertArray } from '@idux/cdk/utils' + +//import { generateOption } from '../utils/generateOption' + +export interface SelectedStateContext { + selectedValue: ComputedRef + selectedNodes: ComputedRef + changeSelected: (value: any[], nodes: TreeSelectNode[]) => void + handleItemRemove: (key: any) => void + handleClear: (evt: MouseEvent) => void +} + +export function useSelectedState( + props: TreeSelectProps, + accessor: FormAccessor, + mergedNodeMap: ComputedRef>, +): SelectedStateContext { + const selectedValue = computed(() => convertArray(accessor.valueRef.value)) + const selectedNodes = computed(() => { + const nodesMap = mergedNodeMap.value + return selectedValue.value.map(value => nodesMap.get(value)).filter(Boolean) + }) + + const setValue = (value: any[], nodes?: TreeSelectNode[]) => { + const currValue = props.multiple ? value : value[0] + const node = props.multiple ? nodes : nodes?.[0] + const oldValue = toRaw(accessor.valueRef.value) + if (currValue !== oldValue) { + accessor.setValue(currValue) + callEmit(props.onChange, currValue, oldValue, node) + } + } + + const changeSelected = (value: any[], nodes: TreeSelectNode[]) => { + setValue(value, nodes) + } + + const handleItemRemove = (key: any) => { + setValue(selectedValue.value.filter(item => key !== item)) + } + + const handleClear = (evt: MouseEvent) => { + evt.stopPropagation() + setValue([]) + callEmit(props.onClear, evt) + } + + return { + selectedValue, + selectedNodes, + changeSelected, + handleItemRemove, + handleClear, + } +} diff --git a/packages/components/tree-select/src/content/Content.tsx b/packages/components/tree-select/src/content/Content.tsx new file mode 100644 index 000000000..30f0aabec --- /dev/null +++ b/packages/components/tree-select/src/content/Content.tsx @@ -0,0 +1,240 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { TreeSelectNode } from '../types' +import type { VKey } from '@idux/cdk/utils' + +import { computed, defineComponent, inject, ref } from 'vue' + +import { callEmit, useControlledProp } from '@idux/cdk/utils' +import { IxButton } from '@idux/components/button' +import { IxInput } from '@idux/components/input' +import { IxTree } from '@idux/components/tree' + +import { covertMergeNodes, covertMergedNodeMap } from '../composables/useDataSource' +import { treeSelectToken } from '../token' + +export default defineComponent({ + setup() { + const { + config, + props, + slots: treeSelectSlots, + getNodeKey, + mergedPrefixCls, + mergedNodeMap, + selectedValue, + searchValue, + expandedKeys, + treeRef, + setExpandedKeys, + setExpandAll, + changeSelected, + handleNodeClick, + } = inject(treeSelectToken)! + + //onMounted(() => scrollToActivated()) + + const [loadedKeys, setLoadedKeys] = useControlledProp(props, 'loadedKeys', () => []) + const expandAllBtnStatus = ref(false) + + const handleScrolledChange = (startIndex: number, endIndex: number, visibleNodes: any[]) => { + const { onScrolledChange } = props + callEmit( + onScrolledChange, + startIndex, + endIndex, + visibleNodes.map(item => item.rawNode), + ) + } + + // 防止异步请求模式下触发outside click + const handleClick = (evt: Event) => { + evt.stopPropagation() + } + + const handleCheck = (checked: boolean, node: TreeSelectNode) => { + const { onCheck } = props + callEmit(onCheck, checked, node) + handleNodeClick() + } + + const handleSelect = (selected: boolean, node: TreeSelectNode) => { + const { onSelect } = props + callEmit(onSelect, selected, node) + handleNodeClick() + } + + const handleExpand = (expanded: boolean, node: TreeSelectNode) => { + const { onExpand } = props + callEmit(onExpand, expanded, node) + } + + const handleExpandedChange = (expendedKeys: VKey[], expendedNodes: TreeSelectNode[]) => { + const { onExpandedChange } = props + callEmit(onExpandedChange, expendedKeys, expendedNodes) + setExpandedKeys(expendedKeys) + } + + const handleExpandAll = (evt: Event) => { + const currStatus = expandAllBtnStatus.value + setExpandAll(!currStatus) + expandAllBtnStatus.value = !currStatus + evt.stopPropagation() + } + + const onLoaded = async (loadedKeys: VKey[], node: TreeSelectNode) => { + const childrenNodes = node.children ?? [] + const key = node.key! + const nodeMap = mergedNodeMap.value + const currNode = nodeMap.get(key) + if (childrenNodes.length && currNode) { + const mergedChildren = covertMergeNodes(props, getNodeKey, childrenNodes, config, key) + covertMergedNodeMap(mergedChildren, nodeMap) + currNode.rawNode.children = childrenNodes + currNode.children = mergedChildren + setLoadedKeys(loadedKeys) + callEmit(props.onLoaded, loadedKeys, node) + } + } + + const handleSearchInput = (evt: Event) => { + const { value } = evt.target as HTMLInputElement + if (value !== searchValue.value) { + searchValue.value = value + } + } + + const handleSearchClear = (evt: Event) => { + searchValue.value = '' + evt.stopPropagation() + } + + const checkable = computed(() => props.multiple && props.checkable) + const cascade = computed(() => checkable.value && props.cascade) + + return () => { + const { + checkStrategy, + childrenKey, + dataSource, + draggable, + empty, + expandIcon, + multiple, + nodeKey, + leafLineIcon, + labelKey, + virtual, + searchable, + showLine, + onDragstart, + onDragend, + onDragenter, + onDragleave, + onDragover, + onDrop, + onNodeClick, + onNodeContextmenu, + onScroll, + onScrolledBottom, + onSearchedChange, + droppable, + treeDisabled, + loadChildren, + searchFn, + overlayRender, + overlayHeight, + } = props + + const prefixCls = mergedPrefixCls.value + const treeSlots = { + label: treeSelectSlots.treeLabel, + prefix: treeSelectSlots.treePrefix, + suffix: treeSelectSlots.treeSuffix, + leafLineIcon: treeSelectSlots.leafLineIcon, + empty: treeSelectSlots.empty, + expandIcon: treeSelectSlots.expandIcon, + } + + const children = [ + , + ] + + if (searchable === 'overlay') { + children.unshift( +
+ + +
, + ) + } + + return overlayRender ? overlayRender(children) :
{children}
+ } + }, +}) diff --git a/packages/components/tree-select/src/token.ts b/packages/components/tree-select/src/token.ts new file mode 100644 index 000000000..f091f77f0 --- /dev/null +++ b/packages/components/tree-select/src/token.ts @@ -0,0 +1,44 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { AccessorContext } from './composables/useAccessor' +import type { MergedNode } from './composables/useDataSource' +import type { GetNodeKey } from './composables/useGetNodeKey' +import type { InputStateContext } from './composables/useInputState' +import type { SelectedStateContext } from './composables/useSelectedState' +import type { TreeSelectProps } from './types' +import type { FocusMonitor } from '@idux/cdk/a11y' +import type { VirtualScrollInstance } from '@idux/cdk/scroll' +import type { VKey } from '@idux/cdk/utils' +import type { TreeSelectConfig } from '@idux/components/config' +import type { TreeInstance } from '@idux/components/tree' +import type { ComputedRef, InjectionKey, Ref, Slots } from 'vue' + +export interface TreeSelectContext extends AccessorContext, InputStateContext, SelectedStateContext { + props: TreeSelectProps + slots: Slots + config: TreeSelectConfig + getNodeKey: ComputedRef + expandedKeys: ComputedRef + mergedPrefixCls: ComputedRef + mergedNodeMap: ComputedRef> + focusMonitor: FocusMonitor + inputRef: Ref + virtualScrollRef?: Ref + triggerRef: Ref + treeRef: Ref + overlayOpened: ComputedRef + searchValue: Ref + setExpandedKeys: (value: any[]) => void + setExpandAll: (isAll: boolean) => void + setOverlayOpened: (open: boolean) => void + handleNodeClick: () => void +} + +export const treeSelectToken: InjectionKey = Symbol('treeSelectToken') diff --git a/packages/components/tree-select/src/trigger/Input.tsx b/packages/components/tree-select/src/trigger/Input.tsx new file mode 100644 index 000000000..4ce2efdb7 --- /dev/null +++ b/packages/components/tree-select/src/trigger/Input.tsx @@ -0,0 +1,50 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import { computed, defineComponent, inject } from 'vue' + +import { treeSelectToken } from '../token' + +export default defineComponent({ + setup() { + const { props, mergedPrefixCls, isDisabled, inputRef, inputWidth, mirrorRef, searchValue, handleInput } = + inject(treeSelectToken)! + + const onClick = (evt: Event) => { + // 多选时input宽度会变小,导致点击trigger时会让input的click事件冒泡,触发两次浮层的展开收缩逻辑 + if (props.multiple) { + evt.stopPropagation() + } + } + const style = computed(() => ({ width: inputWidth.value })) + const innerStyle = computed(() => { + const { searchable } = props + return { opacity: searchable === true ? undefined : 0 } + }) + + return () => { + const { autofocus, multiple } = props + const prefixCls = `${mergedPrefixCls.value}-selector-input` + return ( +
+ + {multiple && } +
+ ) + } + }, +}) diff --git a/packages/components/tree-select/src/trigger/Item.tsx b/packages/components/tree-select/src/trigger/Item.tsx new file mode 100644 index 000000000..9ef9cd198 --- /dev/null +++ b/packages/components/tree-select/src/trigger/Item.tsx @@ -0,0 +1,46 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { FunctionalComponent } from 'vue' + +import { IxIcon } from '@idux/components/icon' +import { useKey } from '@idux/components/utils' + +interface ItemProps { + disabled?: boolean + prefixCls: string + removable: boolean + readonly: boolean + handleItemRemove: (key: any) => void +} + +const Item: FunctionalComponent = (props, { slots }) => { + const { disabled, prefixCls, removable, readonly, handleItemRemove } = props + + const key = useKey() + const classes = prefixCls + (disabled ? ` ${prefixCls}-disabled` : '') + + const handleClick = (evt: Event) => { + evt.stopPropagation() + handleItemRemove(key) + } + + return ( +
+ {slots.default!()} + {!disabled && !readonly && removable && ( + + + + )} +
+ ) +} + +export default Item diff --git a/packages/components/tree-select/src/trigger/Selector.tsx b/packages/components/tree-select/src/trigger/Selector.tsx new file mode 100644 index 000000000..239da8bd6 --- /dev/null +++ b/packages/components/tree-select/src/trigger/Selector.tsx @@ -0,0 +1,101 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import type { MergedNode } from '../composables/useDataSource' +import type { VNodeTypes } from 'vue' + +import { computed, defineComponent, inject } from 'vue' + +import { IxIcon } from '@idux/components/icon' + +import { treeSelectToken } from '../token' +import { treeSelectorProps } from '../types' +import Input from './Input' +import Item from './Item' + +export default defineComponent({ + props: treeSelectorProps, + setup(props) { + const { + props: treeSelectProps, + slots, + mergedPrefixCls, + isDisabled, + selectedValue, + selectedNodes, + handleItemRemove, + handleClear, + searchValue, + } = inject(treeSelectToken)! + + const selectedItems = computed(() => { + const { maxLabelCount } = treeSelectProps + const nodes = selectedNodes.value + const items = nodes.slice(0, maxLabelCount) as Array + if (nodes.length > maxLabelCount) { + const label = `+ ${nodes.length - maxLabelCount} ...` + const key = nodes.slice(maxLabelCount).map(node => node.rawNode) + items.push({ isMax: true, key, label } as unknown as MergedNode & { isMax?: boolean }) + } + return items + }) + + const showItems = computed(() => { + return treeSelectProps.multiple || (selectedValue.value.length > 0 && !searchValue.value) + }) + + const showPlaceholder = computed(() => { + return selectedValue.value.length === 0 && (!searchValue.value || treeSelectProps.searchable === 'overlay') + }) + + return () => { + const { clearable, suffix } = props + const { multiple, readonly } = treeSelectProps + const disabled = isDisabled.value + const prefixCls = `${mergedPrefixCls.value}-selector` + + const itemPrefixCls = `${prefixCls}-item` + const itemNodes = selectedItems.value.map(item => { + const { key, isMax, label } = item + const itemProps = { + key, + disabled: disabled || item.selectDisabled, + prefixCls: itemPrefixCls, + removable: multiple && !isMax, + readonly, + title: label, + handleItemRemove, + } + let labelNode: VNodeTypes | undefined + if (isMax) { + labelNode = slots.maxLabel?.(item.key) ?? label + } else { + labelNode = slots.label?.(item.rawNode) ?? label + } + return {labelNode} + }) + + return ( + + ) + } + }, +}) diff --git a/packages/components/tree-select/src/trigger/Trigger.tsx b/packages/components/tree-select/src/trigger/Trigger.tsx new file mode 100644 index 000000000..fb9ce148f --- /dev/null +++ b/packages/components/tree-select/src/trigger/Trigger.tsx @@ -0,0 +1,107 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import { computed, defineComponent, inject, onBeforeUnmount, onMounted, watch } from 'vue' + +import { FORM_TOKEN } from '@idux/components/form' + +import { treeSelectToken } from '../token' +import Selector from './Selector' + +const hiddenBoxStyle = { width: 0, height: 0, display: 'flex', overflow: 'hidden', opacity: 0 } + +export default defineComponent({ + setup() { + const { + props, + slots, + config, + focusMonitor, + mergedPrefixCls, + triggerRef, + isDisabled, + selectedValue, + isFocused, + overlayOpened, + handleFocus, + handleBlur, + setOverlayOpened, + } = inject(treeSelectToken)! + const formContext = inject(FORM_TOKEN, null) + + const clearable = computed(() => { + return !isDisabled.value && props.clearable && selectedValue.value.length > 0 + }) + const searchable = computed(() => { + return !isDisabled.value && props.searchable + }) + + const suffix = computed(() => { + const { suffix } = props + if (suffix) { + return suffix + } + return props.searchable === true && isFocused.value ? 'search' : config.suffix + }) + + const classes = computed(() => { + const { multiple, readonly, size = formContext?.size.value ?? config.size } = props + const disabled = isDisabled.value + const prefixCls = mergedPrefixCls.value + return { + [prefixCls]: true, + [`${prefixCls}-clearable`]: clearable.value, + [`${prefixCls}-disabled`]: disabled, + [`${prefixCls}-readonly`]: readonly, + [`${prefixCls}-focused`]: isFocused.value, + [`${prefixCls}-opened`]: overlayOpened.value, + [`${prefixCls}-multiple`]: multiple, + [`${prefixCls}-single`]: !multiple, + [`${prefixCls}-searchable`]: searchable.value === true, + [`${prefixCls}-with-suffix`]: slots.suffix || suffix.value, + [`${prefixCls}-${size}`]: true, + } + }) + + const handleClick = () => { + const currOpened = overlayOpened.value + if ((currOpened && searchable.value === true) || isDisabled.value) { + return + } + + setOverlayOpened(!currOpened) + } + + onMounted(() => { + watch(focusMonitor.monitor(triggerRef.value!, true), evt => { + const { origin, event } = evt + if (event) { + if (origin) { + handleFocus(event) + } else { + handleBlur(event) + } + } + }) + }) + + onBeforeUnmount(() => focusMonitor.stopMonitoring(triggerRef.value!)) + + return () => { + return ( +
+ {isFocused.value && !overlayOpened.value && ( + + {selectedValue.value.join(', ')} + + )} + +
+ ) + } + }, +}) diff --git a/packages/components/tree-select/src/types.ts b/packages/components/tree-select/src/types.ts new file mode 100644 index 000000000..10285beb4 --- /dev/null +++ b/packages/components/tree-select/src/types.ts @@ -0,0 +1,117 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { VirtualScrollToFn } from '@idux/cdk/scroll' +import type { IxInnerPropTypes, IxPublicPropTypes, VKey } from '@idux/cdk/utils' +import type { EmptyProps } from '@idux/components/empty' +import type { FormSize } from '@idux/components/form' +import type { TreeCheckStrategy, TreeDragDropOptions, TreeDroppable, TreeNode } from '@idux/components/tree' +import type { DefineComponent, HTMLAttributes, VNode, VNodeTypes } from 'vue' + +import { controlPropDef } from '@idux/cdk/forms' +import { ɵPortalTargetDef } from '@idux/cdk/portal' +import { IxPropTypes } from '@idux/cdk/utils' + +export const treeSelectProps = { + value: IxPropTypes.any, + expandedKeys: IxPropTypes.array(), + loadedKeys: IxPropTypes.array(), + control: controlPropDef, + open: IxPropTypes.bool, + + autofocus: IxPropTypes.bool.def(false), + childrenKey: IxPropTypes.string, + cascade: IxPropTypes.bool.def(false), + checkable: IxPropTypes.bool.def(false), + clearable: IxPropTypes.bool.def(false), + checkStrategy: IxPropTypes.oneOf(['all', 'parent', 'child']).def('all'), + dataSource: IxPropTypes.array().def(() => []), + disabled: IxPropTypes.bool.def(false), + draggable: IxPropTypes.bool.def(false), + droppable: IxPropTypes.func(), + empty: IxPropTypes.oneOfType([String, IxPropTypes.object()]), + expandIcon: IxPropTypes.string, + maxLabelCount: IxPropTypes.number.def(Number.MAX_SAFE_INTEGER), + multiple: IxPropTypes.bool.def(false), + labelKey: IxPropTypes.string, + leafLineIcon: IxPropTypes.string, + loadChildren: IxPropTypes.func<(node: TreeSelectNode) => Promise>(), + nodeKey: IxPropTypes.oneOfType([String, IxPropTypes.func<(node: TreeSelectNode) => VKey>()]), + overlayClassName: IxPropTypes.string, + overlayRender: IxPropTypes.func<(children: VNode[]) => VNodeTypes>(), + placeholder: IxPropTypes.string, + readonly: IxPropTypes.bool.def(false), + searchable: IxPropTypes.oneOfType([Boolean, IxPropTypes.oneOf(['overlay'])]).def(false), + searchFn: IxPropTypes.func<(node: TreeSelectNode, searchValue?: string) => boolean>(), + size: IxPropTypes.oneOf(['sm', 'md', 'lg']), + suffix: IxPropTypes.string, + showLine: IxPropTypes.bool, + target: ɵPortalTargetDef, + treeDisabled: IxPropTypes.func<(node: TreeSelectNode) => boolean | TreeSelectNodeDisabled>(), + virtual: IxPropTypes.bool.def(false), + + // events + 'onUpdate:value': IxPropTypes.emit<(value: any) => void>(), + 'onUpdate:expandedKeys': IxPropTypes.emit<(keys: VKey[]) => void>(), + 'onUpdate:loadedKeys': IxPropTypes.emit<(keys: VKey[]) => void>(), + onCheck: IxPropTypes.emit<(checked: boolean, node: TreeSelectNode) => void>(), + onChange: IxPropTypes.emit<(value: any, oldValue: any, node: TreeSelectNode | TreeSelectNode[]) => void>(), + onClear: IxPropTypes.emit<(evt: Event) => void>(), + onDragstart: IxPropTypes.emit<(options: TreeDragDropOptions) => void>(), + onDragend: IxPropTypes.emit<(options: TreeDragDropOptions) => void>(), + onDragenter: IxPropTypes.emit<(options: TreeDragDropOptions) => void>(), + onDragleave: IxPropTypes.emit<(options: TreeDragDropOptions) => void>(), + onDragover: IxPropTypes.emit<(options: TreeDragDropOptions) => void>(), + onDrop: IxPropTypes.emit<(options: TreeDragDropOptions) => void>(), + onExpand: IxPropTypes.emit<(expanded: boolean, node: TreeSelectNode) => void>(), + onExpandedChange: IxPropTypes.emit<(expendedKeys: VKey[], expendedNodes: TreeSelectNode[]) => void>(), + onLoaded: IxPropTypes.emit<(loadedKeys: VKey[], node: TreeSelectNode) => void>(), + onSelect: IxPropTypes.emit<(selected: boolean, node: TreeSelectNode) => void>(), + onBlur: IxPropTypes.emit<(evt: FocusEvent) => void>(), + onFocus: IxPropTypes.emit<(evt: FocusEvent) => void>(), + onNodeClick: IxPropTypes.emit<(evt: Event, node: TreeSelectNode) => void>(), + onNodeContextmenu: IxPropTypes.emit<(evt: Event, node: TreeSelectNode) => void>(), + onSearchedChange: IxPropTypes.emit<(searchedKeys: VKey[], searchedNodes: TreeSelectNode[]) => void>(), + onScroll: IxPropTypes.emit<(evt: Event) => void>(), + onScrolledChange: + IxPropTypes.emit<(startIndex: number, endIndex: number, visibleOptions: TreeSelectNode[]) => void>(), + onScrolledBottom: IxPropTypes.emit<() => void>(), + + // private + overlayHeight: IxPropTypes.number.def(256), +} + +export type TreeSelectProps = IxInnerPropTypes +export type TreeSelectPublicProps = IxPublicPropTypes +export interface TreeSelectBindings { + focus: (options?: FocusOptions) => void + blur: () => void + scrollTo: VirtualScrollToFn + setExpandAll: (isAll: boolean) => void +} +export type TreeSelectComponent = DefineComponent< + Omit & TreeSelectPublicProps, + TreeSelectBindings +> +export type TreeSelectInstance = InstanceType> + +export type TreeSelectNode = TreeNode + +export interface TreeSelectNodeDisabled { + select?: boolean + drag?: boolean + drop?: boolean +} + +// private +export const treeSelectorProps = { + clearable: IxPropTypes.bool, + suffix: IxPropTypes.string, +} +export type TreeSelectorProps = IxInnerPropTypes diff --git a/packages/components/tree-select/style/index.less b/packages/components/tree-select/style/index.less new file mode 100644 index 000000000..e6fd20878 --- /dev/null +++ b/packages/components/tree-select/style/index.less @@ -0,0 +1,161 @@ +@import '../../style/mixins/borderless.less'; +@import '../../style/mixins/ellipsis.less'; +@import '../../style/mixins/reset.less'; +@import './mixin.less'; +@import './single.less'; +@import './multiple.less'; + +.@{tree-select-prefix} { + position: relative; + display: inline-block; + width: 100%; + + &-selector { + position: relative; + display: flex; + color: @tree-select-color; + background-color: @tree-select-background-color; + border: @tree-select-border-width @tree-select-border-style @tree-select-border-color; + border-radius: @tree-select-border-radius; + transition: all @tree-select-transition-duration @tree-select-transition-function; + cursor: pointer; + + &-item { + flex: 1; + user-select: none; + .ellipsis(); + } + + &-input { + + &-inner { + width: 100%; + margin: 0; + padding: 0; + background: transparent; + border: none; + outline: none; + appearance: none; + cursor: pointer; + z-index: @zindex-l1-1; + } + } + + &-placeholder { + flex: 1; + overflow: hidden; + color: @tree-select-placeholder-color; + transition: all @tree-select-transition-duration @tree-select-transition-function; + .ellipsis(); + } + + &-suffix { + .tree-select-icon(); + + pointer-events: none; + } + + &-clear { + .tree-select-icon(); + + z-index: 1; + opacity: 0; + background-color: @tree-select-icon-background-color; + + &:hover { + color: @tree-select-icon-hover-color; + } + + .@{tree-select-prefix}:hover & { + opacity: 1; + } + } + } + + &:hover:not(&-disabled) &-selector { + border-color: @tree-select-hover-color; + } + + &-active:not(&-disabled):not(&-borderless) &-selector { + border-color: @tree-select-active-color; + box-shadow: @tree-select-active-box-shadow; + } + + &-disabled &-selector { + color: @tree-select-disabled-color; + background-color: @tree-select-disabled-background-color; + cursor: not-allowed; + + &-input-inner { + cursor: not-allowed; + } + } + + &-borderless &-selector { + .borderless(); + } + + &-searchable &-selector { + cursor: text; + + &-input-inner { + cursor: auto; + } + } + + &-overlay { + z-index: @tree-select-option-container-zindex; + padding: @tree-select-option-container-padding; + background-color: @tree-select-option-container-background-color; + border-radius: @tree-select-option-container-border-radius; + box-shadow: @tree-select-option-container-box-shadow; + overflow: auto; + + .@{tree-select-option-prefix}-group:not(:first-child) { + border-top: @tree-select-option-group-border; + } + + &-search-wrapper { + display: flex; + gap: 4px; + margin-bottom: 8px; + } + } +} + +.@{tree-select-option-prefix} { + .tree-select-option(@tree-select-option-font-size, @tree-select-option-color); + + &-disabled { + color: @tree-select-option-disabled-color; + cursor: not-allowed; + } + + &-active:not(&-disabled) { + background-color: @tree-select-option-active-background-color; + } + + &-selected:not(&-disabled) { + color: @tree-select-option-selected-color; + background-color: @tree-select-option-selected-background-color; + font-weight: @tree-select-option-selected-font-weight; + } + + &-checkbox { + margin-left: @tree-select-option-margin-left; + } + + &-label { + margin-left: @tree-select-option-margin-left; + .ellipsis(); + } + + &-group { + .tree-select-option(@tree-select-option-font-size - 2px, @tree-select-option-disabled-color); + + &-label { + margin-left: @tree-select-option-margin-left; + .ellipsis(); + } + } +} diff --git a/packages/components/tree-select/style/mixin.less b/packages/components/tree-select/style/mixin.less new file mode 100644 index 000000000..0a9683b3e --- /dev/null +++ b/packages/components/tree-select/style/mixin.less @@ -0,0 +1,21 @@ +.tree-select-option(@font-size; @text-color) { + display: block; + position: relative; + height: @tree-select-option-height; + padding: @tree-select-option-padding; + font-size: @font-size; + color: @text-color; + transition: @tree-select-option-transition; + cursor: pointer; +} + +.tree-select-icon() { + position: absolute; + top: 50%; + margin-top: -(@tree-select-icon-font-size / 2); + right: @tree-select-icon-margin-right; + line-height: 1; + font-size: @tree-select-icon-font-size; + color: @tree-select-icon-color; + transition: color @tree-select-transition-duration @tree-select-transition-function; +} diff --git a/packages/components/tree-select/style/multiple.less b/packages/components/tree-select/style/multiple.less new file mode 100644 index 000000000..2e85524e2 --- /dev/null +++ b/packages/components/tree-select/style/multiple.less @@ -0,0 +1,110 @@ +.select-size(@tree-select-height; @tree-select-font-size) { + @tree-select-margin-half: ceil((@tree-select-multiple-padding / 2)); + @tree-select-padding-vertical: max(@tree-select-multiple-padding - @tree-select-multiple-item-border-width - @tree-select-margin-half, 0); + @tree-select-item-height: @tree-select-height - @tree-select-multiple-padding * 2; + @tree-select-item-line-height: @tree-select-item-height - @tree-select-border-width * 2; + + .@{tree-select-prefix}-selector { + padding: @tree-select-padding-vertical @tree-select-multiple-padding; + font-size: @tree-select-font-size; + + &-item { + height: @tree-select-item-height; + line-height: @tree-select-item-line-height; + margin: @tree-select-margin-half 0; + margin-inline-end: @tree-select-multiple-padding; + } + + &-input { + margin: @tree-select-margin-half 0; + margin-inline-start: @tree-select-multiple-padding; + + &-inner, + &-mirror { + height: @tree-select-item-height; + line-height: @tree-select-item-line-height; + } + } + } + + &.@{tree-select-prefix}-with-suffix .@{tree-select-prefix}-selector, + &.@{tree-select-prefix}-clearable .@{tree-select-prefix}-selector { + padding-right: @tree-select-icon-font-size + @tree-select-multiple-padding; + } +} + +.@{tree-select-prefix}-multiple { + .@{tree-select-prefix}-selector { + flex-wrap: wrap; + align-items: center; + + &-item { + display: flex; + flex: none; + max-width: 100%; + padding: @tree-select-multiple-item-padding; + background: @tree-select-multiple-item-background-color; + border: @tree-select-multiple-item-border; + border-radius: @tree-select-multiple-item-border-radius; + cursor: default; + + &-disabled { + color: @tree-select-multiple-item-disabled-color; + border-color: @tree-select-multiple-item-disabled-border-color; + cursor: not-allowed; + } + + &-label { + display: inline-block; + .ellipsis(); + } + + &-remove { + margin-left: @tree-select-multiple-item-label-margin-left; + color: @tree-select-icon-color; + font-size: @tree-select-multiple-item-remove-icon-font-size; + line-height: inherit; + cursor: pointer; + + &:hover { + color: @tree-select-icon-hover-color; + } + } + } + + &-input { + position: relative; + max-width: 100%; + + &-mirror { + position: absolute; + top: 0; + left: 0; + white-space: pre; + visibility: hidden; + } + } + + &-placeholder { + position: absolute; + right: @tree-select-padding-horizontal-md; + left: @tree-select-padding-horizontal-md; + } + } + + &.@{tree-select-prefix}-sm { + .select-size(@tree-select-height-sm, @tree-select-font-size-sm); + + .@{tree-select-prefix}-selector-input { + margin: 0; + } + } + + &.@{tree-select-prefix}-md { + .select-size(@tree-select-height-md, @tree-select-font-size-md); + } + + &.@{tree-select-prefix}-lg { + .select-size(@tree-select-height-lg, @tree-select-font-size-lg); + } +} diff --git a/packages/components/tree-select/style/single.less b/packages/components/tree-select/style/single.less new file mode 100644 index 000000000..74d025372 --- /dev/null +++ b/packages/components/tree-select/style/single.less @@ -0,0 +1,57 @@ +.tree-select-size(@tree-select-height; @tree-select-padding-horizontal; @tree-select-font-size) { + .@{tree-select-prefix}-selector { + height: @tree-select-height; + padding: 0 @tree-select-padding-horizontal; + font-size: @tree-select-font-size; + + &-item, + &-placeholder, + &-input-inner { + line-height: @tree-select-height - 2 * @tree-select-border-width; + } + + &-input { + right: @tree-select-padding-horizontal; + left: @tree-select-padding-horizontal; + } + } + + &.@{tree-select-prefix}-show-suffix &-selector-input { + right: @tree-select-padding-horizontal + @tree-select-font-size; + } +} + +.@{tree-select-prefix}-single { + .@{tree-select-prefix}-selector { + width: 100%; + + &-input { + position: absolute; + } + + &-item { + position: relative; + } + } + + &.@{tree-select-prefix}-with-suffix .@{tree-select-prefix}-selector-item, + &.@{tree-select-prefix}-with-suffix .@{tree-select-prefix}-selector-placeholder { + padding-right: @tree-select-icon-font-size; + } + + &.@{tree-select-prefix}-opened .@{tree-select-prefix}-selector-item { + color: @tree-select-placeholder-color; + } + + &.@{tree-select-prefix}-sm { + .tree-select-size(@tree-select-height-sm, @tree-select-padding-horizontal-sm, @tree-select-font-size-sm); + } + + &.@{tree-select-prefix}-md { + .tree-select-size(@tree-select-height-md, @tree-select-padding-horizontal-md, @tree-select-font-size-md); + } + + &.@{tree-select-prefix}-lg { + .tree-select-size(@tree-select-height-lg, @tree-select-padding-horizontal-lg, @tree-select-font-size-lg); + } +} diff --git a/packages/components/tree-select/style/themes/default.less b/packages/components/tree-select/style/themes/default.less new file mode 100644 index 000000000..830f5793b --- /dev/null +++ b/packages/components/tree-select/style/themes/default.less @@ -0,0 +1,72 @@ +@import '../../../style/themes/default.less'; +@import '../../../form/style/themes/default.less'; +@import '../index.less'; + +@tree-select-font-size-sm: @form-font-size-sm; +@tree-select-font-size-md: @form-font-size-md; +@tree-select-font-size-lg: @form-font-size-lg; +@tree-select-line-height: @form-line-height; +@tree-select-height-sm: @form-height-sm; +@tree-select-height-md: @form-height-md; +@tree-select-height-lg: @form-height-lg; +@tree-select-padding-horizontal-sm: @form-padding-horizontal-sm; +@tree-select-padding-horizontal-md: @form-padding-horizontal-md; +@tree-select-padding-horizontal-lg: @form-padding-horizontal-lg; +@tree-select-padding-vertical-sm: @form-padding-vertical-sm; +@tree-select-padding-vertical-md: @form-padding-vertical-md; +@tree-select-padding-vertical-lg: @form-padding-vertical-lg; + +@tree-select-border-width: @form-border-width; +@tree-select-border-style: @form-border-style; +@tree-select-border-color: @form-border-color; +@tree-select-border-radius: @border-radius-md; + +@tree-select-color: @form-color; +@tree-select-color-secondary: @form-color-secondary; +@tree-select-background-color: @form-background-color; +@tree-select-placeholder-color: @form-placeholder-color; +@tree-select-hover-color: @form-hover-color; +@tree-select-active-color: @form-active-color; +@tree-select-active-box-shadow: @form-active-box-shadow; +@tree-select-disabled-color: @form-disabled-color; +@tree-select-disabled-background-color: @form-disabled-background-color; + +@tree-select-transition-duration: @form-transition-duration; +@tree-select-transition-function: @form-transition-function; + +@tree-select-option-font-size: @font-size-md; +@tree-select-option-height: @height-md; +@tree-select-option-margin-left: @spacing-md; +@tree-select-option-padding: @spacing-sm; +@tree-select-option-color: @text-color; +@tree-select-option-disabled-color: @text-color-disabled; +@tree-select-option-active-background-color: @color-grey-l50; +@tree-select-option-selected-color: @color-primary; +@tree-select-option-selected-background-color: tint(@color-primary, 90%); +@tree-select-option-selected-font-weight: @font-weight-xl; +@tree-select-option-transition: background @transition-duration-base @ease-in-out; + +@tree-select-option-group-border: @border-width-sm @border-style @border-color; + +@tree-select-option-container-zindex: @zindex-l4-3; +@tree-select-option-container-padding: @spacing-sm; +@tree-select-option-container-background-color: @background-color-component; +@tree-select-option-container-border-radius: @border-radius-sm; +@tree-select-option-container-box-shadow: @shadow-bottom-md; + +@tree-select-icon-font-size: @font-size-sm; +@tree-select-icon-margin-right: @spacing-xs; +@tree-select-icon-color: @tree-select-placeholder-color; +@tree-select-icon-hover-color: @tree-select-color-secondary; +@tree-select-icon-background-color: @tree-select-background-color; + +@tree-select-multiple-padding: @tree-select-padding-vertical-md; +@tree-select-multiple-item-padding: 0 @spacing-xs; +@tree-select-multiple-item-background-color: @background-color-base; +@tree-select-multiple-item-disabled-color: @tree-select-disabled-color; +@tree-select-multiple-item-disabled-border-color: @tree-select-border-color; +@tree-select-multiple-item-border-width: @border-width-sm; +@tree-select-multiple-item-border: @tree-select-multiple-item-border-width @border-style @border-color-split; +@tree-select-multiple-item-border-radius: @tree-select-border-radius; +@tree-select-multiple-item-label-margin-left: @spacing-xs; +@tree-select-multiple-item-remove-icon-font-size: @font-size-xs; diff --git a/packages/components/tree-select/style/themes/default.ts b/packages/components/tree-select/style/themes/default.ts new file mode 100644 index 000000000..b3734601b --- /dev/null +++ b/packages/components/tree-select/style/themes/default.ts @@ -0,0 +1,6 @@ +// style dependencies +import '@idux/components/style/core/default' +import '@idux/components/icon/style/themes/default' +import '@idux/components/tree/style/themes/default' + +import './default.less' diff --git a/packages/components/tree/src/Tree.tsx b/packages/components/tree/src/Tree.tsx index e7bd54b98..16d7b1963 100644 --- a/packages/components/tree/src/Tree.tsx +++ b/packages/components/tree/src/Tree.tsx @@ -126,8 +126,8 @@ export default defineComponent({ const scrollTo = (option?: number | VirtualScrollToOptions) => { virtualScrollRef?.value?.scrollTo(option) } - - expose({ focus, blur, scrollTo }) + const { setExpandAll } = expandableContext + expose({ focus, blur, scrollTo, setExpandAll }) const handleScrolledChange = (startIndex: number, endIndex: number, visibleNodes: FlattedNode[]) => { callEmit( diff --git a/packages/components/tree/src/node/Indent.tsx b/packages/components/tree/src/node/Indent.tsx index 6e5b6701a..41f45fe13 100644 --- a/packages/components/tree/src/node/Indent.tsx +++ b/packages/components/tree/src/node/Indent.tsx @@ -7,9 +7,9 @@ import type { FunctionalComponent, VNodeTypes } from 'vue' -const Indent: FunctionalComponent<{ level: number; noopIdentUnit: number; prefixCls: string }> = ({ +const Indent: FunctionalComponent<{ level: number; noopIdentUnitArr: number[]; prefixCls: string }> = ({ level, - noopIdentUnit, + noopIdentUnitArr, prefixCls, }) => { const children: VNodeTypes[] = [] @@ -17,7 +17,9 @@ const Indent: FunctionalComponent<{ level: number; noopIdentUnit: number; prefix children.push( , ) } diff --git a/packages/components/tree/src/node/TreeNode.tsx b/packages/components/tree/src/node/TreeNode.tsx index 5aacfee4e..a8df8d291 100644 --- a/packages/components/tree/src/node/TreeNode.tsx +++ b/packages/components/tree/src/node/TreeNode.tsx @@ -117,13 +117,15 @@ export default defineComponent({ const { showLine, checkable, draggable } = treeProps const mergedDraggable = draggable && !dragDisabled const currNode = nodeMap.get(key) - let noopIdentUnit = 0 + const noopIdentUnitArr: number[] = [] if (treeProps.showLine) { - getParentKeys(nodeMap, currNode).forEach(parentKey => { - if (nodeMap.get(parentKey)?.isLast) { - noopIdentUnit++ - } - }) + getParentKeys(nodeMap, currNode) + .reverse() + .forEach((parentKey, index) => { + if (nodeMap.get(parentKey)?.isLast) { + noopIdentUnitArr.push(index) + } + }) } return ( @@ -139,7 +141,7 @@ export default defineComponent({ onDragleave={mergedDraggable ? onDragleave : undefined} onDrop={mergedDraggable && !dropDisabled ? onDrop : undefined} > - + {isLeaf && showLine ? ( ) : ( diff --git a/packages/components/tree/src/types.ts b/packages/components/tree/src/types.ts index db6cb8015..d38f7603d 100644 --- a/packages/components/tree/src/types.ts +++ b/packages/components/tree/src/types.ts @@ -81,6 +81,7 @@ export interface TreeBindings { focus: (options?: FocusOptions) => void blur: () => void scrollTo: VirtualScrollToFn + setExpandAll: (isAll: boolean) => void } export type TreeComponent = DefineComponent & TreePublicProps, TreeBindings> export type TreeInstance = InstanceType> diff --git a/scripts/gulp/icons/assets/tree-expand.svg b/scripts/gulp/icons/assets/tree-expand.svg new file mode 100644 index 000000000..a2ca476a1 --- /dev/null +++ b/scripts/gulp/icons/assets/tree-expand.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/scripts/gulp/icons/assets/tree-unexpand.svg b/scripts/gulp/icons/assets/tree-unexpand.svg new file mode 100644 index 000000000..f39cccced --- /dev/null +++ b/scripts/gulp/icons/assets/tree-unexpand.svg @@ -0,0 +1 @@ + \ No newline at end of file