From 52ea21489621293b4cfcc6a91a1cdea2eb029d8d Mon Sep 17 00:00:00 2001 From: Simona <36978416+SimonaliaChen@users.noreply.github.com> Date: Fri, 30 Oct 2020 23:26:33 +0800 Subject: [PATCH] feat(cascader): refactor done (#435) --- packages/__mocks__/lodash.js | 10 - packages/__mocks__/lodash/debounce.js | 5 + packages/__mocks__/lodash/index.js | 6 + packages/__mocks__/lodash/throttle.js | 5 + .../__tests__/autocomplete.spec.ts | 3 + .../__tests__/cascader-panel.spec.ts | 556 +++++++++++++++++ packages/cascader-panel/index.ts | 8 + packages/cascader-panel/package.json | 12 + packages/cascader-panel/src/config.ts | 43 ++ packages/cascader-panel/src/index.vue | 334 ++++++++++ packages/cascader-panel/src/menu.vue | 134 ++++ packages/cascader-panel/src/node.ts | 168 +++++ packages/cascader-panel/src/node.vue | 182 ++++++ packages/cascader-panel/src/store.ts | 76 +++ packages/cascader-panel/src/types.ts | 64 ++ packages/cascader-panel/src/utils.ts | 55 ++ packages/cascader/__tests__/cascader.spec.ts | 317 ++++++++++ packages/cascader/index.ts | 5 + packages/cascader/package.json | 12 + packages/cascader/src/index.vue | 586 ++++++++++++++++++ packages/element-plus/index.ts | 6 + packages/form/src/token.ts | 4 + packages/infinite-scroll/src/index.ts | 2 +- packages/theme-chalk/src/cascader.scss | 33 +- packages/utils/dom.ts | 1 + packages/utils/resize-event.ts | 2 +- packages/utils/util.ts | 23 +- packages/utils/validators.ts | 3 + types/cascader-panel.d.ts | 9 +- types/cascader.d.ts | 2 +- typings/vue-shim.d.ts | 2 + website/docs/en-US/cascader.md | 6 +- website/docs/es/cascader.md | 6 +- website/docs/fr-FR/cascader.md | 6 +- website/docs/zh-CN/cascader.md | 7 +- 35 files changed, 2661 insertions(+), 32 deletions(-) delete mode 100644 packages/__mocks__/lodash.js create mode 100644 packages/__mocks__/lodash/debounce.js create mode 100644 packages/__mocks__/lodash/index.js create mode 100644 packages/__mocks__/lodash/throttle.js create mode 100644 packages/cascader-panel/__tests__/cascader-panel.spec.ts create mode 100644 packages/cascader-panel/index.ts create mode 100644 packages/cascader-panel/package.json create mode 100644 packages/cascader-panel/src/config.ts create mode 100644 packages/cascader-panel/src/index.vue create mode 100644 packages/cascader-panel/src/menu.vue create mode 100644 packages/cascader-panel/src/node.ts create mode 100644 packages/cascader-panel/src/node.vue create mode 100644 packages/cascader-panel/src/store.ts create mode 100644 packages/cascader-panel/src/types.ts create mode 100644 packages/cascader-panel/src/utils.ts create mode 100644 packages/cascader/__tests__/cascader.spec.ts create mode 100644 packages/cascader/index.ts create mode 100644 packages/cascader/package.json create mode 100644 packages/cascader/src/index.vue diff --git a/packages/__mocks__/lodash.js b/packages/__mocks__/lodash.js deleted file mode 100644 index 967ea8df09ed7..0000000000000 --- a/packages/__mocks__/lodash.js +++ /dev/null @@ -1,10 +0,0 @@ - -const _ = jest.requireActual('lodash') - -_.debounce = _.throttle = jest.fn(fn => { - fn.cancel = jest.fn() - fn.flush = jest.fn() - return fn -}) - -module.exports = _ diff --git a/packages/__mocks__/lodash/debounce.js b/packages/__mocks__/lodash/debounce.js new file mode 100644 index 0000000000000..160a8ba945673 --- /dev/null +++ b/packages/__mocks__/lodash/debounce.js @@ -0,0 +1,5 @@ +module.exports = jest.fn(fn => { + fn.cancel = jest.fn() + fn.flush = jest.fn() + return fn +}) diff --git a/packages/__mocks__/lodash/index.js b/packages/__mocks__/lodash/index.js new file mode 100644 index 0000000000000..07774998e9c43 --- /dev/null +++ b/packages/__mocks__/lodash/index.js @@ -0,0 +1,6 @@ +const _ = jest.requireActual('lodash') + +_.debounce = require('./debounce') +_.throttle = require('./throttle') + +module.exports = _ diff --git a/packages/__mocks__/lodash/throttle.js b/packages/__mocks__/lodash/throttle.js new file mode 100644 index 0000000000000..160a8ba945673 --- /dev/null +++ b/packages/__mocks__/lodash/throttle.js @@ -0,0 +1,5 @@ +module.exports = jest.fn(fn => { + fn.cancel = jest.fn() + fn.flush = jest.fn() + return fn +}) diff --git a/packages/autocomplete/__tests__/autocomplete.spec.ts b/packages/autocomplete/__tests__/autocomplete.spec.ts index eb736e9f0ec5b..8f49f4cec28f9 100644 --- a/packages/autocomplete/__tests__/autocomplete.spec.ts +++ b/packages/autocomplete/__tests__/autocomplete.spec.ts @@ -1,6 +1,9 @@ import { mount } from '@vue/test-utils' import { sleep } from '@element-plus/test-utils' import { NOOP } from '@vue/shared' + +jest.unmock('lodash/debounce') + import Autocomplete from '../src/index.vue' const _mount = (payload = {}) => mount({ diff --git a/packages/cascader-panel/__tests__/cascader-panel.spec.ts b/packages/cascader-panel/__tests__/cascader-panel.spec.ts new file mode 100644 index 0000000000000..2671f0bd57f40 --- /dev/null +++ b/packages/cascader-panel/__tests__/cascader-panel.spec.ts @@ -0,0 +1,556 @@ +import { nextTick } from 'vue' +import { mount } from '@vue/test-utils' +import CascaderPanel from '../src/index.vue' + +const NORMAL_OPTIONS = [ + { + value: 'beijing', + label: 'Beijing', + children: [], + leaf: true, + }, + { + value: 'zhejiang', + label: 'Zhejiang', + children: [ + { + value: 'hangzhou', + label: 'Hangzhou', + }, + { + value: 'ningbo', + label: 'Ningbo', + }, + ], + }, + { + value: 'shanghai', + label: 'Shanghai', + children: [ + // for test nodes in different levels have same value + { + value: 'shanghai', + label: 'Shanghai', + }, + ], + }, +] + +const DISABLED_OPTIONS = [ + { + value: 'beijing', + label: 'beijing', + disabled: true, + }, + { + value: 'zhejiang', + label: 'Zhejiang', + children: [ + { + value: 'hangzhou', + label: 'Hangzhou', + }, + ], + }, + { + value: 'jiangsu', + label: 'Jiangsu', + disabled: true, + children: [ + { + value: 'nanjing', + label: 'Nanjing', + }, + ], + }, +] + +const CUSTOM_PROPS_OPTIONS = [ + { + id: 'beijing', + name: 'Beijing', + areas: [], + }, + { + id: 'zhejiang', + name: 'Zhejiang', + areas: [ + { + id: 'hangzhou', + name: 'Hangzhou', + }, + { + id: 'ningbo', + name: 'Ningbo', + invalid: true, + }, + ], + }, +] + +const MENU = '.el-cascader-menu' +const NODE = '.el-cascader-node' +const VALID_NODE = '.el-cascader-node:not(.is-disabled)' +const EXPAND_ARROW = '.el-icon-arrow-right.el-cascader-node__postfix' +const CHECKBOX = '.el-checkbox__input' +const RADIO = '.el-radio__input' + +let id = 0 + +const _mount: typeof mount = options => mount({ + components: { + CascaderPanel, + }, + ...options, +}) + +const lazyLoad = (node, resolve) => { + const { level } = node + setTimeout(() => { + const nodes = Array.from({ length: level + 1 }) + .map(() => ({ + value: ++id, + label: `option${id}`, + leaf: level >= 1, + })) + resolve(nodes) + }, 1000) +} + +beforeEach(() => { + id = 0 +}) + +jest.useFakeTimers() + +describe('CascaderPanel.vue', () => { + test('expand and check', async () => { + const handleChange = jest.fn() + const handleExpandChange = jest.fn() + const wrapper = _mount({ + template: ` + + `, + data() { + return { + options: NORMAL_OPTIONS, + value: [], + } + }, + methods: { + handleChange, + handleExpandChange, + }, + }) + + const options = wrapper.findAll(NODE) + const [bjNode, zjNode] = options + + expect(wrapper.findAll(MENU).length).toBe(1) + expect(options.length).toBe(3) + expect(bjNode.text()).toBe('Beijing') + expect(bjNode.find(EXPAND_ARROW).exists()).toBe(false) + + await zjNode.trigger('click') + const menus = wrapper.findAll(MENU) + const hzNode = menus[1].find(NODE) + expect(menus.length).toBe(2) + expect(handleExpandChange).toBeCalledTimes(1) + + await hzNode.trigger('click') + // won't trigger when expanding node not change + expect(handleExpandChange).toBeCalledTimes(1) + expect(handleChange).toBeCalledTimes(1) + expect(wrapper.vm.value).toEqual(['zhejiang', 'hangzhou']) + + await bjNode.trigger('click') + expect(wrapper.findAll(MENU).length).toBe(1) + expect(handleExpandChange).toBeCalledTimes(2) + expect(handleChange).toBeCalledTimes(2) + expect(wrapper.vm.value).toEqual(['beijing']) + }) + + test('with default value', async () => { + const wrapper = mount(CascaderPanel, { + props: { + modelValue: ['zhejiang', 'hangzhou'], + options: NORMAL_OPTIONS, + }, + }) + + await nextTick() + + const menus = wrapper.findAll(MENU) + const [, zjNode, shNode] = menus[0].findAll(NODE) + const hzNode = menus[1].find(NODE) + + expect(menus.length).toBe(2) + expect(zjNode.classes('in-active-path')).toBe(true) + expect(hzNode.classes('is-active')).toBe(true) + expect(hzNode.find('.el-icon-check').exists()).toBe(true) + + await wrapper.setProps({ modelValue: ['beijing'] }) + + expect(wrapper.findAll(MENU).length).toBe(1) + expect(wrapper.find(NODE).classes('is-active')).toBe(true) + + // leaf node should be checked + await wrapper.setProps({ modelValue: ['shanghai', 'shanghai'] }) + const secondMenu = wrapper.findAll(MENU)[1] + expect(shNode.classes('is-active')).toBe(false) + expect(secondMenu.find(NODE).classes('is-active')).toBe(true) + + // leaf node should be checked + await wrapper.setProps({ modelValue: null }) + expect(wrapper.find('is-active').exists()).toBe(false) + }) + + test('disabled options', async () => { + const handleChange = jest.fn() + const handleExpandChange = jest.fn() + + const wrapper = _mount({ + template: ` + + `, + data() { + return { + options: DISABLED_OPTIONS, + value: [], + } + }, + methods: { + handleChange, + handleExpandChange, + }, + }) + + const [bjNode, zjNode, jsNode] = wrapper.findAll(NODE) + + expect(wrapper.findAll(VALID_NODE).length).toBe(1) + + await bjNode.trigger('click') + expect(handleChange).not.toBeCalled() + expect(handleExpandChange).not.toBeCalled() + + await jsNode.trigger('click') + expect(handleExpandChange).not.toBeCalled() + + await zjNode.trigger('click') + expect(wrapper.findAll(MENU).length).toBe(2) + expect(handleExpandChange).toBeCalledTimes(1) + }) + + test('options change', async () => { + const wrapper = mount(CascaderPanel, { + props: { + options: NORMAL_OPTIONS, + }, + }) + expect(wrapper.find(NODE).exists()).toBe(true) + await wrapper.setProps({ options: null }) + expect(wrapper.find(NODE).exists()).toBe(false) + }) + + test('expand by hover', async () => { + const wrapper = mount(CascaderPanel, { + props: { + options: DISABLED_OPTIONS, + props: { + expandTrigger: 'hover', + }, + }, + }) + + const [zjNode, jsNode] = wrapper.findAll(NODE).slice(1) + + await jsNode.trigger('mouseenter') + expect(wrapper.findAll(MENU).length).toBe(1) + + await zjNode.trigger('mouseenter') + expect(wrapper.findAll(MENU).length).toBe(2) + }) + + test('emit value only', async () => { + const wrapper = _mount({ + template: ` + + `, + data() { + return { + options: NORMAL_OPTIONS, + props: { emitPath: false }, + value: 'shanghai', + } + }, + }) + + await nextTick() + + const shNode = wrapper.findAll(MENU)[1].find(NODE) + expect(shNode.classes('is-active')).toBe(true) + + await wrapper.find(NODE).trigger('click') + expect(wrapper.vm.value).toBe('beijing') + }) + + test('multiple mode', async () => { + const wrapper = _mount({ + template: ` + + `, + data() { + return { + options: NORMAL_OPTIONS, + props: { multiple: true }, + value: [], + } + }, + }) + + const zjNode = wrapper.findAll(NODE)[1] + const zjCheckbox = zjNode.find(CHECKBOX) + expect(zjCheckbox.exists()).toBe(true) + + await zjNode.trigger('click') + const secondMenu = wrapper.findAll(MENU)[1] + const [hzCheckbox, nbCheckbox] = secondMenu.findAll(CHECKBOX) + + await hzCheckbox.find('input').trigger('click') + expect(hzCheckbox.classes('is-checked')).toBe(true) + expect(zjCheckbox.classes('is-indeterminate')).toBe(true) + expect(wrapper.vm.value).toEqual([['zhejiang', 'hangzhou']]) + + await nbCheckbox.find('input').trigger('click') + expect(zjCheckbox.classes('is-checked')).toBe(true) + expect(wrapper.vm.value).toEqual([['zhejiang', 'hangzhou'], ['zhejiang', 'ningbo']]) + + await zjCheckbox.find('input').trigger('click') + expect(zjCheckbox.classes('is-checked')).toBe(false) + expect(nbCheckbox.classes('is-checked')).toBe(false) + expect(nbCheckbox.classes('is-checked')).toBe(false) + expect(wrapper.vm.value).toEqual([]) + }) + + test('multiple mode with disabled default value', async () => { + const wrapper = mount(CascaderPanel, { + props: { + options: DISABLED_OPTIONS, + props: { multiple: true }, + modelValue: [['beijing']], + }, + }) + + await nextTick() + + const bjNode = wrapper.find(NODE) + const bjCheckbox = wrapper.find(CHECKBOX) + expect(bjNode.classes('is-disabled')).toBe(true) + expect(bjCheckbox.classes('is-disabled')).toBe(true) + expect(bjCheckbox.classes('is-checked')).toBe(true) + }) + + test('check strictly in single mode', async () => { + const wrapper = _mount({ + template: ` + + `, + data() { + return { + options: NORMAL_OPTIONS, + props: { checkStrictly: true }, + value: [], + } + }, + }) + + const zjRadio = wrapper.findAll(RADIO)[1] + expect(zjRadio.exists()).toBe(true) + + await zjRadio.find('input').trigger('click') + expect(wrapper.vm.value).toEqual(['zhejiang']) + }) + + test('check strictly in single mode with disabled options', async () => { + const wrapper = mount(CascaderPanel, { + props: { + options: DISABLED_OPTIONS, + props: { checkStrictly: true }, + }, + }) + + const [bjNode, , jsNode] = wrapper.findAll(NODE) + const bjRadio = bjNode.find(RADIO) + + // leaf nodes should add a disabled style for that they are not expandable + // but non-leaf nodes are not + expect(bjNode.classes('is-disabled')).toBe(true) + expect(jsNode.classes('is-disabled')).toBe(false) + expect(bjRadio.classes('is-disabled')).toBe(true) + expect(bjRadio.find('input[disabled]').exists()).toBe(true) + + await jsNode.trigger('click') + expect(wrapper.findAll(MENU).length).toEqual(2) + }) + + test('check strictly in multiple mode', async () => { + const wrapper = _mount({ + template: ` + + `, + data() { + return { + options: NORMAL_OPTIONS, + props: { checkStrictly: true, multiple: true }, + value: [['shanghai']], + } + }, + }) + + const shNode = wrapper.findAll(NODE)[2] + const [, zjCheckbox, shCheckbox] = wrapper.findAll(CHECKBOX) + + await nextTick() + await shNode.trigger('click') + + const shCheckbox2 = wrapper.findAll(MENU)[1].find(CHECKBOX) + expect(shCheckbox.classes('is-checked')).toBe(true) + expect(shCheckbox2.classes('is-checked')).toBe(false) + + await zjCheckbox.find('input').trigger('click') + expect(wrapper.vm.value).toEqual([['shanghai'], ['zhejiang']]) + }) + + test('custom props', async () => { + const wrapper = _mount({ + template: ` + + `, + data() { + return { + options: CUSTOM_PROPS_OPTIONS, + props: { + value: 'id', + label: 'name', + children: 'areas', + disabled: 'invalid', + leaf: (data: typeof CUSTOM_PROPS_OPTIONS[0]) => !data.areas?.length, + }, + value: [], + } + }, + }) + + const [bjNode, zjNode] = wrapper.findAll(NODE) + expect(bjNode.text()).toBe('Beijing') + expect(bjNode.find(EXPAND_ARROW).exists()).toBe(false) + + await zjNode.trigger('click') + const [hzNode, nbNode] = wrapper.findAll(MENU)[1].findAll(NODE) + expect(hzNode.exists()).toBe(true) + expect(nbNode.classes('is-disabled')).toBe(true) + + await hzNode.trigger('click') + expect(wrapper.vm.value).toEqual(['zhejiang', 'hangzhou']) + }) + + test('lazy load', async () => { + const wrapper = _mount({ + template: ` + + `, + data() { + return { + value: [], + props: { + lazy: true, + lazyLoad, + }, + } + }, + }) + + jest.runAllTimers() + await nextTick() + const firstOption = wrapper.find(NODE) + expect(firstOption.exists()).toBe(true) + + await firstOption.trigger('click') + expect(firstOption.find('.el-icon-loading').exists()).toBe(true) + jest.runAllTimers() + await nextTick() + expect(firstOption.find('.el-icon-loading').exists()).toBe(false) + + const secondMenu = wrapper.findAll(MENU)[1] + expect(secondMenu.exists()).toBe(true) + + await secondMenu.find(NODE).trigger('click') + expect(wrapper.vm.value).toEqual([1, 2]) + }) + + test('lazy load with default value', async () => { + const wrapper = mount(CascaderPanel, { + props: { + props: { + lazy: true, + lazyLoad, + }, + modelValue: [1, 2], + }, + }) + + jest.runAllTimers() + await nextTick() + + expect(wrapper.findAll(MENU).length).toBe(2) + expect(wrapper.find(`.is-active`).text()).toBe('option2') + }) + + test('getCheckedNodes and clearCheckedNodes', () => { + const wrapper = mount(CascaderPanel, { + props: { + options: NORMAL_OPTIONS, + props: { multiple: true }, + modelValue: [['shanghai', 'shanghai']], + }, + }) + const vm = wrapper.vm as any + expect(vm.getCheckedNodes().length).toBe(2) + expect(vm.getCheckedNodes(true).length).toBe(1) + vm.clearCheckedNodes() + expect(vm.getCheckedNodes().length).toBe(0) + }) +}) diff --git a/packages/cascader-panel/index.ts b/packages/cascader-panel/index.ts new file mode 100644 index 0000000000000..d84bff5cf7ab5 --- /dev/null +++ b/packages/cascader-panel/index.ts @@ -0,0 +1,8 @@ +import { App } from 'vue' +import CascaderPanel from './src/index.vue' + +export default (app: App): void => { + app.component(CascaderPanel.name, CascaderPanel) +} + +export { CascaderPanel } diff --git a/packages/cascader-panel/package.json b/packages/cascader-panel/package.json new file mode 100644 index 0000000000000..26b34e87ac74f --- /dev/null +++ b/packages/cascader-panel/package.json @@ -0,0 +1,12 @@ +{ + "name": "@element-plus/cascader-panel", + "version": "0.0.0", + "main": "dist/index.js", + "license": "MIT", + "peerDependencies": { + "vue": "^3.0.0" + }, + "devDependencies": { + "@vue/test-utils": "^2.0.0-beta.3" + } +} diff --git a/packages/cascader-panel/src/config.ts b/packages/cascader-panel/src/config.ts new file mode 100644 index 0000000000000..3e910d24c8476 --- /dev/null +++ b/packages/cascader-panel/src/config.ts @@ -0,0 +1,43 @@ +import { computed, PropType } from 'vue' +import { NOOP } from '@vue/shared' +import { + CascaderValue, + CascaderOption, + CascaderConfig, + CascaderProps, + ExpandTrigger, +} from '@element-plus/cascader-panel/src/types' + +export const CommonProps = { + modelValue: [Number, String, Array] as PropType, + options: { + type: Array as PropType, + default: () => ([] as CascaderOption[]), + }, + props: { + type: Object as PropType, + default: () => ({} as CascaderProps), + }, +} + +export const DefaultProps: CascaderConfig = { + expandTrigger: ExpandTrigger.CLICK, + multiple: false, + checkStrictly: false, // whether all nodes can be selected + emitPath: true, // wether to emit an array of all levels value in which node is located + lazy: false, + lazyLoad: NOOP, + value: 'value', + label: 'label', + children: 'children', + leaf: 'leaf', + disabled: 'disabled', + hoverThreshold: 500, +} + +export const useCascaderConfig = (props: { props: CascaderProps; }) => { + return computed(() => ({ + ...DefaultProps, + ...props.props, + })) +} diff --git a/packages/cascader-panel/src/index.vue b/packages/cascader-panel/src/index.vue new file mode 100644 index 0000000000000..3f0711427b285 --- /dev/null +++ b/packages/cascader-panel/src/index.vue @@ -0,0 +1,334 @@ + + + + diff --git a/packages/cascader-panel/src/menu.vue b/packages/cascader-panel/src/menu.vue new file mode 100644 index 0000000000000..c4e7101fb2392 --- /dev/null +++ b/packages/cascader-panel/src/menu.vue @@ -0,0 +1,134 @@ + + + + diff --git a/packages/cascader-panel/src/node.ts b/packages/cascader-panel/src/node.ts new file mode 100644 index 0000000000000..a383aa77f8b7a --- /dev/null +++ b/packages/cascader-panel/src/node.ts @@ -0,0 +1,168 @@ +import { isFunction } from '@vue/shared' +import { capitalize, isUndefined, isEmpty } from '@element-plus/utils/util' +import type { + CascaderNodeValue, + CascaderNodePathValue, + CascaderOption, + CascaderConfig, +} from './types' + +type ChildrenData = CascaderOption[] | undefined + +let uid = 0 + +const calculatePathNodes = (node: Node) => { + const nodes = [node] + let { parent } = node + + while (parent) { + nodes.unshift(parent) + parent = parent.parent + } + + return nodes +} + +export default class Node { + readonly uid: number = uid++ + readonly level: number + readonly value: CascaderNodeValue + readonly label: string + readonly pathNodes: Node[] + readonly pathValues: CascaderNodePathValue + readonly pathLabels: string[] + + childrenData: ChildrenData + children: Node[] + text: string + loaded: boolean + checked = false + indeterminate = false + loading = false + + constructor ( + readonly data: Nullable, + readonly config: CascaderConfig, + readonly parent?: Node, + readonly root = false, + ) { + const { value: valueKey, label: labelKey, children: childrenKey } = config + + const childrenData = data[childrenKey] as ChildrenData + const pathNodes = calculatePathNodes(this) + + this.level = root ? 0 : parent ? parent.level + 1 : 1 + this.value = data[valueKey] as CascaderNodeValue + this.label = data[labelKey] as string + this.pathNodes = pathNodes + this.pathValues = pathNodes.map(node => node.value) + this.pathLabels = pathNodes.map(node => node.label) + this.childrenData = childrenData + this.children = (childrenData || []).map(child => new Node(child, config, this)) + this.loaded = !config.lazy || this.isLeaf || !isEmpty(childrenData) + } + + get isDisabled (): boolean { + const { data, parent, config } = this + const { disabled, checkStrictly } = config + const isDisabled = isFunction(disabled) ? disabled(data, this) : !!data[disabled] + return isDisabled || !checkStrictly && parent?.isDisabled + } + + get isLeaf (): boolean { + const { data, config, childrenData, loaded } = this + const { lazy, leaf } = config + const isLeaf = isFunction(leaf) ? leaf(data, this) : data[leaf] + + return isUndefined(isLeaf) + ? lazy && !loaded ? false : !Array.isArray(childrenData) + : !!isLeaf + } + + get valueByOption () { + return this.config.emitPath ? this.pathValues : this.value + } + + appendChild (childData: CascaderOption) { + const { childrenData, children } = this + const node = new Node(childData, this.config, this) + + if (Array.isArray(childrenData)) { + childrenData.push(childData) + } else { + this.childrenData = [childData] + } + + children.push(node) + + return node + } + + calcText (allLevels: boolean, separator: string) { + const text = allLevels ? this.pathLabels.join(separator) : this.label + this.text = text + return text + } + + broadcast (event: string, ...args: unknown[]) { + const handlerName = `onParent${capitalize(event)}` + this.children.forEach(child => { + if (child) { + // bottom up + child.broadcast(event, ...args) + child[handlerName] && child[handlerName](...args) + } + }) + } + + emit (event: string, ...args: unknown[]) { + const { parent } = this + const handlerName = `onChild${capitalize(event)}` + if (parent) { + parent[handlerName] && parent[handlerName](...args) + parent.emit(event, ...args) + } + } + + onParentCheck(checked: boolean) { + if (!this.isDisabled) { + this.setCheckState(checked) + } + } + + onChildCheck() { + const { children } = this + const validChildren = children.filter(child => !child.isDisabled) + const checked = validChildren.length + ? validChildren.every(child => child.checked) + : false + + this.setCheckState(checked) + } + + setCheckState(checked: boolean) { + const totalNum = this.children.length + const checkedNum = this.children.reduce((c, p) => { + const num = p.checked ? 1 : (p.indeterminate ? 0.5 : 0) + return c + num + }, 0) + + this.checked = checked + this.indeterminate = checkedNum !== totalNum && checkedNum > 0 + } + + doCheck(checked: boolean) { + if (this.checked === checked) return + + const { checkStrictly, multiple } = this.config + + if (checkStrictly || !multiple) { + this.checked = checked + } else { + // bottom up to unify the calculation of the indeterminate state + this.broadcast('check', checked) + this.setCheckState(checked) + this.emit('check') + } + } +} diff --git a/packages/cascader-panel/src/node.vue b/packages/cascader-panel/src/node.vue new file mode 100644 index 0000000000000..219266387e25a --- /dev/null +++ b/packages/cascader-panel/src/node.vue @@ -0,0 +1,182 @@ + + + diff --git a/packages/cascader-panel/src/store.ts b/packages/cascader-panel/src/store.ts new file mode 100644 index 0000000000000..7f25b4a4644c3 --- /dev/null +++ b/packages/cascader-panel/src/store.ts @@ -0,0 +1,76 @@ +import isEqual from 'lodash/isEqual' +import Node from './node' +import type { + CascaderNodeValue, + CascaderNodePathValue, + CascaderOption, + CascaderConfig, +} from './types' + +const flatNodes = (nodes: Node[], leafOnly: boolean) => { + return nodes.reduce((res, node) => { + if (node.isLeaf) { + res.push(node) + } else { + !leafOnly && res.push(node) + res = res.concat(flatNodes(node.children, leafOnly)) + } + return res + }, [] as Node[]) +} + +export default class Store { + readonly nodes: Node[] + readonly allNodes: Node[] + readonly leafNodes: Node[] + + constructor (data: CascaderOption[], readonly config: CascaderConfig) { + const nodes = (data || []).map(nodeData => new Node(nodeData, this.config)) + this.nodes = nodes + this.allNodes = flatNodes(nodes, false) + this.leafNodes = flatNodes(nodes, true) + } + + getNodes () { + return this.nodes + } + + getFlattedNodes (leafOnly: boolean) { + return leafOnly ? this.leafNodes : this.allNodes + } + + appendNode(nodeData: CascaderOption, parentNode?: Node) { + const node = parentNode + ? parentNode.appendChild(nodeData) + : new Node(nodeData, this.config) + + if (!parentNode) this.nodes.push(node) + + this.allNodes.push(node) + node.isLeaf && this.leafNodes.push(node) + } + + appendNodes(nodeDataList: CascaderOption[], parentNode: Node) { + nodeDataList.forEach(nodeData => this.appendNode(nodeData, parentNode)) + } + + // when checkStrictly, leaf node first + getNodeByValue (value: CascaderNodeValue | CascaderNodePathValue, leafOnly = false): Nullable { + if (!value && value !== 0) return null + + const nodes = this.getFlattedNodes(leafOnly) + .filter(node => node.value === value || isEqual(node.pathValues, value)) + + return nodes[0] || null + } + + getSameNode (node: Node): Nullable { + if (!node) return null + + const nodes = this.getFlattedNodes(false) + .filter(({ value, level }) => node.value === value && node.level === level) + + return nodes[0] || null + } + +} diff --git a/packages/cascader-panel/src/types.ts b/packages/cascader-panel/src/types.ts new file mode 100644 index 0000000000000..db9c52b9a1399 --- /dev/null +++ b/packages/cascader-panel/src/types.ts @@ -0,0 +1,64 @@ + +import type { VNode, InjectionKey } from 'vue' +import type { default as CascaderNode } from './node' + +export type { CascaderNode } + +export type CascaderNodeValue = string | number +export type CascaderNodePathValue = CascaderNodeValue[] +export type CascaderValue = CascaderNodeValue | CascaderNodePathValue | (CascaderNodeValue | CascaderNodePathValue)[] +export type CascaderConfig = Required +export type isDisabled = (data: CascaderOption, node: CascaderNode) => boolean +export type isLeaf = (data: CascaderOption, node: CascaderNode) => boolean +export type Resolve = (dataList?: CascaderOption[]) => void +export type LazyLoad = (node: CascaderNode, resolve: Resolve) => void +export type RenderLabel = ({ node: CascaderNode, data: CascaderOption }) => VNode | VNode[] + +export enum ExpandTrigger { + CLICK = 'click', + HOVER = 'hover' +} + +export interface CascaderOption extends Record { + label?: string + value?: CascaderNodeValue + children?: CascaderOption[] + disabled?: boolean + leaf?: boolean +} + +export interface CascaderProps { + expandTrigger?: ExpandTrigger + multiple?: boolean + checkStrictly?: boolean + emitPath?: boolean + lazy?: boolean + lazyLoad?: LazyLoad + value?: string + label?: string + children?: string + disabled?: string | isDisabled + leaf?: string | isLeaf + hoverThreshold?: number +} + +export interface Tag { + node?: CascaderNode + key: number + text: string + hitState?: boolean + closable: boolean +} + +export interface ElCascaderPanelContext { + config: CascaderConfig + expandingNode: Nullable + checkedNodes: CascaderNode[] + isHoverMenu: boolean + renderLabelFn: RenderLabel + lazyLoad: (node?: CascaderNode, cb?: (dataList: CascaderOption[]) => void) => void + expandNode: (node: CascaderNode, silent?: boolean) => void + handleCheckChange: (node: CascaderNode, checked: boolean, emitClose?: boolean) => void +} + +export const CASCADER_PANEL_INJECTION_KEY: InjectionKey = Symbol() diff --git a/packages/cascader-panel/src/utils.ts b/packages/cascader-panel/src/utils.ts new file mode 100644 index 0000000000000..e1149df8f5230 --- /dev/null +++ b/packages/cascader-panel/src/utils.ts @@ -0,0 +1,55 @@ + +import type { CascaderNode } from './types' + +export const isLeaf = (el: HTMLElement) => !el.getAttribute('aria-owns') + +export const getSibling = (el: HTMLElement, distance: number): Nullable => { + const { parentNode } = el + + if (!parentNode) return null + + const siblings = parentNode.querySelectorAll('.el-cascader-node[tabindex="-1"]') + const index = Array.prototype.indexOf.call(siblings, el) + return siblings[index + distance] || null +} + +export const getMenuIndex = (el: HTMLElement) => { + if (!el) return 0 + const pieces = el.id.split('-') + return Number(pieces[pieces.length - 2]) +} + +export const focusNode = el => { + if (!el) return + el.focus() + !isLeaf(el) && el.click() +} + +export const checkNode = el => { + if (!el) return + + const input = el.querySelector('input') + if (input) { + input.click() + } else if (isLeaf(el)) { + el.click() + } +} + +export const sortByOriginalOrder = (oldNodes: CascaderNode[], newNodes: CascaderNode[]) => { + const newNodesCopy = newNodes.slice(0) + const newIds = newNodesCopy.map(node => node.uid) + const res = oldNodes.reduce((acc, item) => { + const index = newIds.indexOf(item.uid) + if (index > -1) { + acc.push(item) + newNodesCopy.splice(index, 1) + newIds.splice(index, 1) + } + return acc + }, [] as CascaderNode[]) + + res.push(...newNodesCopy) + + return res +} diff --git a/packages/cascader/__tests__/cascader.spec.ts b/packages/cascader/__tests__/cascader.spec.ts new file mode 100644 index 0000000000000..2530c4d158321 --- /dev/null +++ b/packages/cascader/__tests__/cascader.spec.ts @@ -0,0 +1,317 @@ +import { nextTick } from 'vue' +import { mount } from '@vue/test-utils' +import Cascader from '../src/index.vue' + +const OPTIONS = [ + { + value: 'zhejiang', + label: 'Zhejiang', + children: [ + { + value: 'hangzhou', + label: 'Hangzhou', + }, + { + value: 'ningbo', + label: 'Ningbo', + }, + ], + }, +] + +const AXIOM = 'Rem is the best girl' + +const TRIGGER = '.el-cascader' +const DROPDOWN = '.el-cascader__dropdown' +const NODE = '.el-cascader-node' +const ARROW = '.el-icon-arrow-down' +const CLEAR_BTN = '.el-icon-circle-close' +const TAG = '.el-tag' +const SUGGESTION_ITEM = '.el-cascader__suggestion-item' +const CHECK_ICON = '.el-icon-check' + +const _mount: typeof mount = options => mount({ + components: { + Cascader, + }, + ...options, +}) + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('Cascader.vue', () => { + test('toggle popper visible', async () => { + const handleVisibleChange = jest.fn() + const wrapper = _mount({ + template: ` + + `, + methods: { + handleVisibleChange, + }, + }) + const trigger = wrapper.find(TRIGGER) + const dropdown = document.querySelector(DROPDOWN) as HTMLDivElement + + await trigger.trigger('click') + expect(dropdown.style.display).not.toBe('none') + expect(handleVisibleChange).toBeCalledWith(true) + await trigger.trigger('click') + expect(handleVisibleChange).toBeCalledWith(false) + await trigger.trigger('click') + document.body.click() + expect(handleVisibleChange).toBeCalledWith(false) + }) + + test('expand and check', async () => { + const handleChange = jest.fn() + const handleExpandChange = jest.fn() + const wrapper = _mount({ + template: ` + + `, + data () { + return { + value: [], + options: OPTIONS, + } + }, + methods: { + handleChange, + handleExpandChange, + }, + }) + const trigger = wrapper.find(TRIGGER) + const dropdown = document.querySelector(DROPDOWN) as HTMLDivElement + const vm = wrapper.vm as any + + await trigger.trigger('click') + ;(dropdown.querySelector(NODE) as HTMLElement).click() + await nextTick() + expect(handleExpandChange).toBeCalledWith(['zhejiang']) + ;(dropdown.querySelectorAll(NODE)[1] as HTMLElement).click() + await nextTick() + expect(handleChange).toBeCalledWith(['zhejiang', 'hangzhou']) + expect(vm.value).toEqual(['zhejiang', 'hangzhou']) + expect(wrapper.find('input').element.value).toBe('Zhejiang / Hangzhou') + }) + + test('with default value', async () => { + const wrapper = mount(Cascader, { + props: { + options: OPTIONS, + modelValue: ['zhejiang', 'hangzhou'], + }, + }) + await nextTick() + expect(wrapper.find('input').element.value).toBe('Zhejiang / Hangzhou') + await wrapper.setProps({ modelValue: ['zhejiang', 'ningbo'] }) + expect(wrapper.find('input').element.value).toBe('Zhejiang / Ningbo') + }) + + test('options change', async () => { + const wrapper = mount(Cascader, { + props: { + options: OPTIONS, + modelValue: ['zhejiang', 'hangzhou'], + }, + }) + await wrapper.setProps({ options: [] }) + expect(wrapper.find('input').element.value).toBe('') + }) + + test('disabled', async () => { + const handleVisibleChange = jest.fn() + const wrapper = _mount({ + template: ` + + `, + methods: { + handleVisibleChange, + }, + }) + await wrapper.find(TRIGGER).trigger('click') + expect(handleVisibleChange).not.toBeCalled() + expect(wrapper.find('input[disabled]').exists()).toBe(true) + }) + + test('custom placeholder', async () => { + const wrapper = mount(Cascader, { + props: { + placeholder: AXIOM, + }, + }) + expect(wrapper.find('input').attributes().placeholder).toBe(AXIOM) + }) + + test('clearable', async () => { + const wrapper = mount(Cascader, { + props: { + options: OPTIONS, + clearable: true, + modelValue: ['zhejiang', 'hangzhou'], + }, + }) + const trigger = wrapper.find(TRIGGER) + expect(wrapper.find(ARROW).exists()).toBe(true) + await trigger.trigger('mouseenter') + expect(wrapper.find(ARROW).exists()).toBe(false) + await wrapper.find(CLEAR_BTN).trigger('click') + expect(wrapper.find('input').element.value).toBe('') + expect((wrapper.vm as any).getCheckedNodes().length).toBe(0) + await trigger.trigger('mouseleave') + await trigger.trigger('mouseenter') + await expect(wrapper.find(CLEAR_BTN).exists()).toBe(false) + }) + + test('show last level label', async () => { + const wrapper = mount(Cascader, { + props: { + options: OPTIONS, + showAllLevels: false, + modelValue: ['zhejiang', 'hangzhou'], + }, + }) + await nextTick() + expect(wrapper.find('input').element.value).toBe('Hangzhou') + }) + + test('multiple mode', async () => { + const wrapper = _mount({ + template: ` + + `, + data () { + return { + options: OPTIONS, + props: { multiple: true }, + value: [['zhejiang', 'hangzhou'], ['zhejiang', 'ningbo']], + } + }, + }) + await nextTick() + const tags = wrapper.findAll(TAG) + const [firstTag, secondTag] = tags + expect(tags.length).toBe(2) + expect(firstTag.text()).toBe('Zhejiang / Hangzhou') + expect(secondTag.text()).toBe('Zhejiang / Ningbo') + await firstTag.find('.el-tag__close').trigger('click') + expect(wrapper.findAll(TAG).length).toBe(1) + expect(wrapper.vm.value).toEqual([['zhejiang', 'ningbo']]) + }) + + test('collapse tags', async () => { + const wrapper = mount(Cascader, { + props: { + options: OPTIONS, + props: { multiple: true }, + collapseTags: true, + modelValue: [['zhejiang', 'hangzhou'], ['zhejiang', 'ningbo']], + }, + }) + await nextTick() + const [firstTag, secondTag] = wrapper.findAll(TAG) + expect(firstTag.text()).toBe('Zhejiang / Hangzhou') + expect(secondTag.text()).toBe('+ 1') + }) + + test('filterable', async () => { + const wrapper = _mount({ + template: ` + + `, + data () { + return { + options: OPTIONS, + value: [], + } + }, + }) + + const input = wrapper.find('input') + const dropdown = document.querySelector(DROPDOWN) + input.element.value = 'Ha' + await input.trigger('input') + const suggestions = dropdown.querySelectorAll(SUGGESTION_ITEM) as NodeListOf + const hzSuggestion = suggestions[0] + expect(suggestions.length).toBe(1) + expect(hzSuggestion.textContent).toBe('Zhejiang / Hangzhou') + hzSuggestion.click() + await nextTick() + expect(hzSuggestion.querySelector(CHECK_ICON)).toBeTruthy() + expect(wrapper.vm.value).toEqual(['zhejiang', 'hangzhou']) + hzSuggestion.click() + await nextTick() + expect(wrapper.vm.value).toEqual(['zhejiang', 'hangzhou']) + }) + + test('filterable in multiple mode', async () => { + const wrapper = _mount({ + template: ` + + `, + data () { + return { + options: OPTIONS, + props: { multiple: true }, + value: [], + } + }, + }) + + const input = wrapper.find('.el-cascader__search-input') + const dropdown = document.querySelector(DROPDOWN) + ;(input.element as HTMLInputElement).value = 'Ha' + await input.trigger('input') + await nextTick() + const hzSuggestion = dropdown.querySelector(SUGGESTION_ITEM) as HTMLElement + hzSuggestion.click() + await nextTick() + expect(wrapper.vm.value).toEqual([['zhejiang', 'hangzhou']]) + hzSuggestion.click() + await nextTick() + expect(wrapper.vm.value).toEqual([]) + }) + + test('filter method', async () => { + const filterMethod = jest.fn((node, keyword) => { + const { text, value } = node + return text.includes(keyword) || value.includes(keyword) + }) + const wrapper = mount(Cascader, { + props: { + options: OPTIONS, + filterable: true, + filterMethod, + }, + }) + + const input = wrapper.find('input') + const dropdown = document.querySelector(DROPDOWN) + input.element.value = 'ha' + await input.trigger('input') + const hzSuggestion = dropdown.querySelector(SUGGESTION_ITEM) as HTMLElement + expect(filterMethod).toBeCalled() + expect(hzSuggestion.textContent).toBe('Zhejiang / Hangzhou') + }) +}) diff --git a/packages/cascader/index.ts b/packages/cascader/index.ts new file mode 100644 index 0000000000000..0c3fdf933676a --- /dev/null +++ b/packages/cascader/index.ts @@ -0,0 +1,5 @@ +import { App } from 'vue' +import Cascader from './src/index.vue' +export default (app: App): void => { + app.component(Cascader.name, Cascader) +} diff --git a/packages/cascader/package.json b/packages/cascader/package.json new file mode 100644 index 0000000000000..5a85c1423168c --- /dev/null +++ b/packages/cascader/package.json @@ -0,0 +1,12 @@ +{ + "name": "@element-plus/cascader", + "version": "0.0.0", + "main": "dist/index.js", + "license": "MIT", + "peerDependencies": { + "vue": "^3.0.0" + }, + "devDependencies": { + "@vue/test-utils": "^2.0.0-beta.3" + } +} diff --git a/packages/cascader/src/index.vue b/packages/cascader/src/index.vue new file mode 100644 index 0000000000000..0febc66d02d37 --- /dev/null +++ b/packages/cascader/src/index.vue @@ -0,0 +1,586 @@ + + + diff --git a/packages/element-plus/index.ts b/packages/element-plus/index.ts index 1f963eda31d96..8dfece52ba5ec 100644 --- a/packages/element-plus/index.ts +++ b/packages/element-plus/index.ts @@ -56,6 +56,8 @@ import ElPagination from '@element-plus/pagination' import ElMessageBox from '@element-plus/message-box' import ElInputNumber from '@element-plus/input-number' import ElPopover from '@element-plus/popover' +import ElCascader from '@element-plus/cascader' +import ElCascaderPanel from '@element-plus/cascader-panel' export { ElAlert, @@ -112,6 +114,8 @@ export { ElMessageBox, ElInputNumber, ElPopover, + ElCascader, + ElCascaderPanel, } const install = (app: App): void => { @@ -169,6 +173,8 @@ const install = (app: App): void => { ElPagination(app) ElInputNumber(app) ElPopover(app) + ElCascader(app) + ElCascaderPanel(app) } const elementUI = { diff --git a/packages/form/src/token.ts b/packages/form/src/token.ts index f8ebcef1aa590..b37ca2079624b 100644 --- a/packages/form/src/token.ts +++ b/packages/form/src/token.ts @@ -21,6 +21,7 @@ export interface ElFormContext { rules?: Record statusIcon?: boolean hideRequiredAsterisk?: boolean + disabled?: boolean } export interface ValidateFieldCallback { @@ -29,12 +30,15 @@ export interface ValidateFieldCallback { export interface ElFormItemContext { prop?: string + formItemMitt: Emitter validate(trigger?: string, callback?: ValidateFieldCallback): void updateComputedLabelWidth(width: number): void addValidateEvents(): void removeValidateEvents(): void resetField(): void clearValidate(): void + + size?: string } // TODO: change it to symbol diff --git a/packages/infinite-scroll/src/index.ts b/packages/infinite-scroll/src/index.ts index 07aadcad21f08..515aa51b8c7ea 100644 --- a/packages/infinite-scroll/src/index.ts +++ b/packages/infinite-scroll/src/index.ts @@ -1,6 +1,6 @@ import { nextTick } from 'vue' import { isFunction } from '@vue/shared' -import { throttle } from 'lodash' +import throttle from 'lodash/throttle' import { entries } from '@element-plus/utils/util' import { getScrollContainer, getOffsetTopDistance } from '@element-plus/utils/dom' import throwError from '@element-plus/utils/error' diff --git a/packages/theme-chalk/src/cascader.scss b/packages/theme-chalk/src/cascader.scss index 5c883ea9ccf45..ad6d69446f796 100644 --- a/packages/theme-chalk/src/cascader.scss +++ b/packages/theme-chalk/src/cascader.scss @@ -10,6 +10,7 @@ position: relative; font-size: $--font-size-base; line-height: $--input-height; + outline: none; &:not(.is-disabled):hover { .el-input__inner { @@ -72,12 +73,38 @@ } @include e(dropdown) { - margin: 5px 0; font-size: $--cascader-menu-font-size; - background: $--cascader-menu-fill; - border: $--cascader-menu-border; border-radius: $--cascader-menu-radius; box-shadow: $--cascader-menu-shadow; + + &.el-popper.is-light { + background: $--cascader-menu-fill; + border: $--cascader-menu-border; + box-shadow: $--cascader-menu-shadow; + + .el-popper__arrow { + left: 35px; + &::before { + border: $--cascader-menu-border; + } + } + + &[data-popper-placement^="top"] { + transform-origin: bottom center; + + .el-popper__arrow::before { + border-top-color: transparent; + border-left-color: transparent; + } + } + + &[data-popper-placement^="bottom"] { + .el-popper__arrow::before { + border-bottom-color: transparent; + border-right-color: transparent; + } + } + } } @include e(tags) { diff --git a/packages/utils/dom.ts b/packages/utils/dom.ts index 2d9cfab6af8d9..e2322dd6ce8e5 100644 --- a/packages/utils/dom.ts +++ b/packages/utils/dom.ts @@ -233,4 +233,5 @@ export const getOffsetTop = (el: HTMLElement) => { export const getOffsetTopDistance = (el: HTMLElement, containerEl: HTMLElement) => { return Math.abs(getOffsetTop(el) - getOffsetTop(containerEl)) } + export const stop = (e: Event) => e.stopPropagation() diff --git a/packages/utils/resize-event.ts b/packages/utils/resize-event.ts index beb2301eaf3e5..7bfb4fa6c0e9f 100644 --- a/packages/utils/resize-event.ts +++ b/packages/utils/resize-event.ts @@ -24,7 +24,7 @@ export const addResizeListener = function( element: ResizableElement, fn: (...args: unknown[]) => unknown, ): void { - if (isServer) return + if (isServer || !element) return if (!element.__resizeListeners__) { element.__resizeListeners__ = [] element.__ro__ = new ResizeObserver(resizeHandler) diff --git a/packages/utils/util.ts b/packages/utils/util.ts index 9f81167a5358d..d5525efa410e9 100644 --- a/packages/utils/util.ts +++ b/packages/utils/util.ts @@ -167,7 +167,7 @@ export function entries(obj: Hash): [string, T][] { .map((key: string) => ([key, obj[key]])) } -export function isUndefined(val: any) { +export function isUndefined(val: any): val is undefined { return val === void 0 } @@ -193,3 +193,24 @@ export const arrayFind = function ( ): any { return arr.find(pred) } + +export function isEmpty(val: unknown) { + if ( + !val && val !== 0 || + isArray(val) && !val.length || + isObject(val) && !Object.keys(val).length + ) return true + + return false +} + +export function arrayFlat(arr: unknown[]) { + return arr.reduce((acm: unknown[], item) => { + const val = Array.isArray(item) ? arrayFlat(item) : item + return acm.concat(val) + }, []) +} + +export function deduplicate(arr: T[]) { + return [...new Set(arr)] +} diff --git a/packages/utils/validators.ts b/packages/utils/validators.ts index ab634d3e6ae31..6ea68ad485f80 100644 --- a/packages/utils/validators.ts +++ b/packages/utils/validators.ts @@ -2,3 +2,6 @@ export const isValidWidthUnit = (val: string) => ['px', 'rem', 'em', 'vw', '%', 'vmin', 'vmax'].some(unit => val.endsWith(unit), ) + +export const isValidComponentSize = (val: string) => + ['large', 'medium', 'small', 'mini'].includes(val) diff --git a/types/cascader-panel.d.ts b/types/cascader-panel.d.ts index df32fa0c12f89..b90dd9abc09e2 100644 --- a/types/cascader-panel.d.ts +++ b/types/cascader-panel.d.ts @@ -1,5 +1,8 @@ -import { VNode, CreateElement } from 'vue' -import { ElementUIComponent } from './component' +import { h } from 'vue' +import type { VNode } from 'vue' +import type { ElementUIComponent } from './component' + +type H = typeof h /** Trigger mode of expanding current item */ export type ExpandTrigger = 'click' | 'hover' @@ -66,7 +69,7 @@ export declare class ElCascaderPanel extends Elemen border: boolean /** Render function of custom label content */ - renderLabel: (h: CreateElement, context: { node: CascaderNode; data: D; }) => VNode + renderLabel: (h: H, context: { node: CascaderNode; data: D; }) => VNode $slots: CascaderPanelSlots } diff --git a/types/cascader.d.ts b/types/cascader.d.ts index 5dda2afafc50e..e4594c72c5724 100644 --- a/types/cascader.d.ts +++ b/types/cascader.d.ts @@ -17,7 +17,7 @@ export interface CascaderSlots { /** Cascader Component */ export declare class ElCascader extends ElementUIComponent { /** Data of the options */ - options: CascaderOption[] + options: D[] /** Configuration options */ props: CascaderProps diff --git a/typings/vue-shim.d.ts b/typings/vue-shim.d.ts index c334bd7c0eecd..522995d1f3aae 100644 --- a/typings/vue-shim.d.ts +++ b/typings/vue-shim.d.ts @@ -15,3 +15,5 @@ declare type Indexable = { declare type Hash = Indexable declare type TimeoutHandle = ReturnType + +declare type ComponentSize = 'large' | 'medium' | 'small' | 'mini' diff --git a/website/docs/en-US/cascader.md b/website/docs/en-US/cascader.md index 5d13574f950fa..15f5684363610 100644 --- a/website/docs/en-US/cascader.md +++ b/website/docs/en-US/cascader.md @@ -1481,7 +1481,7 @@ You can customize the content of cascader node. :::demo You can customize the content of cascader node by `scoped slot`. You'll have access to `node` and `data` in the scope, standing for the Node object and node data of the current node respectively。 ```html -