diff --git a/docs/.vitepress/crowdin/en-US/pages/component.json b/docs/.vitepress/crowdin/en-US/pages/component.json index 80079dfa80..79d31e70ff 100644 --- a/docs/.vitepress/crowdin/en-US/pages/component.json +++ b/docs/.vitepress/crowdin/en-US/pages/component.json @@ -206,6 +206,11 @@ "link": "/tree", "text": "Tree" }, + { + "link": "/tree-select", + "text": "TreeSelect", + "promotion": "2.1.8" + }, { "link": "/tree-v2", "text": "Virtualized Tree" diff --git a/docs/en-US/component/tree-select.md b/docs/en-US/component/tree-select.md new file mode 100644 index 0000000000..09d614c2a0 --- /dev/null +++ b/docs/en-US/component/tree-select.md @@ -0,0 +1,93 @@ +--- +title: TreeSelect +lang: en-US +--- + +# TreeSelect + +The tree selector of the dropdown menu, +it combines the functions of components `el-tree` and `el-select`. + +## Basic usage + +Selector for tree structures. + +:::demo + +tree-select/basic + +::: + +## Select any level + +When using the `check-strictly=true` attribute, any node can be checked, +otherwise only leaf nodes are supported. + +:::demo + +tree-select/check-strictly + +::: + +## Multiple Selection + +Multiple selection using clicks or checkbox. + +:::demo + +tree-select/multiple + +::: + +## Disabled Selection + +Disable options using the disabled field. + +:::demo + +tree-select/disabled + +::: + +## Filterable + +Use keyword filtering or custom filtering methods. +`filterMethod` can custom filter method for data, +`filterNodeMethod` can custom filter method for data node. + +:::demo + +tree-select/filterable + +::: + +## Custom content + +Contents of custom tree nodes. + +:::demo + +tree-select/slots + +::: + +## LazyLoad + +Lazy loading of tree nodes, suitable for large data lists. + +:::demo + +tree-select/lazy + +::: + +## Attributes + +Since this component combines the functions of components `el-tree` and `el-select`, +the original properties have not been changed, so no repetition here, +and please go to the original component to view the documentation. + +| Attributes | Methods | Events | Slots | +| --------------------------------------- | ----------------------------- | ----------------------------------- | ---------------------------------- | +| [tree](./tree.md#attributes) | [tree](./tree.md#method) | [tree](./tree.md#events) | [tree](./tree.md#slots) | +| [select](./select.md#select-attributes) | [select](./select.md#methods) | [select](./select.md#select-events) | [select](./select.md#select-slots) | diff --git a/docs/examples/tree-select/basic.vue b/docs/examples/tree-select/basic.vue new file mode 100644 index 0000000000..71798c3101 --- /dev/null +++ b/docs/examples/tree-select/basic.vue @@ -0,0 +1,81 @@ + + + +``` diff --git a/docs/examples/tree-select/check-strictly.vue b/docs/examples/tree-select/check-strictly.vue new file mode 100644 index 0000000000..f329f59517 --- /dev/null +++ b/docs/examples/tree-select/check-strictly.vue @@ -0,0 +1,81 @@ + + + +``` diff --git a/docs/examples/tree-select/disabled.vue b/docs/examples/tree-select/disabled.vue new file mode 100644 index 0000000000..c182ed591e --- /dev/null +++ b/docs/examples/tree-select/disabled.vue @@ -0,0 +1,84 @@ + + + +``` diff --git a/docs/examples/tree-select/filterable.vue b/docs/examples/tree-select/filterable.vue new file mode 100644 index 0000000000..5ce3745e80 --- /dev/null +++ b/docs/examples/tree-select/filterable.vue @@ -0,0 +1,104 @@ + + + +``` diff --git a/docs/examples/tree-select/lazy.vue b/docs/examples/tree-select/lazy.vue new file mode 100644 index 0000000000..2c94795601 --- /dev/null +++ b/docs/examples/tree-select/lazy.vue @@ -0,0 +1,36 @@ + + + +``` diff --git a/docs/examples/tree-select/multiple.vue b/docs/examples/tree-select/multiple.vue new file mode 100644 index 0000000000..526918afc5 --- /dev/null +++ b/docs/examples/tree-select/multiple.vue @@ -0,0 +1,84 @@ + + + +``` diff --git a/docs/examples/tree-select/slots.vue b/docs/examples/tree-select/slots.vue new file mode 100644 index 0000000000..a78c58b46f --- /dev/null +++ b/docs/examples/tree-select/slots.vue @@ -0,0 +1,104 @@ + + + +``` diff --git a/packages/components/index.ts b/packages/components/index.ts index 5f204b82fb..345806f075 100644 --- a/packages/components/index.ts +++ b/packages/components/index.ts @@ -61,6 +61,7 @@ export * from './timeline' export * from './tooltip' export * from './transfer' export * from './tree' +export * from './tree-select' export * from './tree-v2' export * from './upload' export * from './virtual-list' diff --git a/packages/components/tree-select/__tests__/tree-select.spec.ts b/packages/components/tree-select/__tests__/tree-select.spec.ts new file mode 100644 index 0000000000..5f92ef8770 --- /dev/null +++ b/packages/components/tree-select/__tests__/tree-select.spec.ts @@ -0,0 +1,312 @@ +import { h, nextTick, ref } from 'vue' +import { mount } from '@vue/test-utils' +import TreeSelect from '../src/tree-select.vue' +import type { RenderFunction } from 'vue' +import type { VueWrapper } from '@vue/test-utils' +import type ElSelect from '@element-plus/components/select' +import type ElTree from '@element-plus/components/tree' + +const createComponent = ({ + slots = {}, + props = {}, +}: { + slots?: Record + props?: typeof TreeSelect['props'] +} = {}) => { + // vm can not get component expose, use ref + const wrapperRef = ref() + const value = props.modelValue || ref('') + const wrapper = mount({ + data() { + return { + modelValue: value, + data: [ + { + value: 1, + label: '一级 1', + children: [ + { + value: 11, + label: '二级 1-1', + children: [ + { + value: 111, + label: '三级 1-1', + }, + ], + }, + ], + }, + ], + 'onUpdate:modelValue': (val: string) => (value.value = val), + ...props, + } + }, + render() { + return h( + TreeSelect, + { + ...this.$data, + ref: (val: object) => (wrapperRef.value = val), + }, + slots + ) + }, + }) + + return { + wrapper, + getWrapperRef: () => + new Promise((resolve) => + nextTick(() => resolve(wrapperRef.value)) + ) as Promise & InstanceType>, + select: wrapper.findComponent({ name: 'ElSelect' }) as VueWrapper< + InstanceType + >, + tree: wrapper.findComponent({ name: 'ElTree' }) as VueWrapper< + InstanceType + >, + } +} + +describe('TreeSelect.vue', () => { + test('render test', async () => { + const { wrapper, tree } = createComponent({ + props: { + defaultExpandAll: true, + }, + }) + + expect(wrapper.find('.el-tree')).toBeTruthy() + expect(wrapper.find('.el-select')).toBeTruthy() + + expect(tree.findAll('.el-tree > .el-tree-node').length).toBe(1) + expect(tree.findAll('.el-tree .el-tree-node').length).toBe(3) + expect(tree.findAll('.el-tree .el-select-dropdown__item').length).toBe(3) + + wrapper.vm.data[0].children = [] + + await nextTick() + + expect(tree.findAll('.el-tree .el-tree-node').length).toBe(1) + }) + + test('modelValue', async () => { + const value = ref(1) + const { getWrapperRef, select, tree } = createComponent({ + props: { + modelValue: value, + checkStrictly: true, + showCheckbox: true, + }, + }) + + const wrapperRef = await getWrapperRef() + + await nextTick() + expect(select.vm.modelValue).toBe(1) + expect(wrapperRef.getCheckedKeys()).toEqual([1]) + + value.value = 11 + await nextTick(nextTick) + expect(select.vm.modelValue).toBe(11) + expect(wrapperRef.getCheckedKeys()).toEqual([11]) + + await tree + .findAll('.el-select-dropdown__item') + .slice(-1)[0] + .trigger('click') + await nextTick() + expect(select.vm.modelValue).toBe(111) + expect(wrapperRef.getCheckedKeys()).toEqual([111]) + + await tree.find('.el-tree-node__content').trigger('click') + await nextTick() + expect(select.vm.modelValue).toBe(1) + expect(wrapperRef.getCheckedKeys()).toEqual([1]) + + await tree.findAll('.el-checkbox')[1].trigger('click') + await nextTick() + expect(select.vm.modelValue).toBe(11) + expect(wrapperRef.getCheckedKeys()).toEqual([11]) + }) + + test('disabled', async () => { + const { wrapper, tree } = createComponent({ + props: { + data: [ + { + value: '1', + label: '1', + children: [ + { + value: '2', + label: '2', + disabled: true, + }, + ], + }, + ], + showCheckbox: true, + checkStrictly: true, + defaultExpandAll: true, + }, + }) + + await nextTick() + await tree.find('.el-tree-node').trigger('click') + await tree.find('.el-tree-node .el-checkbox.is-disabled').trigger('click') + await tree + .find('.el-tree-node .el-select-dropdown__item.is-disabled') + .trigger('click') + await nextTick() + expect(wrapper.vm.modelValue).toBe('1') + }) + + test('multiple', async () => { + const value = ref([1]) + const { getWrapperRef, select, tree } = createComponent({ + props: { + modelValue: value, + checkStrictly: true, + showCheckbox: true, + multiple: true, + }, + }) + + const wrapperRef = await getWrapperRef() + + await nextTick() + expect(select.vm.modelValue).toEqual([1]) + expect(wrapperRef.getCheckedKeys()).toEqual([1]) + + value.value = [11] + await nextTick(nextTick) + expect(select.vm.modelValue).toEqual([11]) + expect(wrapperRef.getCheckedKeys()).toEqual([11]) + + await tree + .findAll('.el-select-dropdown__item') + .slice(-1)[0] + .trigger('click') + await nextTick() + expect(select.vm.modelValue).toEqual([11, 111]) + expect(wrapperRef.getCheckedKeys()).toEqual([11, 111]) + + await tree.find('.el-tree-node__content').trigger('click') + await nextTick() + expect(select.vm.modelValue).toEqual([11, 111, 1]) + expect(wrapperRef.getCheckedKeys()).toEqual([1, 11, 111]) + + await tree.findAll('.el-checkbox')[1].trigger('click') + await nextTick() + expect(select.vm.modelValue).toEqual([1, 111]) + expect(wrapperRef.getCheckedKeys()).toEqual([1, 111]) + }) + + test('filter', async () => { + const { select, tree } = createComponent({ + props: { + filterable: true, + }, + }) + + select.vm.query = '一级 1' + await nextTick() + expect(tree.findAll('.el-tree-node').length).toBe(1) + }) + + test('props', async () => { + const { wrapper, select, tree } = createComponent({ + props: { + data: [ + { + id: '1', + name: '1', + childrens: [ + { + id: '2', + name: '2', + }, + ], + }, + ], + props: { + label: 'name', + children: 'childrens', + }, + valueKey: 'id', + }, + }) + + await nextTick() + expect(tree.find('.el-tree-node').text()).toBe('1') + wrapper.vm.modelValue = '2' + await nextTick() + expect(select.vm.selectedLabel).toBe('2') + }) + + test('slots', async () => { + const { select, tree } = createComponent({ + slots: { + default: ({ data }: { data: { label: string } }) => `123${data.label}`, + prefix: () => 'prefix', + }, + }) + + await nextTick() + expect(tree.find('.el-select-dropdown__item').text()).toBe('123一级 1') + expect(select.find('.el-input__prefix-inner').text()).toBe('prefix') + }) + + test('renderContent', async () => { + const { tree } = createComponent({ + props: { + renderContent: ( + h: RenderFunction, + { data }: { data: { label: string } } + ) => { + return `123${data.label}` + }, + }, + }) + + await nextTick() + expect(tree.find('.el-select-dropdown__item').text()).toBe('123一级 1') + }) + + test('lazy', async () => { + const { tree } = createComponent({ + props: { + data: [ + { + value: 1, + label: 1, + }, + ], + lazy: true, + load: (node: object, resolve: (p: any) => any[]) => { + resolve([{ value: 2, label: 2, isLeaf: true }]) + }, + }, + }) + + await nextTick() + await tree.find('.el-tree-node').trigger('click') + await nextTick() + expect(tree.find('.el-tree-node .el-tree-node').text()).toBe('2') + }) + + test('events', async () => { + const onNodeClick = jest.fn() + const { tree } = createComponent({ + props: { + onNodeClick, + }, + }) + await nextTick() + await tree.find('.el-tree-node').trigger('click') + await nextTick() + expect(onNodeClick).toBeCalled() + }) +}) diff --git a/packages/components/tree-select/index.ts b/packages/components/tree-select/index.ts new file mode 100644 index 0000000000..38dccfe16c --- /dev/null +++ b/packages/components/tree-select/index.ts @@ -0,0 +1,13 @@ +import TreeSelect from './src/tree-select.vue' + +import type { App } from 'vue' +import type { SFCWithInstall } from '@element-plus/utils' + +TreeSelect.install = (app: App): void => { + app.component(TreeSelect.name, TreeSelect) +} + +const _TreeSelect = TreeSelect as SFCWithInstall + +export default _TreeSelect +export const ElTreeSelect = _TreeSelect diff --git a/packages/components/tree-select/src/select.ts b/packages/components/tree-select/src/select.ts new file mode 100644 index 0000000000..f8a7beaf9b --- /dev/null +++ b/packages/components/tree-select/src/select.ts @@ -0,0 +1,50 @@ +import { computed, nextTick, toRefs } from 'vue' +import { pick } from 'lodash-unified' +import ElSelect from '@element-plus/components/select' +import { useNamespace } from '@element-plus/hooks' +import type { Ref } from 'vue' +import type ElTree from '@element-plus/components/tree' + +export const useSelect = ( + props, + { attrs }, + { + tree, + key, + }: { + select: Ref | undefined> + tree: Ref | undefined> + key: Ref + } +) => { + const ns = useNamespace('tree-select') + + const result = { + ...pick(toRefs(props), Object.keys(ElSelect.props)), + ...attrs, + valueKey: key, + popperClass: computed(() => { + const classes = [ns.e('popper')] + if (props.popperClass) classes.push(props.popperClass) + return classes.join(' ') + }), + filterMethod: (keyword = '') => { + if (props.filterMethod) props.filterMethod(keyword) + + nextTick(() => { + // let tree node expand only, same with tree filter + tree.value?.filter(keyword) + }) + }, + // clear filter text when visible change + onVisibleChange: (visible: boolean) => { + attrs.onVisibleChange?.(visible) + + if (props.filterable && visible) { + result.filterMethod() + } + }, + } + + return result +} diff --git a/packages/components/tree-select/src/tree-select-option.ts b/packages/components/tree-select/src/tree-select-option.ts new file mode 100644 index 0000000000..93e19889df --- /dev/null +++ b/packages/components/tree-select/src/tree-select-option.ts @@ -0,0 +1,22 @@ +import { defineComponent } from 'vue' +import { ElOption } from '@element-plus/components/select' + +const component = defineComponent({ + extends: ElOption, + setup(props, ctx) { + const result = (ElOption.setup as NonNullable)(props, ctx) + + // use methods.selectOptionClick + delete result.selectOptionClick + + return result + }, + methods: { + selectOptionClick() { + // $el.parentElement => el-tree-node__content + this.$el.parentElement.click() + }, + }, +}) + +export default component diff --git a/packages/components/tree-select/src/tree-select.vue b/packages/components/tree-select/src/tree-select.vue new file mode 100644 index 0000000000..5eabf2829e --- /dev/null +++ b/packages/components/tree-select/src/tree-select.vue @@ -0,0 +1,83 @@ + diff --git a/packages/components/tree-select/src/tree.ts b/packages/components/tree-select/src/tree.ts new file mode 100644 index 0000000000..305bab0ec7 --- /dev/null +++ b/packages/components/tree-select/src/tree.ts @@ -0,0 +1,136 @@ +import { computed, nextTick, toRefs, watch } from 'vue' +import { isEqual, pick } from 'lodash-unified' +import { UPDATE_MODEL_EVENT } from '@element-plus/constants' +import { isFunction } from '@element-plus/utils' +import ElTree from '@element-plus/components/tree' +import TreeSelectOption from './tree-select-option' +import type { Ref } from 'vue' +import type ElSelect from '@element-plus/components/select' +import type Node from '@element-plus/components/tree/src/model/node' +import type { TreeNodeData } from '@element-plus/components/tree/src/tree.type' + +export const useTree = ( + props, + { attrs, slots, emit }, + { + select, + tree, + key, + }: { + select: Ref | undefined> + tree: Ref | undefined> + key: Ref + } +) => { + watch( + () => props.modelValue, + () => { + if (props.showCheckbox) { + nextTick(() => { + const treeInstance = tree.value + if ( + treeInstance && + !isEqual( + treeInstance.getCheckedKeys(), + toValidArray(props.modelValue) + ) + ) { + treeInstance.setCheckedKeys(toValidArray(props.modelValue)) + } + }) + } + }, + { + immediate: true, + deep: true, + } + ) + + const propsMap = computed(() => ({ + value: key.value, + ...props.props, + })) + + const getNodeValByProp = ( + prop: 'value' | 'label' | 'children' | 'disabled' | 'isLeaf', + data: TreeNodeData + ) => { + const propVal = propsMap.value[prop] + if (isFunction(propVal)) { + return propVal( + data, + tree.value?.getNode(getNodeValByProp('value', data)) as Node + ) + } else { + return data[propVal as string] + } + } + + return { + ...pick(toRefs(props), Object.keys(ElTree.props)), + ...attrs, + nodeKey: key, + defaultExpandedKeys: computed(() => + props.defaultExpandedKeys + ? props.defaultExpandedKeys.concat(props.modelValue) + : toValidArray(props.modelValue) + ), + renderContent: (h, { node, data, store }) => { + return h( + TreeSelectOption, + { + value: getNodeValByProp('value', data), + label: getNodeValByProp('label', data), + disabled: getNodeValByProp('disabled', data), + }, + props.renderContent + ? () => props.renderContent(h, { node, data, store }) + : slots.default + ? () => slots.default({ node, data, store }) + : undefined + ) + }, + filterNodeMethod: (value, data, node) => { + if (props.filterNodeMethod) + return props.filterNodeMethod(value, data, node) + if (!value) return true + return getNodeValByProp('label', data)?.includes(value) + }, + onNodeClick: (data, node, e) => { + attrs.onNodeClick?.(data, node, e) + + if (props.checkStrictly || node.isLeaf) { + if (!getNodeValByProp('disabled', data)) { + const option = select.value?.options.get( + getNodeValByProp('value', data) + ) + select.value?.handleOptionSelect(option, true) + } + } else { + e.ctx.handleExpandIconClick() + } + }, + onCheck: (data, params) => { + attrs.onCheck?.(data, params) + + // remove folder node when `checkStrictly` is false + const checkedKeys = !props.checkStrictly + ? tree.value?.getCheckedKeys(true) + : params.checkedKeys + + const value = getNodeValByProp('value', data) + emit( + UPDATE_MODEL_EVENT, + props.multiple + ? checkedKeys + : checkedKeys.includes(value) + ? value + : undefined + ) + }, + } +} + +function toValidArray(val: any) { + return Array.isArray(val) ? val : val || val === 0 ? [val] : [] +} diff --git a/packages/components/tree-select/style/css.ts b/packages/components/tree-select/style/css.ts new file mode 100644 index 0000000000..c652f5a0cc --- /dev/null +++ b/packages/components/tree-select/style/css.ts @@ -0,0 +1,3 @@ +import '@element-plus/components/select/style/css' +import '@element-plus/components/tree/style/css' +import '@element-plus/theme-chalk/src/tree-select/css' diff --git a/packages/components/tree-select/style/index.ts b/packages/components/tree-select/style/index.ts new file mode 100644 index 0000000000..a369976bc9 --- /dev/null +++ b/packages/components/tree-select/style/index.ts @@ -0,0 +1,3 @@ +import '@element-plus/components/select/style' +import '@element-plus/components/tree/style' +import '@element-plus/theme-chalk/src/tree-select.scss' diff --git a/packages/element-plus/component.ts b/packages/element-plus/component.ts index 372c72edc9..99dac40870 100644 --- a/packages/element-plus/component.ts +++ b/packages/element-plus/component.ts @@ -94,6 +94,7 @@ import { ElTooltip } from '@element-plus/components/tooltip' import { ElTooltipV2 } from '@element-plus/components/tooltip-v2' import { ElTransfer } from '@element-plus/components/transfer' import { ElTree } from '@element-plus/components/tree' +import { ElTreeSelect } from '@element-plus/components/tree-select' import { ElTreeV2 } from '@element-plus/components/tree-v2' import { ElUpload } from '@element-plus/components/upload' import type { Plugin } from 'vue' @@ -188,6 +189,7 @@ export default [ ElTooltipV2, ElTransfer, ElTree, + ElTreeSelect, ElTreeV2, ElUpload, ] as Plugin[] diff --git a/packages/theme-chalk/src/index.scss b/packages/theme-chalk/src/index.scss index 1e705e1a4a..933edc01c0 100644 --- a/packages/theme-chalk/src/index.scss +++ b/packages/theme-chalk/src/index.scss @@ -92,6 +92,7 @@ @use './tooltip-v2.scss'; @use './transfer.scss'; @use './tree.scss'; +@use './tree-select.scss'; @use './upload.scss'; @use './virtual-list.scss'; @use './popper.scss'; diff --git a/packages/theme-chalk/src/tree-select.scss b/packages/theme-chalk/src/tree-select.scss new file mode 100644 index 0000000000..59b67b2d78 --- /dev/null +++ b/packages/theme-chalk/src/tree-select.scss @@ -0,0 +1,36 @@ +@use 'mixins/mixins' as *; +@use 'mixins/var' as *; +@use 'common/var' as *; + +@include b(tree-select) { + @include set-component-css-var('tree', $tree); +} + +@include b(tree-select) { + @include e(popper) { + // padding-left same with select option + .#{$namespace}-tree-node__expand-icon { + margin-left: 8px; + } + + // remove icon when show checkbox + .#{$namespace}-tree-node.is-checked + > .#{$namespace}-tree-node__content + .#{$namespace}-select-dropdown__item.selected::after { + content: none; + } + + .#{$namespace}-select-dropdown__item { + flex: 1; + background: transparent !important; + + // padding-left move to `el-tree-node__expand-icon` + padding-left: 0; + + // fix: select height > tree node height + // https://github.com/yujinpan/el-select-tree/pull/33 + height: 20px; + line-height: 20px; + } + } +}