diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index ee74595b6f7..3e0471eb87d 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -3,14 +3,16 @@ ### Feats - `n-dropdown` add `on-clickoutside` prop, closes [#123](https://github.com/TuSimple/naive-ui/issues/123). - -## Pending - -- `n-menu` add `renderLabel` prop, closes [#84](https://github.com/TuSimple/naive-ui/issues/84) +- `n-menu` add `render-label` prop, closes [#84](https://github.com/TuSimple/naive-ui/issues/84) +- `n-tree` supports keyboard operations. +- Add `n-tree-select` component. ### Fixes - Fix `n-tree` drag over leaf node causes error, closes [#200](https://github.com/TuSimple/naive-ui/issues/200). +- Fix `n-tree` misses `on-update-expanded-keys`, `on-update-selected-keys`, `on-update-checked-keys` prop. +- Fix `n-tree`'s `selected-keys` prop influences original array. +- Fix `n-select`'s input has useless empty row in multiple filterable mode. ## 2.12.2 (2021-06-19) diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index ee997fc36be..5ec5ce7b55e 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -3,14 +3,16 @@ ### Feats - `n-dropdown` 新增 `on-clickoutside` 属性,关闭 [#123](https://github.com/TuSimple/naive-ui/issues/123) - -## Pending - -- `n-menu` 新增 `renderLabel` 属性,关闭 [#84](https://github.com/TuSimple/naive-ui/issues/84) +- `n-menu` 新增 `render-label` 属性,关闭 [#84](https://github.com/TuSimple/naive-ui/issues/84) +- `n-tree` 支持键盘操作 +- 新增 `n-tree-select` 组件 ### Fixes +- 修复 `n-tree` 缺少 `on-update-expanded-keys`、`on-update-selected-keys`、`on-update-checked-keys` 属性 - 修复 `n-tree` 拖拽悬浮叶节点报错,关闭 [#200](https://github.com/TuSimple/naive-ui/issues/200) +- 修复 `n-tree` 对 `selected-keys` 属性影响原数组 +- 修复 `n-select` 砸爱 multiple filterable 模式下输入框有无用的空行 ## 2.12.2 (2021-06-19) diff --git a/demo/routes/routes.js b/demo/routes/routes.js index 8e5c015c822..3603f761340 100644 --- a/demo/routes/routes.js +++ b/demo/routes/routes.js @@ -445,6 +445,11 @@ export const enComponentRoutes = [ path: 'color-picker', component: () => import('../../src/color-picker/demos/enUS/index.demo-entry.md') + }, + { + path: 'tree-select', + component: () => + import('../../src/tree-select/demos/enUS/index.demo-entry.md') } ] @@ -775,6 +780,11 @@ export const zhComponentRoutes = [ path: 'color-picker', component: () => import('../../src/color-picker/demos/zhCN/index.demo-entry.md') + }, + { + path: 'tree-select', + component: () => + import('../../src/tree-select/demos/zhCN/index.demo-entry.md') } ] diff --git a/demo/store/menu-options.js b/demo/store/menu-options.js index 6c234ad686e..b10a8a1b12c 100644 --- a/demo/store/menu-options.js +++ b/demo/store/menu-options.js @@ -355,6 +355,12 @@ export function createComponentMenuOptions ({ lang, theme, mode }) { enSuffix: true, path: '/transfer' }, + { + en: 'Tree Select', + zh: '树型选择', + enSuffix: true, + path: '/tree-select' + }, { en: 'Upload', zh: '上传', diff --git a/design-notes/todo.md b/design-notes/todo.md new file mode 100644 index 00000000000..8e34e4c6ef8 --- /dev/null +++ b/design-notes/todo.md @@ -0,0 +1,17 @@ +# TODO + +## Tree + +- (moderate) Use set to check if node is checked or selected internally + +## DataTable + +- (moderate) Add fast path for virtual mode if no cell crosses rows + +## Tabs + +- (moderate) Add iOS styled tabs + +## TreeSelect + +- (moderate) Async diff --git a/package.json b/package.json index d0c28dae488..28a7da0e3e5 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,7 @@ "superagent": "^6.1.0", "typescript": "^4.3.2", "vite": "^2.1.3", - "vue": "^3.0.10", + "vue": "^3.1.1", "vue-router": "^4.0.5" }, "peerDependencies": { diff --git a/src/_internal/selection/src/Selection.tsx b/src/_internal/selection/src/Selection.tsx index 1d3f2044860..86f7e3f7545 100644 --- a/src/_internal/selection/src/Selection.tsx +++ b/src/_internal/selection/src/Selection.tsx @@ -60,7 +60,6 @@ export default defineComponent({ }, multiple: Boolean, filterable: Boolean, - remote: Boolean, clearable: Boolean, disabled: Boolean, size: { @@ -80,7 +79,6 @@ export default defineComponent({ onBlur: Function as PropType<(e: FocusEvent) => void>, onFocus: Function as PropType<(e: FocusEvent) => void>, onDeleteOption: Function, - onDeleteLastOption: Function, maxTagCount: [String, Number] as PropType, onClear: Function as PropType<(e: MouseEvent) => void>, onPatternInput: Function as PropType<(e: InputEvent) => void> @@ -95,6 +93,7 @@ export default defineComponent({ const counterRef = ref(null) const counterWrapperRef = ref(null) const overflowRef = ref(null) + const inputTagElRef = ref(null) const showTagsPopoverRef = ref(false) const patternInputFocusedRef = ref(false) @@ -143,6 +142,17 @@ export default defineComponent({ } } } + function hideInputTag (): void { + const { value: inputTagEl } = inputTagElRef + if (inputTagEl) inputTagEl.style.display = 'none' + } + function showInputTag (): void { + const { value: inputTagEl } = inputTagElRef + if (inputTagEl) inputTagEl.style.display = 'inline-block' + } + watch(toRef(props, 'active'), (value) => { + if (!value) hideInputTag() + }) watch(toRef(props, 'pattern'), () => { if (props.multiple) { void nextTick(syncMirrorWidth) @@ -160,10 +170,6 @@ export default defineComponent({ const { onDeleteOption } = props if (onDeleteOption) onDeleteOption(value) } - function doDeleteLastOption (): void { - const { onDeleteLastOption } = props - if (onDeleteLastOption) onDeleteLastOption() - } function doClear (e: MouseEvent): void { const { onClear } = props if (onClear) onClear(e) @@ -204,7 +210,10 @@ export default defineComponent({ function handlePatternKeyDown (e: KeyboardEvent): void { if (e.code === 'Backspace') { if (!props.pattern.length) { - doDeleteLastOption() + const { selectedOptions } = props + if (selectedOptions?.length) { + handleDeleteOption(selectedOptions[selectedOptions.length - 1]) + } } } } @@ -250,6 +259,7 @@ export default defineComponent({ function focusInput (): void { const { value: patternInputEl } = patternInputRef if (patternInputEl) { + showInputTag() patternInputEl.focus() } } @@ -319,6 +329,7 @@ export default defineComponent({ singleElRef, patternInputWrapperRef, overflowRef, + inputTagElRef, handleMouseDown, handleFocusin, handleClear, @@ -493,6 +504,7 @@ export default defineComponent({ const input = filterable ? (
{{ diff --git a/src/dropdown/src/Dropdown.tsx b/src/dropdown/src/Dropdown.tsx index 0480f6d7683..53d5fe3d48c 100644 --- a/src/dropdown/src/Dropdown.tsx +++ b/src/dropdown/src/Dropdown.tsx @@ -398,7 +398,7 @@ export default defineComponent({ {{ trigger: this.$slots.default, - _: true + _: 1 }} ) diff --git a/src/menu/demos/enUS/render-label.demo.md b/src/menu/demos/enUS/render-label.demo.md index 5f158607fd6..a29c79cb1e7 100644 --- a/src/menu/demos/enUS/render-label.demo.md +++ b/src/menu/demos/enUS/render-label.demo.md @@ -1,6 +1,6 @@ # Render Label -The `renderLabel` can be used to batch render menu options. +The `render-label` can be used to batch render menu options. ```html diff --git a/src/menu/demos/zhCN/render-label.demo.md b/src/menu/demos/zhCN/render-label.demo.md index c8d17d0f81f..c00a3e7213b 100644 --- a/src/menu/demos/zhCN/render-label.demo.md +++ b/src/menu/demos/zhCN/render-label.demo.md @@ -1,6 +1,6 @@ # 批量处理菜单渲染 -使用 `renderLabel` 可以批量控制菜单的选项渲染。 +使用 `render-label` 可以批量控制菜单的选项渲染。 ```html diff --git a/src/popselect/src/Popselect.tsx b/src/popselect/src/Popselect.tsx index 7b29f94fed4..aa6b10194ba 100644 --- a/src/popselect/src/Popselect.tsx +++ b/src/popselect/src/Popselect.tsx @@ -92,7 +92,7 @@ export default defineComponent({ {{ trigger: this.$slots.default, - _: true + _: 1 }} ) diff --git a/src/scrollbar/index.ts b/src/scrollbar/index.ts index 2c226d7be0e..84542116437 100644 --- a/src/scrollbar/index.ts +++ b/src/scrollbar/index.ts @@ -1,2 +1,5 @@ -export { default as NScrollbar } from './src/ScrollBar' +export { + default as NScrollbar, + XScrollbar as NxScrollbar +} from './src/ScrollBar' export type { ScrollbarInst, ScrollbarProps } from './src/ScrollBar' diff --git a/src/scrollbar/src/ScrollBar.tsx b/src/scrollbar/src/ScrollBar.tsx index f49caf98ce1..ce231ac7f8e 100644 --- a/src/scrollbar/src/ScrollBar.tsx +++ b/src/scrollbar/src/ScrollBar.tsx @@ -11,14 +11,18 @@ import { Transition, CSSProperties, watchEffect, - VNode + VNode, + HTMLAttributes } from 'vue' import { on, off } from 'evtd' import { VResizeObserver } from 'vueuc' import { useIsIos } from 'vooks' import { useConfig, useTheme } from '../../_mixins' import type { ThemeProps } from '../../_mixins' -import type { ExtractPublicPropTypes } from '../../_utils' +import type { + ExtractInternalPropTypes, + ExtractPublicPropTypes +} from '../../_utils' import { scrollbarLight } from '../styles' import type { ScrollbarTheme } from '../styles' import style from './styles/index.cssr' @@ -86,15 +90,18 @@ const scrollbarProps = { onScroll: Function as PropType<(e: Event) => void>, onWheel: Function as PropType<(e: WheelEvent) => void>, onResize: Function as PropType<(e: ResizeObserverEntry) => void>, - onDragleave: Function as PropType<(e: DragEvent) => void>, - privateOnSetSL: Function as PropType<(scrollLeft: number) => void> + internalOnUpdateScrollLeft: Function as PropType<(scrollLeft: number) => void> } as const export type ScrollbarProps = ExtractPublicPropTypes +export type ScrollbarInternalProps = ExtractInternalPropTypes< + typeof scrollbarProps +> -export default defineComponent({ +const Scrollbar = defineComponent({ name: 'Scrollbar', props: scrollbarProps, + inheritAttrs: false, setup (props) { const { mergedClsPrefixRef } = useConfig(props) @@ -427,8 +434,8 @@ export default defineComponent({ const { value: container } = mergedContainerRef if (container) { container.scrollLeft = toScrollLeft - const { privateOnSetSL } = props - if (privateOnSetSL) privateOnSetSL(toScrollLeft) + const { internalOnUpdateScrollLeft } = props + if (internalOnUpdateScrollLeft) internalOnUpdateScrollLeft(toScrollLeft) } } function handleXScrollMouseUp (e: MouseEvent): void { @@ -591,7 +598,6 @@ export default defineComponent({ mergeProps(this.$attrs, { class: `${mergedClsPrefix}-scrollbar`, style: this.cssVars, - onDragleave: this.onDragleave, onMouseenter: this.handleMouseEnterWrapper, onMouseleave: this.handleMouseLeaveWrapper }), @@ -690,3 +696,9 @@ export default defineComponent({ ) } }) + +type NativeScrollbarProps = Omit +type MergedProps = Partial + +export default Scrollbar +export const XScrollbar: new () => { $props: MergedProps } = Scrollbar as any diff --git a/src/select/src/Select.tsx b/src/select/src/Select.tsx index 136128826a9..cfa0436acec 100644 --- a/src/select/src/Select.tsx +++ b/src/select/src/Select.tsx @@ -496,21 +496,6 @@ export default defineComponent({ doUpdateValue(option.value) } } - function handleDeleteLastOption (): void { - if (!patternRef.value.length) { - const changedValue = createClearedMultipleSelectValue( - mergedValueRef.value - ) - if (Array.isArray(changedValue)) { - const poppedValue = changedValue.pop() - if (poppedValue === undefined) return - const createdOptionIndex = getCreatedOptionIndex(poppedValue) - ~createdOptionIndex && - createdOptionsRef.value.splice(createdOptionIndex, 1) - doUpdateValue(changedValue) - } - } - } function getCreatedOptionIndex (optionValue: string | number): number { const createdOptions = createdOptionsRef.value return createdOptions.findIndex( @@ -555,9 +540,7 @@ export default defineComponent({ } } function handleMenuMousedown (e: MouseEvent): void { - if (!happensIn(e, 'action') && props.multiple && props.filterable) { - focusSelection() - } + if (!happensIn(e, 'action')) e.preventDefault() } // scroll events on menu function handleMenuScroll (e: Event): void { @@ -660,7 +643,6 @@ export default defineComponent({ handleMenuBlur, handleMenuTabOut, handleTriggerClick, - handleDeleteLastOption, handleToggleOption, handlePatternInput, handleClear, @@ -709,7 +691,6 @@ export default defineComponent({ selectedOptions={this.selectedOptions} multiple={this.multiple} filterable={this.filterable} - remote={this.remote} clearable={this.clearable} disabled={this.disabled} size={this.mergedSize} @@ -720,7 +701,6 @@ export default defineComponent({ loading={this.loading} focused={this.focused} onClick={this.handleTriggerClick} - onDeleteLastOption={this.handleDeleteLastOption} onDeleteOption={this.handleToggleOption} onPatternInput={this.handlePatternInput} onClear={this.handleClear} @@ -740,7 +720,7 @@ export default defineComponent({ containerClass={this.namespace} width={this.consistentMenuWidth ? 'target' : undefined} minWidth="target" - placement="bottom-start" + placement={this.placement} > {{ default: () => ( diff --git a/src/styles.ts b/src/styles.ts index eaff3244ee2..d101c5f96b2 100644 --- a/src/styles.ts +++ b/src/styles.ts @@ -61,8 +61,9 @@ export { timePickerDark } from './time-picker/styles' export { timelineDark } from './timeline/styles' export { tooltipDark } from './tooltip/styles' export { transferDark } from './transfer/styles' -export { typographyDark } from './typography/styles' export { treeDark } from './tree/styles' +export { treeSelectDark } from './tree-select/styles' +export { typographyDark } from './typography/styles' export { uploadDark } from './upload/styles' // danger zone, internal styles diff --git a/src/themes/dark.ts b/src/themes/dark.ts index fe94279a9c9..d71b488f42b 100644 --- a/src/themes/dark.ts +++ b/src/themes/dark.ts @@ -67,6 +67,7 @@ import { timePickerDark } from '../time-picker/styles' import { timelineDark } from '../timeline/styles' import { tooltipDark } from '../tooltip/styles' import { transferDark } from '../transfer/styles' +import { treeSelectDark } from '../tree-select/styles' import { typographyDark } from '../typography/styles' import { treeDark } from '../tree/styles' import { uploadDark } from '../upload/styles' @@ -143,6 +144,7 @@ export const darkTheme: BuiltInGlobalTheme = { Tooltip: tooltipDark, Transfer: transferDark, Tree: treeDark, + TreeSelect: treeSelectDark, Typography: typographyDark, Upload: uploadDark } diff --git a/src/themes/light.ts b/src/themes/light.ts index 5b15d9a7bd6..93e94956100 100644 --- a/src/themes/light.ts +++ b/src/themes/light.ts @@ -71,6 +71,7 @@ import { tooltipLight } from '../tooltip/styles' import { transferLight } from '../transfer/styles' import { typographyLight } from '../typography/styles' import { treeLight } from '../tree/styles' +import { treeSelectLight } from '../tree-select/styles' import { uploadLight } from '../upload/styles' import type { BuiltInGlobalTheme } from './interface' @@ -145,6 +146,7 @@ export const lightTheme: BuiltInGlobalTheme = { Tooltip: tooltipLight, Transfer: transferLight, Tree: treeLight, + TreeSelect: treeSelectLight, Typography: typographyLight, Upload: uploadLight } diff --git a/src/tree-select/demos/enUS/basic.demo.md b/src/tree-select/demos/enUS/basic.demo.md new file mode 100644 index 00000000000..39bbd436bee --- /dev/null +++ b/src/tree-select/demos/enUS/basic.demo.md @@ -0,0 +1,134 @@ +# Basic + +```html + +``` + +```js +import { defineComponent } from 'vue' + +export default defineComponent({ + setup () { + return { + options: [ + { + label: 'Rubber Soul', + key: 'Rubber Soul', + children: [ + { + label: + "Everybody's Got Something to Hide Except Me and My Monkey", + key: "Everybody's Got Something to Hide Except Me and My Monkey" + }, + { + label: 'Drive My Car', + key: 'Drive My Car', + disabled: true + }, + { + label: 'Norwegian Wood', + key: 'Norwegian Wood' + }, + { + label: "You Won't See", + key: "You Won't See", + disabled: true + }, + { + label: 'Nowhere Man', + key: 'Nowhere Man' + }, + { + label: 'Think For Yourself', + key: 'Think For Yourself' + }, + { + label: 'The Word', + key: 'The Word' + }, + { + label: 'Michelle', + key: 'Michelle', + disabled: true + }, + { + label: 'What goes on', + key: 'What goes on' + }, + { + label: 'Girl', + key: 'Girl' + }, + { + label: "I'm looking through you", + key: "I'm looking through you" + }, + { + label: 'In My Life', + key: 'In My Life' + }, + { + label: 'Wait', + key: 'Wait' + } + ] + }, + { + label: 'Let It Be', + key: 'Let It Be Album', + children: [ + { + label: 'Two Of Us', + key: 'Two Of Us' + }, + { + label: 'Dig A Pony', + key: 'Dig A Pony' + }, + { + label: 'Across The Universe', + key: 'Across The Universe' + }, + { + label: 'I Me Mine', + key: 'I Me Mine' + }, + { + label: 'Dig It', + key: 'Dig It' + }, + { + label: 'Let It Be', + key: 'Let It Be' + }, + { + label: 'Maggie Mae', + key: 'Maggie Mae' + }, + { + label: "I've Got A Feeling", + key: "I've Got A Feeling" + }, + { + label: 'One After 909', + key: 'One After 909' + }, + { + label: 'The Long And Winding Road', + key: 'The Long And Winding Road' + }, + { + label: 'For You Blue', + key: 'For You Blue' + }, + { + label: 'Get Back', + key: 'Get Back' + } + ] + } + ] + } + } +}) +``` diff --git a/src/tree-select/demos/enUS/checkbox.demo.md b/src/tree-select/demos/enUS/checkbox.demo.md new file mode 100644 index 00000000000..8797c51843f --- /dev/null +++ b/src/tree-select/demos/enUS/checkbox.demo.md @@ -0,0 +1,139 @@ +# Use Checkbox + +```html + +``` + +```js +import { defineComponent } from 'vue' + +export default defineComponent({ + setup () { + return { + options: [ + { + label: 'Rubber Soul', + key: 'Rubber Soul', + children: [ + { + label: + "Everybody's Got Something to Hide Except Me and My Monkey", + key: "Everybody's Got Something to Hide Except Me and My Monkey" + }, + { + label: 'Drive My Car', + key: 'Drive My Car', + disabled: true + }, + { + label: 'Norwegian Wood', + key: 'Norwegian Wood' + }, + { + label: "You Won't See", + key: "You Won't See", + disabled: true + }, + { + label: 'Nowhere Man', + key: 'Nowhere Man' + }, + { + label: 'Think For Yourself', + key: 'Think For Yourself' + }, + { + label: 'The Word', + key: 'The Word' + }, + { + label: 'Michelle', + key: 'Michelle', + disabled: true + }, + { + label: 'What goes on', + key: 'What goes on' + }, + { + label: 'Girl', + key: 'Girl' + }, + { + label: "I'm looking through you", + key: "I'm looking through you" + }, + { + label: 'In My Life', + key: 'In My Life' + }, + { + label: 'Wait', + key: 'Wait' + } + ] + }, + { + label: 'Let It Be', + key: 'Let It Be Album', + children: [ + { + label: 'Two Of Us', + key: 'Two Of Us' + }, + { + label: 'Dig A Pony', + key: 'Dig A Pony' + }, + { + label: 'Across The Universe', + key: 'Across The Universe' + }, + { + label: 'I Me Mine', + key: 'I Me Mine' + }, + { + label: 'Dig It', + key: 'Dig It' + }, + { + label: 'Let It Be', + key: 'Let It Be' + }, + { + label: 'Maggie Mae', + key: 'Maggie Mae' + }, + { + label: "I've Got A Feeling", + key: "I've Got A Feeling" + }, + { + label: 'One After 909', + key: 'One After 909' + }, + { + label: 'The Long And Winding Road', + key: 'The Long And Winding Road' + }, + { + label: 'For You Blue', + key: 'For You Blue' + }, + { + label: 'Get Back', + key: 'Get Back' + } + ] + } + ] + } + } +}) +``` diff --git a/src/tree-select/demos/enUS/debug.demo.md b/src/tree-select/demos/enUS/debug.demo.md new file mode 100644 index 00000000000..b6d5585fdb6 --- /dev/null +++ b/src/tree-select/demos/enUS/debug.demo.md @@ -0,0 +1,48 @@ +# Debug + +```html + + + Multiple + Checkable + Cascade + Filterable + + + +``` + +```js +import { defineComponent, ref } from 'vue' + +function createData (level = 4, baseKey = '') { + if (!level) return undefined + return Array.apply(null, { length: 6 - level }).map((_, index) => { + const key = '' + baseKey + level + index + return { + label: key, + key, + children: createData(level - 1, key) + } + }) +} + +export default defineComponent({ + setup () { + return { + multiple: ref(false), + checkable: ref(false), + cascade: ref(false), + filterable: ref(false), + options: createData() + } + } +}) +``` diff --git a/src/tree-select/demos/enUS/filterable.demo.md b/src/tree-select/demos/enUS/filterable.demo.md new file mode 100644 index 00000000000..e92ef266a7c --- /dev/null +++ b/src/tree-select/demos/enUS/filterable.demo.md @@ -0,0 +1,149 @@ +# Filterable + +```html + + + + +``` + +```js +import { defineComponent } from 'vue' + +export default defineComponent({ + setup () { + return { + options: [ + { + label: 'Rubber Soul', + key: 'Rubber Soul', + children: [ + { + label: + "Everybody's Got Something to Hide Except Me and My Monkey", + key: "Everybody's Got Something to Hide Except Me and My Monkey" + }, + { + label: 'Drive My Car', + key: 'Drive My Car', + disabled: true + }, + { + label: 'Norwegian Wood', + key: 'Norwegian Wood' + }, + { + label: "You Won't See", + key: "You Won't See", + disabled: true + }, + { + label: 'Nowhere Man', + key: 'Nowhere Man' + }, + { + label: 'Think For Yourself', + key: 'Think For Yourself' + }, + { + label: 'The Word', + key: 'The Word' + }, + { + label: 'Michelle', + key: 'Michelle', + disabled: true + }, + { + label: 'What goes on', + key: 'What goes on' + }, + { + label: 'Girl', + key: 'Girl' + }, + { + label: "I'm looking through you", + key: "I'm looking through you" + }, + { + label: 'In My Life', + key: 'In My Life' + }, + { + label: 'Wait', + key: 'Wait' + } + ] + }, + { + label: 'Let It Be', + key: 'Let It Be Album', + children: [ + { + label: 'Two Of Us', + key: 'Two Of Us' + }, + { + label: 'Dig A Pony', + key: 'Dig A Pony' + }, + { + label: 'Across The Universe', + key: 'Across The Universe' + }, + { + label: 'I Me Mine', + key: 'I Me Mine' + }, + { + label: 'Dig It', + key: 'Dig It' + }, + { + label: 'Let It Be', + key: 'Let It Be' + }, + { + label: 'Maggie Mae', + key: 'Maggie Mae' + }, + { + label: "I've Got A Feeling", + key: "I've Got A Feeling" + }, + { + label: 'One After 909', + key: 'One After 909' + }, + { + label: 'The Long And Winding Road', + key: 'The Long And Winding Road' + }, + { + label: 'For You Blue', + key: 'For You Blue' + }, + { + label: 'Get Back', + key: 'Get Back' + } + ] + } + ] + } + } +}) +``` diff --git a/src/tree-select/demos/enUS/index.demo-entry.md b/src/tree-select/demos/enUS/index.demo-entry.md new file mode 100644 index 00000000000..1e0b283838f --- /dev/null +++ b/src/tree-select/demos/enUS/index.demo-entry.md @@ -0,0 +1,50 @@ +# Tree Select + +It's said that 99% of the people can't distinguish it from cascader. + +## Demos + +```demo +basic +multiple +checkbox +filterable +debug +``` + +## API + +### TreeSelect Props + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| cascade | `boolean` | `false` | Whether to do cascade check when use checkboxes. | +| checkable | `boolean` | `false` | Whether to use checkbox to select value. | +| clearable | `boolean` | `false` | Whether it's clearable. | +| consistent-menu-width | `boolean` | `true` | Whether to make menu's width consistent with input. Set to `true` will disable virtual scroll. | +| default-value | `string \| number \| Array \| null` | `null` | Selected key (or keys when multiple) by default. | +| default-expand-all | `boolean` | `false` | Expand all nodes by default. | +| default-expanded-keys | `Array` | `[]` | Expanded keys by default. | +| disabled | `boolean` | `false` | Whether to disable the tree select. | +| expanded-keys | `Array` | `undefined` | Expanded keys. | +| filterable | `boolean` | `false` | Whether the tree select is disabled. | +| filter | `(pattern: string, option: TreeSelectOption) => boolean` | - | Filter function. | +| max-tag-count | `number \| 'responsive'` | `undefined` | Max tag count to be shown in multiple mode. Set to `'responsive'` will keep all tags in the same row. | +| multiple | `boolean` | `false` | Whether to support multiple select. | +| options | `TreeSelectOption[]` | `[]` | Options. | +| placeholder | `string` | `'Please Select'` | Placeholder. | +| value | `string \| number \| Array \| null>` | `undefined` | Selected key (or keys when multiple). | +| virtual-scroll | `boolean` | `true` | Whether to enable virtual scroll. | +| on-blur | `(e: FocusEvent) => void` | `undefined` | Callback on blur. | +| on-update:expanded-keys | `(value: Array) => void` | `undefined` | Callback on expanded keys updated. | +| on-focus | `(e: FocusEvent) => void` | `undefined` | Callback on focus. | +| on-update:value | `(value: string \| number \| Array \| null) => void` | `undefined` | Callback on value updated. | + +### TreeSelectOption Properties + +| Name | Type | Description | +| --------- | -------------------- | ------------------------------------ | +| key | `string \| number` | Key of the option. Should be unique. | +| label | `string` | Displayed content of the option. | +| children? | `TreeSelectOption[]` | Child options of the option. | +| disabled? | `boolean` | Whether to disabled the option. | diff --git a/src/tree-select/demos/enUS/multiple.demo.md b/src/tree-select/demos/enUS/multiple.demo.md new file mode 100644 index 00000000000..a1c6df6ba13 --- /dev/null +++ b/src/tree-select/demos/enUS/multiple.demo.md @@ -0,0 +1,138 @@ +# Multiple + +```html + +``` + +```js +import { defineComponent } from 'vue' + +export default defineComponent({ + setup () { + return { + options: [ + { + label: 'Rubber Soul', + key: 'Rubber Soul', + children: [ + { + label: + "Everybody's Got Something to Hide Except Me and My Monkey", + key: "Everybody's Got Something to Hide Except Me and My Monkey" + }, + { + label: 'Drive My Car', + key: 'Drive My Car', + disabled: true + }, + { + label: 'Norwegian Wood', + key: 'Norwegian Wood' + }, + { + label: "You Won't See", + key: "You Won't See", + disabled: true + }, + { + label: 'Nowhere Man', + key: 'Nowhere Man' + }, + { + label: 'Think For Yourself', + key: 'Think For Yourself' + }, + { + label: 'The Word', + key: 'The Word' + }, + { + label: 'Michelle', + key: 'Michelle', + disabled: true + }, + { + label: 'What goes on', + key: 'What goes on' + }, + { + label: 'Girl', + key: 'Girl' + }, + { + label: "I'm looking through you", + key: "I'm looking through you" + }, + { + label: 'In My Life', + key: 'In My Life' + }, + { + label: 'Wait', + key: 'Wait' + } + ] + }, + { + label: 'Let It Be', + key: 'Let It Be Album', + children: [ + { + label: 'Two Of Us', + key: 'Two Of Us' + }, + { + label: 'Dig A Pony', + key: 'Dig A Pony' + }, + { + label: 'Across The Universe', + key: 'Across The Universe' + }, + { + label: 'I Me Mine', + key: 'I Me Mine' + }, + { + label: 'Dig It', + key: 'Dig It' + }, + { + label: 'Let It Be', + key: 'Let It Be' + }, + { + label: 'Maggie Mae', + key: 'Maggie Mae' + }, + { + label: "I've Got A Feeling", + key: "I've Got A Feeling" + }, + { + label: 'One After 909', + key: 'One After 909' + }, + { + label: 'The Long And Winding Road', + key: 'The Long And Winding Road' + }, + { + label: 'For You Blue', + key: 'For You Blue' + }, + { + label: 'Get Back', + key: 'Get Back' + } + ] + } + ] + } + } +}) +``` diff --git a/src/tree-select/demos/zhCN/basic.demo.md b/src/tree-select/demos/zhCN/basic.demo.md new file mode 100644 index 00000000000..93b7ca96ee6 --- /dev/null +++ b/src/tree-select/demos/zhCN/basic.demo.md @@ -0,0 +1,134 @@ +# 基础用法 + +```html + +``` + +```js +import { defineComponent } from 'vue' + +export default defineComponent({ + setup () { + return { + options: [ + { + label: 'Rubber Soul', + key: 'Rubber Soul', + children: [ + { + label: + "Everybody's Got Something to Hide Except Me and My Monkey", + key: "Everybody's Got Something to Hide Except Me and My Monkey" + }, + { + label: 'Drive My Car', + key: 'Drive My Car', + disabled: true + }, + { + label: 'Norwegian Wood', + key: 'Norwegian Wood' + }, + { + label: "You Won't See", + key: "You Won't See", + disabled: true + }, + { + label: 'Nowhere Man', + key: 'Nowhere Man' + }, + { + label: 'Think For Yourself', + key: 'Think For Yourself' + }, + { + label: 'The Word', + key: 'The Word' + }, + { + label: 'Michelle', + key: 'Michelle', + disabled: true + }, + { + label: 'What goes on', + key: 'What goes on' + }, + { + label: 'Girl', + key: 'Girl' + }, + { + label: "I'm looking through you", + key: "I'm looking through you" + }, + { + label: 'In My Life', + key: 'In My Life' + }, + { + label: 'Wait', + key: 'Wait' + } + ] + }, + { + label: 'Let It Be', + key: 'Let It Be Album', + children: [ + { + label: 'Two Of Us', + key: 'Two Of Us' + }, + { + label: 'Dig A Pony', + key: 'Dig A Pony' + }, + { + label: 'Across The Universe', + key: 'Across The Universe' + }, + { + label: 'I Me Mine', + key: 'I Me Mine' + }, + { + label: 'Dig It', + key: 'Dig It' + }, + { + label: 'Let It Be', + key: 'Let It Be' + }, + { + label: 'Maggie Mae', + key: 'Maggie Mae' + }, + { + label: "I've Got A Feeling", + key: "I've Got A Feeling" + }, + { + label: 'One After 909', + key: 'One After 909' + }, + { + label: 'The Long And Winding Road', + key: 'The Long And Winding Road' + }, + { + label: 'For You Blue', + key: 'For You Blue' + }, + { + label: 'Get Back', + key: 'Get Back' + } + ] + } + ] + } + } +}) +``` diff --git a/src/tree-select/demos/zhCN/checkbox.demo.md b/src/tree-select/demos/zhCN/checkbox.demo.md new file mode 100644 index 00000000000..7fe61e1dd68 --- /dev/null +++ b/src/tree-select/demos/zhCN/checkbox.demo.md @@ -0,0 +1,139 @@ +# 使用 Checkbox 选择 + +```html + +``` + +```js +import { defineComponent } from 'vue' + +export default defineComponent({ + setup () { + return { + options: [ + { + label: 'Rubber Soul', + key: 'Rubber Soul', + children: [ + { + label: + "Everybody's Got Something to Hide Except Me and My Monkey", + key: "Everybody's Got Something to Hide Except Me and My Monkey" + }, + { + label: 'Drive My Car', + key: 'Drive My Car', + disabled: true + }, + { + label: 'Norwegian Wood', + key: 'Norwegian Wood' + }, + { + label: "You Won't See", + key: "You Won't See", + disabled: true + }, + { + label: 'Nowhere Man', + key: 'Nowhere Man' + }, + { + label: 'Think For Yourself', + key: 'Think For Yourself' + }, + { + label: 'The Word', + key: 'The Word' + }, + { + label: 'Michelle', + key: 'Michelle', + disabled: true + }, + { + label: 'What goes on', + key: 'What goes on' + }, + { + label: 'Girl', + key: 'Girl' + }, + { + label: "I'm looking through you", + key: "I'm looking through you" + }, + { + label: 'In My Life', + key: 'In My Life' + }, + { + label: 'Wait', + key: 'Wait' + } + ] + }, + { + label: 'Let It Be', + key: 'Let It Be Album', + children: [ + { + label: 'Two Of Us', + key: 'Two Of Us' + }, + { + label: 'Dig A Pony', + key: 'Dig A Pony' + }, + { + label: 'Across The Universe', + key: 'Across The Universe' + }, + { + label: 'I Me Mine', + key: 'I Me Mine' + }, + { + label: 'Dig It', + key: 'Dig It' + }, + { + label: 'Let It Be', + key: 'Let It Be' + }, + { + label: 'Maggie Mae', + key: 'Maggie Mae' + }, + { + label: "I've Got A Feeling", + key: "I've Got A Feeling" + }, + { + label: 'One After 909', + key: 'One After 909' + }, + { + label: 'The Long And Winding Road', + key: 'The Long And Winding Road' + }, + { + label: 'For You Blue', + key: 'For You Blue' + }, + { + label: 'Get Back', + key: 'Get Back' + } + ] + } + ] + } + } +}) +``` diff --git a/src/tree-select/demos/zhCN/debug.demo.md b/src/tree-select/demos/zhCN/debug.demo.md new file mode 100644 index 00000000000..b6d5585fdb6 --- /dev/null +++ b/src/tree-select/demos/zhCN/debug.demo.md @@ -0,0 +1,48 @@ +# Debug + +```html + + + Multiple + Checkable + Cascade + Filterable + + + +``` + +```js +import { defineComponent, ref } from 'vue' + +function createData (level = 4, baseKey = '') { + if (!level) return undefined + return Array.apply(null, { length: 6 - level }).map((_, index) => { + const key = '' + baseKey + level + index + return { + label: key, + key, + children: createData(level - 1, key) + } + }) +} + +export default defineComponent({ + setup () { + return { + multiple: ref(false), + checkable: ref(false), + cascade: ref(false), + filterable: ref(false), + options: createData() + } + } +}) +``` diff --git a/src/tree-select/demos/zhCN/filterable.demo.md b/src/tree-select/demos/zhCN/filterable.demo.md new file mode 100644 index 00000000000..aa2cc0484b7 --- /dev/null +++ b/src/tree-select/demos/zhCN/filterable.demo.md @@ -0,0 +1,149 @@ +# 可过滤 + +```html + + + + +``` + +```js +import { defineComponent } from 'vue' + +export default defineComponent({ + setup () { + return { + options: [ + { + label: 'Rubber Soul', + key: 'Rubber Soul', + children: [ + { + label: + "Everybody's Got Something to Hide Except Me and My Monkey", + key: "Everybody's Got Something to Hide Except Me and My Monkey" + }, + { + label: 'Drive My Car', + key: 'Drive My Car', + disabled: true + }, + { + label: 'Norwegian Wood', + key: 'Norwegian Wood' + }, + { + label: "You Won't See", + key: "You Won't See", + disabled: true + }, + { + label: 'Nowhere Man', + key: 'Nowhere Man' + }, + { + label: 'Think For Yourself', + key: 'Think For Yourself' + }, + { + label: 'The Word', + key: 'The Word' + }, + { + label: 'Michelle', + key: 'Michelle', + disabled: true + }, + { + label: 'What goes on', + key: 'What goes on' + }, + { + label: 'Girl', + key: 'Girl' + }, + { + label: "I'm looking through you", + key: "I'm looking through you" + }, + { + label: 'In My Life', + key: 'In My Life' + }, + { + label: 'Wait', + key: 'Wait' + } + ] + }, + { + label: 'Let It Be', + key: 'Let It Be Album', + children: [ + { + label: 'Two Of Us', + key: 'Two Of Us' + }, + { + label: 'Dig A Pony', + key: 'Dig A Pony' + }, + { + label: 'Across The Universe', + key: 'Across The Universe' + }, + { + label: 'I Me Mine', + key: 'I Me Mine' + }, + { + label: 'Dig It', + key: 'Dig It' + }, + { + label: 'Let It Be', + key: 'Let It Be' + }, + { + label: 'Maggie Mae', + key: 'Maggie Mae' + }, + { + label: "I've Got A Feeling", + key: "I've Got A Feeling" + }, + { + label: 'One After 909', + key: 'One After 909' + }, + { + label: 'The Long And Winding Road', + key: 'The Long And Winding Road' + }, + { + label: 'For You Blue', + key: 'For You Blue' + }, + { + label: 'Get Back', + key: 'Get Back' + } + ] + } + ] + } + } +}) +``` diff --git a/src/tree-select/demos/zhCN/index.demo-entry.md b/src/tree-select/demos/zhCN/index.demo-entry.md new file mode 100644 index 00000000000..224499c773b --- /dev/null +++ b/src/tree-select/demos/zhCN/index.demo-entry.md @@ -0,0 +1,50 @@ +# 树型选择 Tree Select + +据说 99% 的人分不清它和 Cascader 的区别。 + +## 演示 + +```demo +basic +multiple +checkbox +filterable +debug +``` + +## API + +### TreeSelect Props + +| 名称 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| cascade | `boolean` | `false` | 使用 checkbox 进行多选时是否级联 | +| checkable | `boolean` | `false` | 是否使用 checkbox 进行选择 | +| clearable | `boolean` | `false` | 是否可清除 | +| consistent-menu-width | `boolean` | `true` | 是否使菜单宽度和输入框一致,打开会禁用虚拟滚动 | +| default-value | `string \| number \| Array \| null` | `null` | 默认选中的 key | +| default-expand-all | `boolean` | `false` | 默认展开全部 | +| default-expanded-keys | `Array` | `[]` | 默认展开节点的 key | +| disabled | `boolean` | `false` | 是否禁用 | +| expanded-keys | `Array` | `undefined` | 展开节点的 key | +| filterable | `boolean` | `false` | 是否可过滤 | +| filter | `(pattern: string, option: TreeSelectOption) => boolean` | - | 过滤器函数 | +| max-tag-count | `number \| 'responsive'` | `undefined` | 多选时最多直接显示多少选项,设为 `'responsive'` 会保证最多一行 | +| multiple | `boolean` | `false` | 是否支持多选 | +| options | `TreeSelectOption[]` | `[]` | 选项 | +| placeholder | `string` | `'请选择'` | 占位信息 | +| value | `string \| number \| Array \| null>` | `undefined` | 选中的 key | +| virtual-scroll | `boolean` | `true` | 是否开启虚拟滚动 | +| on-blur | `(e: FocusEvent) => void` | `undefined` | Blur 时的回调 | +| on-update:expanded-keys | `(value: Array) => void` | `undefined` | 展开节点更新的回调 | +| on-focus | `(e: FocusEvent) => void` | `undefined` | Focus 时的回调 | +| on-update:value | `(value: string \| number \| Array \| null) => void` | `undefined` | 更新值的回调 | + +### TreeSelectOption Properties + +| 名称 | 类型 | 说明 | +| --------- | -------------------- | -------------------- | +| key | `string \| number` | 选项的 key,需要唯一 | +| label | `string` | 选项的显示内容 | +| children? | `TreeSelectOption[]` | 节点的子选项 | +| disabled? | `boolean` | 是否禁用选项 | diff --git a/src/tree-select/demos/zhCN/multiple.demo.md b/src/tree-select/demos/zhCN/multiple.demo.md new file mode 100644 index 00000000000..a0e0a110bc9 --- /dev/null +++ b/src/tree-select/demos/zhCN/multiple.demo.md @@ -0,0 +1,138 @@ +# 多选 + +```html + +``` + +```js +import { defineComponent } from 'vue' + +export default defineComponent({ + setup () { + return { + options: [ + { + label: 'Rubber Soul', + key: 'Rubber Soul', + children: [ + { + label: + "Everybody's Got Something to Hide Except Me and My Monkey", + key: "Everybody's Got Something to Hide Except Me and My Monkey" + }, + { + label: 'Drive My Car', + key: 'Drive My Car', + disabled: true + }, + { + label: 'Norwegian Wood', + key: 'Norwegian Wood' + }, + { + label: "You Won't See", + key: "You Won't See", + disabled: true + }, + { + label: 'Nowhere Man', + key: 'Nowhere Man' + }, + { + label: 'Think For Yourself', + key: 'Think For Yourself' + }, + { + label: 'The Word', + key: 'The Word' + }, + { + label: 'Michelle', + key: 'Michelle', + disabled: true + }, + { + label: 'What goes on', + key: 'What goes on' + }, + { + label: 'Girl', + key: 'Girl' + }, + { + label: "I'm looking through you", + key: "I'm looking through you" + }, + { + label: 'In My Life', + key: 'In My Life' + }, + { + label: 'Wait', + key: 'Wait' + } + ] + }, + { + label: 'Let It Be', + key: 'Let It Be Album', + children: [ + { + label: 'Two Of Us', + key: 'Two Of Us' + }, + { + label: 'Dig A Pony', + key: 'Dig A Pony' + }, + { + label: 'Across The Universe', + key: 'Across The Universe' + }, + { + label: 'I Me Mine', + key: 'I Me Mine' + }, + { + label: 'Dig It', + key: 'Dig It' + }, + { + label: 'Let It Be', + key: 'Let It Be' + }, + { + label: 'Maggie Mae', + key: 'Maggie Mae' + }, + { + label: "I've Got A Feeling", + key: "I've Got A Feeling" + }, + { + label: 'One After 909', + key: 'One After 909' + }, + { + label: 'The Long And Winding Road', + key: 'The Long And Winding Road' + }, + { + label: 'For You Blue', + key: 'For You Blue' + }, + { + label: 'Get Back', + key: 'Get Back' + } + ] + } + ] + } + } +}) +``` diff --git a/src/tree-select/index.ts b/src/tree-select/index.ts new file mode 100644 index 00000000000..c819a785ee4 --- /dev/null +++ b/src/tree-select/index.ts @@ -0,0 +1,3 @@ +export { default as NTreeSelect } from './src/TreeSelect' +export type { TreeSelectProps } from './src/TreeSelect' +export type { TreeSelectOption } from './src/interface' diff --git a/src/tree-select/src/TreeSelect.tsx b/src/tree-select/src/TreeSelect.tsx new file mode 100644 index 00000000000..31386c8fb96 --- /dev/null +++ b/src/tree-select/src/TreeSelect.tsx @@ -0,0 +1,717 @@ +import { + h, + defineComponent, + PropType, + ref, + toRef, + Transition, + withDirectives, + computed, + CSSProperties, + provide, + watch, + nextTick +} from 'vue' +import { + FollowerPlacement, + VBinder, + VFollower, + VTarget, + FollowerInst +} from 'vueuc' +import { useIsMounted, useMergedState } from 'vooks' +import { clickoutside } from 'vdirs' +import { createTreeMate } from 'treemate' +import { Key, InternalTreeInst } from '../../tree/src/interface' +import type { SelectBaseOption } from '../../select/src/interface' +import { treeMateOptions, treeSharedProps } from '../../tree/src/Tree' +import { + NInternalSelection, + InternalSelectionInst, + NBaseFocusDetector +} from '../../_internal' +import { NTree } from '../../tree' +import { NEmpty } from '../../empty' +import { useConfig, useFormItem, useLocale, useTheme } from '../../_mixins' +import type { ThemeProps } from '../../_mixins' +import { + call, + ExtractPublicPropTypes, + MaybeArray, + useAdjustedTo +} from '../../_utils' +import { treeSelectLight, TreeSelectTheme } from '../styles' +import type { + OnUpdateValue, + OnUpdateValueImpl, + TreeSelectOption, + Value +} from './interface' +import { treeSelectInjectionKey } from './interface' +import { treeOption2SelectOption, filterTree } from './utils' +import style from './styles/index.cssr' + +const props = { + ...(useTheme.props as ThemeProps), + bordered: { + type: Boolean, + default: true + }, + cascade: Boolean, + checkable: Boolean, + clearable: Boolean, + consistentMenuWidth: { + type: Boolean, + default: true + }, + defaultShow: Boolean, + defaultValue: { + type: [String, Number, Array] as PropType< + string | number | Array | null + >, + default: null + }, + disabled: Boolean, + filterable: Boolean, + maxTagCount: [String, Number] as PropType, + multiple: Boolean, + options: { + type: Array as PropType, + default: () => [] + }, + placeholder: String, + placement: { + type: String as PropType, + default: 'bottom-start' + }, + show: { + type: Boolean as PropType, + default: undefined + }, + size: String as PropType<'small' | 'medium' | 'large'>, + value: [String, Number, Array] as PropType< + string | number | Array | null + >, + to: useAdjustedTo.propTo, + virtualScroll: { + type: Boolean, + default: true + }, + ...treeSharedProps, + onBlur: Function as PropType<(e: FocusEvent) => void>, + onFocus: Function as PropType<(e: FocusEvent) => void>, + onUpdateShow: [Function, Array] as PropType< + MaybeArray<(show: boolean) => void> + >, + onUpdateValue: [Function, Array] as PropType>, + 'onUpdate:value': [Function, Array] as PropType>, + 'onUpdate:show': [Function, Array] as PropType< + MaybeArray<(show: boolean) => void> + > +} as const + +export type TreeSelectProps = ExtractPublicPropTypes + +export default defineComponent({ + name: 'TreeSelect', + props, + setup (props) { + const followerInstRef = ref(null) + const triggerInstRef = ref(null) + const treeInstRef = ref(null) + const menuElRef = ref(null) + const { mergedClsPrefixRef, namespaceRef } = useConfig(props) + const { localeRef } = useLocale('Select') + const { + mergedSizeRef, + nTriggerFormBlur, + nTriggerFormChange, + nTriggerFormFocus, + nTriggerFormInput + } = useFormItem(props) + const uncontrolledValueRef = ref(props.defaultValue) + const controlledValueRef = toRef(props, 'value') + const mergedValueRef = useMergedState( + controlledValueRef, + uncontrolledValueRef + ) + const uncontrolledShowRef = ref(props.defaultShow) + const controlledShowRef = toRef(props, 'show') + const mergedShowRef = useMergedState(controlledShowRef, uncontrolledShowRef) + const patternRef = ref('') + const filteredTreeInfoRef = computed<{ + filteredTree: TreeSelectOption[] + highlightKeySet: Set | undefined + expandedKeys: Key[] | undefined + }>(() => { + if (!props.filterable) { + return { + filteredTree: props.options, + highlightKeySet: undefined, + expandedKeys: undefined + } + } + const { value: pattern } = patternRef + if (!pattern.length || !props.filter) { + return { + filteredTree: props.options, + highlightKeySet: undefined, + expandedKeys: undefined + } + } + return filterTree(props.options, props.filter, pattern) + }) + // used to resolve selected options + const dataTreeMateRef = computed(() => + createTreeMate(props.options, treeMateOptions) + ) + const displayTreeMateRef = computed(() => + createTreeMate( + filteredTreeInfoRef.value.filteredTree, + treeMateOptions + ) + ) + const { value: initMergedValue } = mergedValueRef + const pendingNodeKeyRef = ref( + props.checkable + ? null + : Array.isArray(initMergedValue) && initMergedValue.length + ? initMergedValue[initMergedValue.length - 1] + : null + ) + const mergedCascadeRef = computed(() => { + return props.multiple && props.cascade + }) + // The same logic as tree, now it's not that complex so I don't extract a + // function to reuse it. + const uncontrolledExpandedKeysRef = ref( + props.defaultExpandAll + ? displayTreeMateRef.value.getNonLeafKeys() + : props.defaultExpandedKeys || props.expandedKeys + ) + const controlledExpandedKeysRef = toRef(props, 'expandedKeys') + const mergedExpandedKeysRef = useMergedState( + controlledExpandedKeysRef, + uncontrolledExpandedKeysRef + ) + const focusedRef = ref(false) + const mergedPlaceholderRef = computed(() => { + const { placeholder } = props + if (placeholder !== undefined) return placeholder + return localeRef.value.placeholder + }) + const treeSelectedKeysRef = computed(() => { + if (props.checkable) return [] + const { value: mergedValue } = mergedValueRef + const { multiple } = props + return Array.isArray(mergedValue) + ? multiple + ? mergedValue + : [] + : multiple + ? [] + : mergedValue === null + ? [] + : [mergedValue] + }) + const treeCheckedKeysRef = computed(() => { + if (!props.checkable) return [] + const { value: mergedValue } = mergedValueRef + if (props.multiple) { + if (Array.isArray(mergedValue)) return mergedValue + else return [] + } else { + if (mergedValue === null || Array.isArray(mergedValue)) return [] + else return [mergedValue] + } + }) + const selectedOptionRef = computed(() => { + if (props.multiple) return null + const { value: mergedValue } = mergedValueRef + if (!Array.isArray(mergedValue) && mergedValue !== null) { + const tmNode = dataTreeMateRef.value.getNode(mergedValue) + if (tmNode !== null) return treeOption2SelectOption(tmNode.rawNode) + } + return null + }) + const selectedOptionsRef = computed(() => { + if (!props.multiple) return null + const { value: mergedValue } = mergedValueRef + if (Array.isArray(mergedValue)) { + const res: SelectBaseOption[] = [] + const { value: treeMate } = dataTreeMateRef + mergedValue.forEach((value) => { + const tmNode = treeMate.getNode(value) + if (tmNode !== null) res.push(treeOption2SelectOption(tmNode.rawNode)) + }) + return res + } + return [] + }) + const menuPaddingRef = computed(() => { + const { + self: { menuPadding } + } = themeRef.value + return menuPadding + }) + function focusSelection (): void { + triggerInstRef.value?.focus() + } + function focusSelectionInput (): void { + triggerInstRef.value?.focusInput() + } + function doUpdateShow (value: boolean): void { + const { onUpdateShow, 'onUpdate:show': _onUpdateShow } = props + if (onUpdateShow) call(onUpdateShow, value) + if (_onUpdateShow) call(_onUpdateShow, value) + uncontrolledShowRef.value = value + } + function doUpdateValue ( + value: string | number | Array | null + ): void { + const { onUpdateValue, 'onUpdate:value': _onUpdateValue } = props + if (onUpdateValue) call(onUpdateValue as OnUpdateValueImpl, value) + if (_onUpdateValue) call(_onUpdateValue as OnUpdateValueImpl, value) + uncontrolledValueRef.value = value + nTriggerFormInput() + nTriggerFormChange() + } + function doUpdateExpandedKeys (keys: Key[]): void { + const { + onUpdateExpandedKeys, + 'onUpdate:expandedKeys': _onUpdateExpandedKeys + } = props + if (onUpdateExpandedKeys) call(onUpdateExpandedKeys, keys) + if (_onUpdateExpandedKeys) call(_onUpdateExpandedKeys, keys) + uncontrolledExpandedKeysRef.value = keys + } + function doFocus (e: FocusEvent): void { + const { onFocus } = props + if (onFocus) onFocus(e) + nTriggerFormFocus() + } + function doBlur (e: FocusEvent): void { + closeMenu() + const { onBlur } = props + if (onBlur) onBlur(e) + nTriggerFormBlur() + } + function closeMenu (): void { + doUpdateShow(false) + } + function openMenu (): void { + if (!props.disabled) { + patternRef.value = '' + doUpdateShow(true) + if (props.filterable) { + focusSelectionInput() + } + } + } + function handleMenuLeave (): void { + patternRef.value = '' + } + function handleMenuClickoutside (e: MouseEvent): void { + if (mergedShowRef.value) { + if (!triggerInstRef.value?.$el.contains(e.target as Node)) { + // outside select, don't need to return focus + closeMenu() + } + } + } + function handleTriggerClick (): void { + if (props.disabled) return + if (!mergedShowRef.value) { + openMenu() + } else { + if (!props.filterable) { + // already focused, don't need to return focus + closeMenu() + } + } + } + function handleUpdateSelectedKeys (keys: Key[]): void { + if (props.checkable && props.multiple) { + return + } + if (props.multiple) { + doUpdateValue(keys) + } else { + doUpdateValue(keys[0] ?? null) + closeMenu() + if (!props.filterable) { + // Currently it is not necessary. However if there is an action slot, + // it will be useful. So just leave it here. + focusSelection() + } + } + if (props.filterable) { + focusSelectionInput() + patternRef.value = '' + } + } + function handleUpdateCheckedKeys (keys: Key[]): void { + // only in checkable & multiple mode, we use tree's check update + if (props.checkable && props.multiple) { + doUpdateValue(keys) + if (props.filterable) { + focusSelectionInput() + patternRef.value = '' + } + } + } + function handleTriggerFocus (e: FocusEvent): void { + if (menuElRef.value?.contains(e.relatedTarget as Element)) return + focusedRef.value = true + doFocus(e) + } + function handleTriggerBlur (e: FocusEvent): void { + if (menuElRef.value?.contains(e.relatedTarget as Element)) return + focusedRef.value = false + doBlur(e) + } + function handleMenuFocusin (e: FocusEvent): void { + if ( + menuElRef.value?.contains(e.relatedTarget as Element) || + triggerInstRef.value?.$el?.contains(e.relatedTarget as Element) + ) { + return + } + focusedRef.value = true + doFocus(e) + } + function handleMenuFocusout (e: FocusEvent): void { + if ( + menuElRef.value?.contains(e.relatedTarget as Element) || + triggerInstRef.value?.$el?.contains(e.relatedTarget as Element) + ) { + return + } + focusedRef.value = false + doBlur(e) + } + function handleClear (e: MouseEvent): void { + e.stopPropagation() + const { multiple } = props + if (!multiple && props.filterable) { + closeMenu() + } + if (multiple) { + doUpdateValue([]) + } else { + doUpdateValue(null) + } + } + function handleDeleteOption (option: SelectBaseOption): void { + // only work for multiple mode + const { value: mergedValue } = mergedValueRef + if (Array.isArray(mergedValue)) { + const index = mergedValue.findIndex((key) => key === option.value) + if (~index) { + if (props.checkable) { + const { checkedKeys } = dataTreeMateRef.value.uncheck( + option.value, + mergedValue, + { + cascade: mergedCascadeRef.value + } + ) + doUpdateValue(checkedKeys) + } else { + const nextValue = Array.from(mergedValue) + nextValue.splice(index, 1) + doUpdateValue(nextValue) + } + } + } + } + function handlePatternInput (e: InputEvent): void { + const { value } = e.target as unknown as HTMLInputElement + patternRef.value = value + } + function handleKeydown (e: KeyboardEvent): void { + const { value: treeInst } = treeInstRef + if (treeInst) { + treeInst.handleKeydown(e) + } + } + function handleKeyup (e: KeyboardEvent): void { + if (e.code === 'Enter') { + if (mergedShowRef.value) { + treeHandleKeyup(e) + if (!props.multiple) { + closeMenu() + focusSelection() + } + } else { + openMenu() + } + e.preventDefault() + } else if (e.code === 'Escape') { + closeMenu() + focusSelection() + } else { + treeHandleKeyup(e) + } + } + function treeHandleKeyup (e: KeyboardEvent): void { + const { value: treeInst } = treeInstRef + if (treeInst) { + treeInst.handleKeyup(e) + } + } + function handleTabOut (): void { + closeMenu() + focusSelection() + } + function handleMenuMousedown (e: MouseEvent): void { + // If there's an action slot later, we need to check if mousedown happens + // in action panel + e.preventDefault() + } + provide(treeSelectInjectionKey, { + pendingNodeKeyRef + }) + function syncPosition (): void { + followerInstRef.value?.syncPosition() + } + watch(mergedValueRef, () => { + if (!mergedShowRef.value) return + void nextTick(syncPosition) + }) + let memorizedExpandedKeys: Key[] | undefined + watch(patternRef, (value, oldValue) => { + if (!value.length) { + if (memorizedExpandedKeys !== undefined) { + doUpdateExpandedKeys(memorizedExpandedKeys) + } + } else { + if (!oldValue.length) { + memorizedExpandedKeys = mergedExpandedKeysRef.value + } + const { expandedKeys } = filteredTreeInfoRef.value + if (expandedKeys !== undefined) { + doUpdateExpandedKeys(expandedKeys) + } + } + }) + const themeRef = useTheme( + 'TreeSelect', + 'TreeSelect', + style, + treeSelectLight, + props, + mergedClsPrefixRef + ) + return { + menuElRef, + triggerInstRef, + followerInstRef, + treeInstRef, + mergedClsPrefix: mergedClsPrefixRef, + mergedValue: mergedValueRef, + mergedShow: mergedShowRef, + namespace: namespaceRef, + adjustedTo: useAdjustedTo(props), + isMounted: useIsMounted(), + focused: focusedRef, + filteredTreeInfo: filteredTreeInfoRef, + dataTreeMate: dataTreeMateRef, + displayTreeMate: displayTreeMateRef, + menuPadding: menuPaddingRef, + mergedPlaceholder: mergedPlaceholderRef, + mergedExpandedKeys: mergedExpandedKeysRef, + treeSelectedKeys: treeSelectedKeysRef, + treeCheckedKeys: treeCheckedKeysRef, + mergedSize: mergedSizeRef, + selectedOption: selectedOptionRef, + selectedOptions: selectedOptionsRef, + pattern: patternRef, + pendingNodeKey: pendingNodeKeyRef, + mergedCascade: mergedCascadeRef, + doUpdateExpandedKeys, + handleMenuLeave, + handleTriggerClick, + handleMenuClickoutside, + handleUpdateSelectedKeys, + handleUpdateCheckedKeys, + handleTriggerFocus, + handleTriggerBlur, + handleMenuFocusin, + handleMenuFocusout, + handleClear, + handleDeleteOption, + handlePatternInput, + handleKeydown, + handleKeyup, + handleTabOut, + handleMenuMousedown, + cssVars: computed(() => { + const { + common: { cubicBezierEaseInOut }, + self: { menuBoxShadow, menuBorderRadius, menuColor, menuHeight } + } = themeRef.value + return { + '--menu-box-shadow': menuBoxShadow, + '--menu-border-radius': menuBorderRadius, + '--menu-color': menuColor, + '--menu-height': menuHeight, + '--bezier': cubicBezierEaseInOut + } + }), + mergedTheme: themeRef + } + }, + render () { + const { mergedTheme, mergedClsPrefix } = this + return ( +
+ + {{ + default: () => [ + + {{ + default: () => ( + + ) + }} + , + + {{ + default: () => ( + + {{ + default: () => { + if (!this.mergedShow) return null + const { + mergedClsPrefix, + filteredTreeInfo, + checkable, + multiple + } = this + return withDirectives( +
+ {filteredTreeInfo.filteredTree.length ? ( + + ) : ( +
+ +
+ )} + +
, + [[clickoutside, this.handleMenuClickoutside]] + ) + } + }} +
+ ) + }} +
+ ] + }} +
+
+ ) + } +}) diff --git a/src/tree-select/src/interface.ts b/src/tree-select/src/interface.ts new file mode 100644 index 00000000000..081e7b5a3da --- /dev/null +++ b/src/tree-select/src/interface.ts @@ -0,0 +1,40 @@ +import { InjectionKey, Ref } from 'vue' +import { TreeOptionBase } from '../../tree/src/interface' + +export type TreeSelectOption = Omit< +TreeOptionBase, +'checkboxDisabled' | 'isLeaf' | 'children' +> & { + children?: TreeSelectOption[] + [k: string]: unknown +} + +export type OnUpdateValue = ( + value: string & + number & + (string | number) & + string[] & + number[] & + Array & + null +) => void + +export type OnUpdateValueImpl = ( + value: + | string + | number + | (string | number) + | string[] + | number[] + | Array + | null +) => void + +export type Value = string | number | Array | null + +export interface TreeSelectInjection { + pendingNodeKeyRef: Ref +} + +export const treeSelectInjectionKey: InjectionKey = + Symbol('tree-select') diff --git a/src/tree-select/src/styles/index.cssr.ts b/src/tree-select/src/styles/index.cssr.ts new file mode 100644 index 00000000000..b835eb3ed24 --- /dev/null +++ b/src/tree-select/src/styles/index.cssr.ts @@ -0,0 +1,37 @@ +import fadeInScaleUpTransition from '../../../_styles/transitions/fade-in-scale-up.cssr' +import { c, cB, cE } from '../../../_utils/cssr' + +// vars: +// --bezier +// --menu-height +// --menu-border-radius +// --menu-box-shadow +// --menu-color +export default c([ + cB('tree-select', ` + z-index: auto; + outline: none; + width: 100%; + position: relative; + `), + cB('tree-select-menu', ` + position: relative; + overflow: hidden; + margin: 4px 0; + max-height: var(--menu-height); + transition: box-shadow .3s var(--bezier), background-color .3s var(--bezier); + border-radius: var(--menu-border-radius); + box-shadow: var(--menu-box-shadow); + background-color: var(--menu-color); + outline: none; + `, [ + cB('tree', 'max-height: inherit;'), + cE('empty', ` + display: flex; + padding: 12px 32px; + flex: 1; + justify-content: center; + `), + fadeInScaleUpTransition() + ]) +]) diff --git a/src/tree-select/src/utils.ts b/src/tree-select/src/utils.ts new file mode 100644 index 00000000000..edbc0c98d44 --- /dev/null +++ b/src/tree-select/src/utils.ts @@ -0,0 +1,83 @@ +import { SelectBaseOption } from '../../select/src/interface' +import { Key } from '../../tree/src/interface' +import { TreeSelectOption } from './interface' + +export function treeOption2SelectOption ( + treeOpt: TreeSelectOption +): SelectBaseOption { + return { + ...treeOpt, + value: treeOpt.key + } +} + +export function filterTree ( + tree: TreeSelectOption[], + filter: (pattern: string, v: TreeSelectOption) => boolean, + pattern: string +): { + filteredTree: TreeSelectOption[] + expandedKeys: Key[] + highlightKeySet: Set + } { + const visitedTailKeys = new Set() + const visitedNonTailKeys = new Set() + const highlightKeySet = new Set() + const expandedKeys: Key[] = [] + const filteredTree: TreeSelectOption[] = [] + const path: TreeSelectOption[] = [] + function visit (t: TreeSelectOption[]): void { + t.forEach((n) => { + path.push(n) + if (filter(pattern, n)) { + visitedTailKeys.add(n.key) + highlightKeySet.add(n.key) + for (let i = path.length - 2; i >= 0; --i) { + const { key } = path[i] + if (!visitedNonTailKeys.has(key)) { + visitedNonTailKeys.add(key) + if (visitedTailKeys.has(key)) { + visitedTailKeys.delete(key) + } + } else { + break + } + } + } + if (n.children) { + visit(n.children) + } + path.pop() + }) + } + visit(tree) + function build (t: TreeSelectOption[], sibs: TreeSelectOption[]): void { + t.forEach((n) => { + const { key } = n + const isVisitedTail = visitedTailKeys.has(key) + const isVisitedNonTail = visitedNonTailKeys.has(key) + if (!isVisitedTail && !isVisitedNonTail) return + const { children } = n + if (children) { + if (isVisitedTail) { + // If it is visited path tail, use origin node + sibs.push(n) + } else { + // It it is not visited path tail, use cloned node + expandedKeys.push(n.key) + const clonedNode = { ...n, children: [] } + sibs.push(clonedNode) + build(children, clonedNode.children) + } + } else { + sibs.push(n) + } + }) + } + build(tree, filteredTree) + return { + filteredTree, + highlightKeySet, + expandedKeys + } +} diff --git a/src/tree-select/styles/dark.ts b/src/tree-select/styles/dark.ts new file mode 100644 index 00000000000..586dc08bf5a --- /dev/null +++ b/src/tree-select/styles/dark.ts @@ -0,0 +1,17 @@ +import { emptyDark } from '../../empty/styles' +import { treeDark } from '../../tree/styles' +import { commonDark } from '../../_styles/common' +import { internalSelectionDark } from '../../_internal/selection/styles' +import type { TreeSelectTheme } from './light' + +const treeSelectDark: TreeSelectTheme = { + name: 'TreeSelect', + common: commonDark, + peers: { + Tree: treeDark, + Empty: emptyDark, + InternalSelection: internalSelectionDark + } +} + +export default treeSelectDark diff --git a/src/tree-select/styles/index.ts b/src/tree-select/styles/index.ts new file mode 100644 index 00000000000..a93ed5f6fb7 --- /dev/null +++ b/src/tree-select/styles/index.ts @@ -0,0 +1,3 @@ +export { default as treeSelectDark } from './dark' +export { default as treeSelectLight } from './light' +export type { TreeSelectThemeVars, TreeSelectTheme } from './light' diff --git a/src/tree-select/styles/light.ts b/src/tree-select/styles/light.ts new file mode 100644 index 00000000000..90c88e9b49d --- /dev/null +++ b/src/tree-select/styles/light.ts @@ -0,0 +1,33 @@ +import { commonLight } from '../../_styles/common' +import type { ThemeCommonVars } from '../../_styles/common' +import { createTheme } from '../../_mixins/use-theme' +import { treeLight } from '../../tree/styles' +import { emptyLight } from '../../empty/styles' +import { internalSelectionLight } from '../../_internal/selection/styles' + +export const self = (vars: ThemeCommonVars) => { + const { popoverColor, boxShadow2, borderRadius, heightMedium } = vars + return { + menuPadding: '4px', + menuColor: popoverColor, + menuBoxShadow: boxShadow2, + menuBorderRadius: borderRadius, + menuHeight: `calc(${heightMedium} * 7.6)` + } +} + +export type TreeSelectThemeVars = ReturnType + +const treeSelectLight = createTheme({ + name: 'TreeSelect', + common: commonLight, + peers: { + Tree: treeLight, + Empty: emptyLight, + InternalSelection: internalSelectionLight + }, + self +}) + +export default treeSelectLight +export type TreeSelectTheme = typeof treeSelectLight diff --git a/src/tree-select/tests/TreeSelect.spec.ts b/src/tree-select/tests/TreeSelect.spec.ts new file mode 100644 index 00000000000..93db6dcede9 --- /dev/null +++ b/src/tree-select/tests/TreeSelect.spec.ts @@ -0,0 +1,32 @@ +import { mount } from '@vue/test-utils' +import { NTreeSelect, TreeSelectOption } from '../index' + +describe('n-tree-select', () => { + it('should work with import on demand', () => { + mount(NTreeSelect) + }) + it('should accept proper options', () => { + mount(NTreeSelect, { + props: { + options: [ + { + label: '1', + key: '1' + } + ] + } + }) + const options: TreeSelectOption[] = [ + { + label: '1', + key: '1', + gogogo: '12' + } + ] + mount(NTreeSelect, { + props: { + options + } + }) + }) +}) diff --git a/src/tree/demos/enUS/index.demo-entry.md b/src/tree/demos/enUS/index.demo-entry.md index bebb235623e..8a8128f8151 100644 --- a/src/tree/demos/enUS/index.demo-entry.md +++ b/src/tree/demos/enUS/index.demo-entry.md @@ -17,7 +17,9 @@ async disabled ``` -## Props +## API + +### Tree Props | Name | Type | default | Description | | --- | --- | --- | --- | @@ -28,7 +30,7 @@ disabled | cascade | `boolean` | `false` | Whether to cascade checkboxes. | | checkable | `boolean` | `false` | | | checked-keys | `Array` | `undefined` | If set, checked status will work in controlled manner. | -| data | `Array` | `[]` | The node data of the tree. Reset `data` will cause clearing of some uncontrolled status. If you need to modify data, you'd better make tree work in a controlled manner. | +| data | `Array` | `[]` | The node data of the tree. Reset `data` will cause clearing of some uncontrolled status. If you need to modify data, you'd better make tree work in a controlled manner. | | default-checked-keys | `Array` | `[]` | | | default-expand-all | `boolean` | `false` | | | default-expanded-keys | `Array` | `[]` | | @@ -36,32 +38,30 @@ disabled | draggable | `boolean` | `false` | | | expand-on-dragenter | `boolean` | `true` | Whether to expand nodes after dragenter. | | expanded-keys | `Array` | `undefined` | If set, expanded status will work in controlled manner. | -| filter | `(node: TreeNode) => boolean` | A simple string based filter | | +| filter | `(node: TreeOption) => boolean` | A simple string based filter | | | multiple | `boolean` | `false` | | -| on-load | `(node: TreeNode) => Promise` | `undefined` | | +| on-load | `(node: TreeOption) => Promise` | `undefined` | | | pattern | `string` | `''` | | | remote | `boolean` | `false` | Whether to load nodes async. It should work with `on-load` | | selectable | `boolean` | `true` | | | selected-keys | `Array` | `undefined` | If set, selected status will work in controlled manner. | | virtual-scroll | `boolean` | `false` | Whether to enable virtual scroll. You need to set proper style height of the tree in advance. | -| on-dragend | `(data: { node: TreeNode, event: DragEvent }) => void` | `undefined` | | -| on-dragenter | `(data: { node: TreeNode, event: DragEvent }) => void` | `undefined` | | -| on-dragleave | `(data: { node: TreeNode, event: DragEvent }) => void` | `undefined` | | -| on-dragstart | `(data: { node: TreeNode, event: DragEvent }) => void` | `undefined` | | -| on-drop | `(data: { node: TreeNode, dragNode: TreeNode, dropPosition: 'before' \| 'inside' \| 'after', event: DragEvent }) => void` | `undefined` | | +| on-dragend | `(data: { node: TreeOption, event: DragEvent }) => void` | `undefined` | | +| on-dragenter | `(data: { node: TreeOption, event: DragEvent }) => void` | `undefined` | | +| on-dragleave | `(data: { node: TreeOption, event: DragEvent }) => void` | `undefined` | | +| on-dragstart | `(data: { node: TreeOption, event: DragEvent }) => void` | `undefined` | | +| on-drop | `(data: { node: TreeOption, dragNode: TreeOption, dropPosition: 'before' \| 'inside' \| 'after', event: DragEvent }) => void` | `undefined` | | | on-update:checked-keys | `(keys: Array) => void` | `undefined` | | | on-update:expanded-keys | `(keys: Array) => void` | `undefined` | | | on-update:selected-keys | `(keys: Array) => void` | `undefined` | | -## API - -### TreeNode Properties +### TreeOption Properties | Name | Type | Description | | --- | --- | --- | | key | `string \| number` | Key of the node, should be unique. | | label | `string` | Label of the node. | | checkboxDisabled? | `boolean` | Whether the checkbox is disabled. | -| children? | `TreeNode[]` | Child nodes of the node. | +| children? | `TreeOption[]` | Child nodes of the node. | | disabled? | `boolean` | Whether the node is disabled. | | isLeaf? | `boolean` | Whether the node is leaf. Required in remote mode. | diff --git a/src/tree/demos/zhCN/index.demo-entry.md b/src/tree/demos/zhCN/index.demo-entry.md index 61b03472596..2fdc63d3816 100644 --- a/src/tree/demos/zhCN/index.demo-entry.md +++ b/src/tree/demos/zhCN/index.demo-entry.md @@ -17,7 +17,9 @@ async disabled ``` -## Props +## API + +### Tree Props | 名称 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | @@ -28,7 +30,7 @@ disabled | cascade | `boolean` | `false` | 是否关联选项 | | checkable | `boolean` | `false` | | | checked-keys | `Array` | `undefined` | 如果设定则 checked 状态受控 | -| data | `Array` | `[]` | 树的节点数据。重新设置 data 会将一些非受控状态清空,如果你需要在使用中改动 data,最好以受控的方式控制树 | +| data | `Array` | `[]` | 树的节点数据。重新设置 data 会将一些非受控状态清空,如果你需要在使用中改动 data,最好以受控的方式控制树 | | default-checked-keys | `Array` | `[]` | | | default-expand-all | `boolean` | `false` | | | default-expanded-keys | `Array` | `[]` | | @@ -36,32 +38,30 @@ disabled | draggable | `boolean` | `false` | | | expand-on-dragenter | `boolean` | `true` | 是否在拖入后展开节点 | | expanded-keys | `Array` | `undefined` | 如果设定则展开受控 | -| filter | `(node: TreeNode) => boolean` | 一个简单的字符串过滤算法 | | +| filter | `(node: TreeOption) => boolean` | 一个简单的字符串过滤算法 | | | multiple | `boolean` | `false` | | -| on-load | `(node: TreeNode) => Promise` | `undefined` | | +| on-load | `(node: TreeOption) => Promise` | `undefined` | | | pattern | `string` | `''` | | | remote | `boolean` | `false` | 是否异步获取选项,和 onLoad 配合 | | selectable | `boolean` | `true` | | | selected-keys | `Array` | `undefined` | 如果设定则 selected 状态受控 | | virtual-scroll | `boolean` | `false` | 是否启用虚拟滚动,启用前你需要设定好树的高度样式 | -| on-dragend | `(data: { node: TreeNode, event: DragEvent }) => void` | `undefined` | | -| on-dragenter | `(data: { node: TreeNode, event: DragEvent }) => void` | `undefined` | | -| on-dragleave | `(data: { node: TreeNode, event: DragEvent }) => void` | `undefined` | | -| on-dragstart | `(data: { node: TreeNode, event: DragEvent }) => void` | `undefined` | | -| on-drop | `(data: { node: TreeNode, dragNode: TreeNode, dropPosition: 'before' \| 'inside' \| 'after', event: DragEvent }) => void` | `undefined` | | +| on-dragend | `(data: { node: TreeOption, event: DragEvent }) => void` | `undefined` | | +| on-dragenter | `(data: { node: TreeOption, event: DragEvent }) => void` | `undefined` | | +| on-dragleave | `(data: { node: TreeOption, event: DragEvent }) => void` | `undefined` | | +| on-dragstart | `(data: { node: TreeOption, event: DragEvent }) => void` | `undefined` | | +| on-drop | `(data: { node: TreeOption, dragNode: TreeOption, dropPosition: 'before' \| 'inside' \| 'after', event: DragEvent }) => void` | `undefined` | | | on-update:checked-keys | `(keys: Array) => void` | `undefined` | | | on-update:expanded-keys | `(keys: Array) => void` | `undefined` | | | on-update:selected-keys | `(keys: Array) => void` | `undefined` | | -## API - -### TreeNode Properties +### TreeOption Properties | 名称 | 类型 | 说明 | | --- | --- | --- | | key | `string \| number` | 节点的 key,需要唯一 | | label | `string` | 节点的内容 | | checkboxDisabled? | `boolean` | 是否禁用节点的 checkbox | -| children? | `TreeNode[]` | 节点的子节点 | +| children? | `TreeOption[]` | 节点的子节点 | | disabled? | `boolean` | 是否禁用节点 | | isLeaf? | `boolean` | 节点是否是叶节点,在 remote 模式下是必须的 | diff --git a/src/tree/index.ts b/src/tree/index.ts index 525c3f8b527..8435fb85086 100644 --- a/src/tree/index.ts +++ b/src/tree/index.ts @@ -1,2 +1,3 @@ export { default as NTree } from './src/Tree' export type { TreeProps } from './src/Tree' +export type { TreeOption } from './src/interface' diff --git a/src/tree/src/MotionWrapper.tsx b/src/tree/src/MotionWrapper.tsx index 314ae22741a..d79cdab7f0b 100644 --- a/src/tree/src/MotionWrapper.tsx +++ b/src/tree/src/MotionWrapper.tsx @@ -1,4 +1,5 @@ import { h, defineComponent, PropType } from 'vue' +import { pxfy } from 'seemly' import FadeInExpandTransition from '../../_internal/fade-in-expand-transition' import { TmNode } from './interface' import TreeNode from './TreeNode' @@ -40,7 +41,7 @@ export default defineComponent({ `${clsPrefix}-tree-motion-wrapper--${this.mode}` ]} style={{ - height: this.height + height: pxfy(this.height) }} > {this.nodes.map((node) => ( diff --git a/src/tree/src/Tree.tsx b/src/tree/src/Tree.tsx index 9326822b6b3..e2ac6a1063c 100644 --- a/src/tree/src/Tree.tsx +++ b/src/tree/src/Tree.tsx @@ -9,21 +9,24 @@ import { PropType, watch, CSSProperties, - VNode + VNode, + nextTick } from 'vue' -import { createTreeMate, flatten, createIndexGetter } from 'treemate' +import { createTreeMate, flatten, createIndexGetter, TreeMate } from 'treemate' import { useMergedState } from 'vooks' import { VirtualListInst, VVirtualList } from 'vueuc' +import { getPadding } from 'seemly' import { useConfig, useTheme } from '../../_mixins' import type { ThemeProps } from '../../_mixins' -import { call, warn } from '../../_utils' +import { call, createDataKey, warn } from '../../_utils' import type { ExtractPublicPropTypes, MaybeArray } from '../../_utils' -import { NScrollbar, ScrollbarInst } from '../../scrollbar' +import { NxScrollbar } from '../../scrollbar' +import type { ScrollbarInst } from '../../scrollbar' import { treeLight } from '../styles' import type { TreeTheme } from '../styles' import NTreeNode from './TreeNode' -import { keysWithFilter, emptyImage } from './utils' -import style from './styles/index.cssr' +import { keysWithFilter, emptyImage, defaultFilter } from './utils' +import { useKeyboard } from './keyboard' import { DragInfo, DropInfo, @@ -36,16 +39,43 @@ import { DropPosition, AllowDrop, MotionData, - treeInjectionKey + treeInjectionKey, + InternalTreeInst } from './interface' import MotionWrapper from './MotionWrapper' import { defaultAllowDrop } from './dnd' +import style from './styles/index.cssr' // TODO: // During expanding, some node are mis-applied with :active style // Async dnd has bug -const ITEM_SIZE = 30 +const ITEM_SIZE = 30 // 24 + 3 + 3 + +export const treeMateOptions = { + getDisabled (node: TreeOption) { + return !!(node.disabled || node.checkboxDisabled) + } +} + +export const treeSharedProps = { + filter: { + type: Function as PropType<(pattern: string, node: TreeOption) => boolean>, + default: defaultFilter + }, + defaultExpandAll: Boolean, + expandedKeys: Array as PropType, + defaultExpandedKeys: { + type: Array as PropType, + default: () => [] + }, + onUpdateExpandedKeys: [Function, Array] as PropType< + MaybeArray<(value: Key[]) => void> + >, + 'onUpdate:expandedKeys': [Function, Array] as PropType< + MaybeArray<(value: Key[]) => void> + > +} as const const treeProps = { ...(useTheme.props as ThemeProps), @@ -53,7 +83,6 @@ const treeProps = { type: Array as PropType, default: () => [] }, - defaultExpandAll: Boolean, expandOnDragenter: { type: Boolean, default: true @@ -72,11 +101,6 @@ const treeProps = { type: Array as PropType, default: () => [] }, - expandedKeys: Array as PropType, - defaultExpandedKeys: { - type: Array as PropType, - default: () => [] - }, selectedKeys: Array as PropType, defaultSelectedKeys: { type: Array as PropType, @@ -88,13 +112,6 @@ const treeProps = { type: String, default: '' }, - filter: { - type: Function as PropType<(pattern: string, node: TreeOption) => boolean>, - default: (pattern: string, node: TreeOption) => { - if (!pattern) return true - return ~node.label.toLowerCase().indexOf(pattern.toLowerCase()) - } - }, onLoad: Function as PropType<(node: TreeOption) => Promise>, cascade: Boolean, selectable: { @@ -109,6 +126,10 @@ const treeProps = { type: Function as PropType, default: defaultAllowDrop }, + animated: { + type: Boolean, + default: true + }, virtualScroll: Boolean, onDragenter: [Function, Array] as PropType void>>, onDragleave: [Function, Array] as PropType void>>, @@ -116,60 +137,37 @@ const treeProps = { onDragstart: [Function, Array] as PropType void>>, onDragover: [Function, Array] as PropType void>>, onDrop: [Function, Array] as PropType void>>, - // eslint-disable-next-line vue/prop-name-casing - 'onUpdate:expandedKeys': [Function, Array] as PropType< + onUpdateCheckedKeys: [Function, Array] as PropType< MaybeArray<(value: Key[]) => void> >, - // eslint-disable-next-line vue/prop-name-casing 'onUpdate:checkedKeys': [Function, Array] as PropType< MaybeArray<(value: Key[]) => void> >, - // eslint-disable-next-line vue/prop-name-casing + onUpdateSelectedKeys: [Function, Array] as PropType< + MaybeArray<(value: Key[]) => void> + >, 'onUpdate:selectedKeys': [Function, Array] as PropType< MaybeArray<(value: Key[]) => void> >, - // deprecated - /** @deprecated */ - onExpandedKeysChange: { - type: [Function, Array] as PropType< - MaybeArray<(value: Key[]) => void> | undefined - >, - validator: () => { - warn( - 'tree', - '`on-expanded-keys-change` is deprecated, please use `on-update:expanded-keys` instead.' - ) - return true - }, - default: undefined - }, - /** @deprecated */ - onCheckedKeysChange: { - type: [Function, Array] as PropType< - MaybeArray<(value: Key[]) => void> | undefined - >, - validator: () => { - warn( - 'tree', - '`on-checked-keys-change` is deprecated, please use `on-update:expanded-keys` instead.' - ) - return true - }, - default: undefined + ...treeSharedProps, + // internal props for tree-select + internalScrollable: Boolean, + internalScrollablePadding: String, + // use it to do check + internalDataTreeMate: Object as PropType>, + // use it to display + internalDisplayTreeMate: Object as PropType>, + internalHighlightKeySet: Object as PropType>, + internalCheckOnSelect: Boolean, + internalHideFilteredNode: Boolean, // I'm sure this won't work with draggable + internalCheckboxFocusable: { + type: Boolean, + default: true }, - /** @deprecated */ - onSelectedKeysChange: { - type: [Function, Array] as PropType< - MaybeArray<(value: Key[]) => void> | undefined - >, - validator: () => { - warn( - 'tree', - '`on-selected-keys-change` is deprecated, please use `on-update:selected-keys` instead.' - ) - return true - }, - default: undefined + internalFocusable: { + // Make tree-select take over keyboard operations + type: Boolean, + default: true } } as const @@ -197,13 +195,13 @@ export default defineComponent({ function getScrollContent (): HTMLElement | null | undefined { return virtualListInstRef.value?.itemsElRef } - const treeMateRef = computed(() => - createTreeMate(props.data, { - getDisabled (node) { - return !!(node.disabled || node.checkboxDisabled) - } - }) - ) + // We don't expect data source to change so we just determine it once + const displayTreeMateRef = props.internalDisplayTreeMate + ? toRef(props, 'internalDisplayTreeMate') + : computed(() => createTreeMate(props.data, treeMateOptions)) + const dataTreeMateRef = props.internalDataTreeMate + ? toRef(props, 'internalDataTreeMate') + : displayTreeMateRef const uncontrolledCheckedKeysRef = ref( props.defaultCheckedKeys || props.checkedKeys ) @@ -213,7 +211,7 @@ export default defineComponent({ uncontrolledCheckedKeysRef ) const checkedStatusRef = computed(() => { - return treeMateRef.value.getCheckedKeys(mergedCheckedKeysRef.value, { + return dataTreeMateRef.value!.getCheckedKeys(mergedCheckedKeysRef.value, { cascade: props.cascade }) }) @@ -233,8 +231,8 @@ export default defineComponent({ ) const uncontrolledExpandedKeysRef = ref( props.defaultExpandAll - ? treeMateRef.value.getNonLeafKeys() - : props.defaultExpandedKeys || props.expandedKeys + ? dataTreeMateRef.value!.getNonLeafKeys() + : props.defaultExpandedKeys ) const controlledExpandedKeysRef = toRef(props, 'expandedKeys') const mergedExpandedKeysRef = useMergedState( @@ -243,12 +241,25 @@ export default defineComponent({ ) const fNodesRef = computed(() => - treeMateRef.value.getFlattenedNodes(mergedExpandedKeysRef.value) + displayTreeMateRef.value!.getFlattenedNodes(mergedExpandedKeysRef.value) ) + const { pendingNodeKeyRef, handleKeyup, handleKeydown } = useKeyboard({ + mergedSelectedKeysRef, + fNodesRef, + mergedExpandedKeysRef, + handleSelect, + handleSwitcherClick + }) + let expandTimerId: number | null = null let nodeKeyToBeExpanded: Key | null = null - const highlightKeysRef = ref([]) + const uncontrolledHighlightKeySetRef = ref>(new Set()) + const controlledHighlightKeySetRef = toRef(props, 'internalHighlightKeySet') + const mergedHighlightKeySetRef = useMergedState( + controlledHighlightKeySetRef, + uncontrolledHighlightKeySetRef + ) const loadingKeysRef = ref([]) let dragStartX: number = 0 @@ -272,6 +283,7 @@ export default defineComponent({ toRef(props, 'data'), () => { loadingKeysRef.value = [] + pendingNodeKeyRef.value = null resetDndState() }, { @@ -280,21 +292,29 @@ export default defineComponent({ ) watch(toRef(props, 'pattern'), (value) => { if (value) { - const [expandedKeysAfterChange, highlightKeys] = keysWithFilter( - props.data, - props.pattern, - props.filter - ) - highlightKeysRef.value = highlightKeys - doExpandedKeysChange(expandedKeysAfterChange) + const { expandedKeys: expandedKeysAfterChange, highlightKeySet } = + keysWithFilter(props.data, props.pattern, props.filter) + uncontrolledHighlightKeySetRef.value = highlightKeySet + doUpdateExpandedKeys(expandedKeysAfterChange) } else { - highlightKeysRef.value = [] + uncontrolledHighlightKeySetRef.value = new Set() } }) - const aipRef = ref(false) // animation in progress - const afNodeRef = ref>([]) // animation flattened nodes + // animation in progress + const aipRef = ref(false) + // animation flattened nodes + const afNodeRef = ref>([]) + // Note: Since the virtual list depends on min height, if there's a node + // whose height starts from 0, the virtual list will have a wrong height + // during animation. This will seldom cause wired scrollbar status. It is + // fixable and need some changes in vueuc, I've no time so I just leave it + // here. Maybe the bug won't be fixed during the life time of the project. watch(mergedExpandedKeysRef, (value, prevValue) => { + if (!props.animated) { + void nextTick(syncScrollbar) + return + } const prevVSet = new Set(prevValue) let addedKey: Key | null = null let removedKey: Key | null = null @@ -327,7 +347,7 @@ export default defineComponent({ if (addedKey !== null) { // play add animation aipRef.value = true - afNodeRef.value = treeMateRef.value.getFlattenedNodes(prevValue) + afNodeRef.value = displayTreeMateRef.value!.getFlattenedNodes(prevValue) const expandedNodeIndex = afNodeRef.value.findIndex( (node) => (node as any).key === addedKey ) @@ -352,7 +372,7 @@ export default defineComponent({ if (removedKey !== null) { // play remove animation aipRef.value = true - afNodeRef.value = treeMateRef.value.getFlattenedNodes(value) + afNodeRef.value = displayTreeMateRef.value!.getFlattenedNodes(value) const collapsedNodeIndex = afNodeRef.value.findIndex( (node) => (node as any).key === removedKey ) @@ -385,36 +405,47 @@ export default defineComponent({ else return fNodesRef.value }) + function syncScrollbar (): void { + const { value: scrollbarInst } = scrollbarInstRef + if (scrollbarInst) scrollbarInst.sync() + } + function handleAfterEnter (): void { aipRef.value = false + if (props.virtualScroll) { + // If virtual scroll, we won't listen to resize during animation, so + // resize callback of virtual list won't be called and as a result + // scrollbar won't sync. We need to sync scrollbar manually. + void nextTick(syncScrollbar) + } } - function doExpandedKeysChange (value: Key[]): void { + function doUpdateExpandedKeys (value: Key[]): void { const { - 'onUpdate:expandedKeys': onUpdateExpandedKeys, - onExpandedKeysChange + 'onUpdate:expandedKeys': _onUpdateExpandedKeys, + onUpdateExpandedKeys } = props uncontrolledExpandedKeysRef.value = value + if (_onUpdateExpandedKeys) call(_onUpdateExpandedKeys, value) if (onUpdateExpandedKeys) call(onUpdateExpandedKeys, value) - if (onExpandedKeysChange) call(onExpandedKeysChange, value) } - function doCheckedKeysChange (value: Key[]): void { + function doUpdateCheckedKeys (value: Key[]): void { const { - 'onUpdate:checkedKeys': onUpdateCheckedKeys, - onCheckedKeysChange + 'onUpdate:checkedKeys': _onUpdateCheckedKeys, + onUpdateCheckedKeys } = props uncontrolledCheckedKeysRef.value = value if (onUpdateCheckedKeys) call(onUpdateCheckedKeys, value) - if (onCheckedKeysChange) call(onCheckedKeysChange, value) + if (_onUpdateCheckedKeys) call(_onUpdateCheckedKeys, value) } function doUpdateSelectedKeys (value: Key[]): void { const { - 'onUpdate:selectedKeys': onUpdateSelectedKeys, - onSelectedKeysChange + 'onUpdate:selectedKeys': _onUpdateSelectedKeys, + onUpdateSelectedKeys } = props uncontrolledSelectedKeysRef.value = value if (onUpdateSelectedKeys) call(onUpdateSelectedKeys, value) - if (onSelectedKeysChange) call(onSelectedKeysChange, value) + if (_onUpdateSelectedKeys) call(_onUpdateSelectedKeys, value) } // Drag & Drop function doDragEnter (info: DragInfo): void { @@ -464,37 +495,48 @@ export default defineComponent({ } function handleCheck (node: TmNode, checked: boolean): void { if (props.disabled || node.disabled) return - const { checkedKeys } = treeMateRef.value[checked ? 'check' : 'uncheck']( - node.key, - displayedCheckedKeysRef.value, - { - cascade: props.cascade - } - ) - doCheckedKeysChange(checkedKeys) + const { checkedKeys } = dataTreeMateRef.value![ + checked ? 'check' : 'uncheck' + ](node.key, displayedCheckedKeysRef.value, { + cascade: props.cascade + }) + doUpdateCheckedKeys(checkedKeys) } - function toggleExpand (node: TmNode): void { + function toggleExpand (key: Key): void { if (props.disabled) return const { value: mergedExpandedKeys } = mergedExpandedKeysRef const index = mergedExpandedKeys.findIndex( - (expandNodeId) => expandNodeId === node.key + (expandNodeId) => expandNodeId === key ) if (~index) { const expandedKeysAfterChange = Array.from(mergedExpandedKeys) expandedKeysAfterChange.splice(index, 1) - doExpandedKeysChange(expandedKeysAfterChange) + doUpdateExpandedKeys(expandedKeysAfterChange) } else { - doExpandedKeysChange(mergedExpandedKeys.concat(node.key)) + doUpdateExpandedKeys(mergedExpandedKeys.concat(key)) } } function handleSwitcherClick (node: TmNode): void { if (props.disabled || aipRef.value) return - toggleExpand(node) + toggleExpand(node.key) } function handleSelect (node: TmNode): void { if (props.disabled || node.disabled || !props.selectable) return + pendingNodeKeyRef.value = node.key + if (props.internalCheckOnSelect) { + const { + value: { checkedKeys, indeterminateKeys } + } = checkedStatusRef + handleCheck( + node, + !( + checkedKeys.includes(node.key) || + indeterminateKeys.includes(node.key) + ) + ) + } if (props.multiple) { - const selectedKeys = mergedSelectedKeysRef.value + const selectedKeys = Array.from(mergedSelectedKeysRef.value) const index = selectedKeys.findIndex((key) => key === node.key) if (~index) { if (props.cancelable) { @@ -532,7 +574,7 @@ export default defineComponent({ droppingMouseNode.key === node.key && !mergedExpandedKeysRef.value.includes(node.key) ) { - doExpandedKeysChange(mergedExpandedKeysRef.value.concat(node.key)) + doUpdateExpandedKeys(mergedExpandedKeysRef.value.concat(node.key)) } expandTimerId = null nodeKeyToBeExpanded = null @@ -856,14 +898,43 @@ export default defineComponent({ resetDndState() } function handleScroll (): void { - scrollbarInstRef.value?.sync() + syncScrollbar() } function handleResize (): void { - scrollbarInstRef.value?.sync() + syncScrollbar() } + function handleFocusout (e: FocusEvent): void { + if (props.virtualScroll || props.internalScrollable) { + const { value: scrollbarInst } = scrollbarInstRef + if (scrollbarInst?.containerRef?.contains(e.relatedTarget as Element)) { + return + } + pendingNodeKeyRef.value = null + } else { + const { value: selfEl } = selfElRef + if (selfEl?.contains(e.relatedTarget as Element)) return + pendingNodeKeyRef.value = null + } + } + watch(pendingNodeKeyRef, (value) => { + if (value === null) return + if (props.virtualScroll) { + virtualListInstRef.value?.scrollTo({ key: value }) + } else if (props.internalScrollable) { + const { value: scrollbarInst } = scrollbarInstRef + if (scrollbarInst === null) return + const targetEl = scrollbarInst.contentRef?.querySelector( + `[data-key="${createDataKey(value)}"]` + ) + if (!targetEl) return + scrollbarInst.scrollTo({ + el: targetEl as any + }) + } + }) provide(treeInjectionKey, { loadingKeysRef, - highlightKeysRef, + highlightKeySetRef: mergedHighlightKeySetRef, displayedCheckedKeysRef, displayedIndeterminateKeysRef, mergedSelectedKeysRef, @@ -882,6 +953,9 @@ export default defineComponent({ droppingPositionRef, droppingOffsetLevelRef, fNodesRef, + pendingNodeKeyRef, + internalScrollableRef: toRef(props, 'internalScrollable'), + internalCheckboxFocusableRef: toRef(props, 'internalCheckboxFocusable'), handleSwitcherClick, handleDragEnd, handleDragEnter, @@ -892,6 +966,10 @@ export default defineComponent({ handleSelect, handleCheck }) + const exposedMethods: InternalTreeInst = { + handleKeydown, + handleKeyup + } return { mergedClsPrefix: mergedClsPrefixRef, mergedTheme: themeRef, @@ -900,6 +978,7 @@ export default defineComponent({ selfElRef, virtualListInstRef, scrollbarInstRef, + handleFocusout, handleDragLeaveTree, handleScroll, getScrollContainer, @@ -935,12 +1014,25 @@ export default defineComponent({ '--node-text-color-disabled': nodeTextColorDisabled, '--drop-mark-color': dropMarkColor } - }) + }), + ...exposedMethods } }, render () { - const { mergedClsPrefix, blockNode, blockLine, draggable, selectable } = - this + const { + mergedClsPrefix, + blockNode, + blockLine, + draggable, + selectable, + disabled, + internalFocusable, + handleKeyup, + handleKeydown, + handleFocusout + } = this + const mergedFocusable = internalFocusable && !disabled + const tabindex = mergedFocusable ? '0' : undefined const treeClass = [ `${mergedClsPrefix}-tree`, (blockLine || blockNode) && `${mergedClsPrefix}-tree--block-node`, @@ -964,9 +1056,10 @@ export default defineComponent({ /> ) if (this.virtualScroll) { - const { mergedTheme } = this + const { mergedTheme, internalScrollablePadding } = this + const padding = getPadding(internalScrollablePadding || '0') return ( - {{ default: () => ( @@ -982,7 +1079,15 @@ export default defineComponent({ items={this.fNodes} itemSize={ITEM_SIZE} ignoreItemResize={this.aip} - style={this.cssVars as CSSProperties} + paddingTop={padding.top} + paddingBottom={padding.bottom} + style={[ + this.cssVars as CSSProperties, + { + paddingLeft: padding.left, + paddingRight: padding.right + } + ]} onScroll={this.handleScroll} onResize={this.handleResize} showScrollbar={false} @@ -995,18 +1100,48 @@ export default defineComponent({ ) }} - + + ) + } + const { internalScrollable } = this + if (internalScrollable) { + return ( + + {{ + default: () => ( +
+ {this.fNodes.map(createNode)} +
+ ) + }} +
+ ) + } else { + return ( +
+ {this.fNodes.map(createNode)} +
) } - return ( -
- {this.fNodes.map(createNode)} -
- ) } }) diff --git a/src/tree/src/TreeNode.tsx b/src/tree/src/TreeNode.tsx index a05f5bfabcd..e02151d55b2 100644 --- a/src/tree/src/TreeNode.tsx +++ b/src/tree/src/TreeNode.tsx @@ -11,6 +11,7 @@ import { } from 'vue' import { useMemo } from 'vooks' import { happensIn, repeat } from 'seemly' +import { createDataKey } from '../../_utils' import NTreeNodeSwitcher from './TreeNodeSwitcher' import NTreeNodeCheckbox from './TreeNodeCheckbox' import NTreeNodeContent from './TreeNodeContent' @@ -151,11 +152,14 @@ const TreeNode = defineComponent({ } return false }), + pending: useMemo( + () => NTree.pendingNodeKeyRef.value === props.tmNode.key + ), loading: useMemo(() => NTree.loadingKeysRef.value.includes(props.tmNode.key) ), highlight: useMemo(() => - NTree.highlightKeysRef.value.includes(props.tmNode.key) + NTree.highlightKeySetRef.value.has(props.tmNode.key) ), checked: useMemo(() => NTree.displayedCheckedKeysRef.value.includes(props.tmNode.key) @@ -174,9 +178,11 @@ const TreeNode = defineComponent({ () => NTree.disabledRef.value || props.tmNode.disabled ), checkboxDisabled: computed(() => !!props.tmNode.rawNode.checkboxDisabled), + internalScrollable: NTree.internalScrollableRef, checkable: NTree.checkableRef, draggable: NTree.draggableRef, blockLine: NTree.blockLineRef, + checkboxFocusable: NTree.internalCheckboxFocusableRef, droppingPosition: droppingPositionRef, droppingOffsetLevel: droppingOffsetLevelRef, indent: indentRef, @@ -204,7 +210,9 @@ const TreeNode = defineComponent({ blockLine, indent, disabled, - suffix + pending, + suffix, + internalScrollable } = this // drag start not inside // it need to be append to node itself, not wrapper @@ -218,6 +226,9 @@ const TreeNode = defineComponent({ onDragover: this.handleDragOver } : undefined + // In non virtual mode, there's no evidence that which element should be + // scrolled to, so we need data-key to query the target element. + const dataKey = internalScrollable ? createDataKey(tmNode.key) : undefined return (
{checkable ? ( VNodeChild } +export type TreeOption = TreeOptionBase & { [k: string]: unknown } + export type TreeOptions = TreeOption[] export interface DragInfo { @@ -50,7 +52,7 @@ export interface InternalDropInfo { export interface TreeInjection { loadingKeysRef: Ref - highlightKeysRef: Ref + highlightKeySetRef: Ref> displayedCheckedKeysRef: Ref displayedIndeterminateKeysRef: Ref mergedSelectedKeysRef: Ref @@ -69,6 +71,9 @@ export interface TreeInjection { droppingPositionRef: Ref droppingOffsetLevelRef: Ref disabledRef: Ref + pendingNodeKeyRef: Ref + internalScrollableRef: Ref + internalCheckboxFocusableRef: Ref handleSwitcherClick: (node: TreeNode) => void handleSelect: (node: TreeNode) => void handleCheck: (node: TreeNode, checked: boolean) => void @@ -90,3 +95,8 @@ export interface MotionData { mode: 'expand' | 'collapse' nodes: TmNode[] } + +export interface InternalTreeInst { + handleKeyup: (e: KeyboardEvent) => void + handleKeydown: (e: KeyboardEvent) => void +} diff --git a/src/tree/src/keyboard.tsx b/src/tree/src/keyboard.tsx new file mode 100644 index 00000000000..75666c453a9 --- /dev/null +++ b/src/tree/src/keyboard.tsx @@ -0,0 +1,120 @@ +import { inject, ref, Ref } from 'vue' +import { TreeNode } from 'treemate' +import { Key, TmNode, TreeOption } from './interface' +import { treeSelectInjectionKey } from '../../tree-select/src/interface' + +export function useKeyboard ({ + fNodesRef, + mergedExpandedKeysRef, + mergedSelectedKeysRef, + handleSelect, + handleSwitcherClick +}: { + fNodesRef: Ref>> + mergedExpandedKeysRef: Ref + mergedSelectedKeysRef: Ref + handleSelect: (node: TmNode) => void + handleSwitcherClick: (node: TmNode) => void +}): { + pendingNodeKeyRef: Ref + handleKeyup: (e: KeyboardEvent) => void + handleKeydown: (e: KeyboardEvent) => void + } { + const { value: mergedSelectedKeys } = mergedSelectedKeysRef + + // If it's used in tree-select, make it take over pending state + const treeSelectInjection = inject(treeSelectInjectionKey, null) + const pendingNodeKeyRef = treeSelectInjection + ? treeSelectInjection.pendingNodeKeyRef + : ref( + mergedSelectedKeys.length + ? mergedSelectedKeys[mergedSelectedKeys.length - 1] + : null + ) + function handleKeyup (e: KeyboardEvent): void { + const { value: pendingNodeKey } = pendingNodeKeyRef + if (pendingNodeKey === null) { + if ( + ['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight'].includes(e.code) + ) { + if (pendingNodeKey === null) { + const { value: fNodes } = fNodesRef + let fIndex = 0 + while (fIndex < fNodes.length) { + if (!fNodes[fIndex].disabled) { + pendingNodeKeyRef.value = fNodes[fIndex].key + break + } + fIndex += 1 + } + } + } + } else { + const { value: fNodes } = fNodesRef + let fIndex = fNodes.findIndex((tmNode) => tmNode.key === pendingNodeKey) + if (!~fIndex) return + if (e.code === 'Enter') { + handleSelect(fNodes[fIndex]) + } else if (e.code === 'ArrowDown') { + fIndex += 1 + while (fIndex < fNodes.length) { + if (!fNodes[fIndex].disabled) { + pendingNodeKeyRef.value = fNodes[fIndex].key + break + } + fIndex += 1 + } + } else if (e.code === 'ArrowUp') { + fIndex -= 1 + while (fIndex >= 0) { + if (!fNodes[fIndex].disabled) { + pendingNodeKeyRef.value = fNodes[fIndex].key + break + } + fIndex -= 1 + } + } else if (e.code === 'ArrowLeft') { + const pendingNode = fNodes[fIndex] + if ( + pendingNode.isLeaf || + !mergedExpandedKeysRef.value.includes(pendingNodeKey) + ) { + const parentTmNode = pendingNode.getParent() + if (parentTmNode) { + pendingNodeKeyRef.value = parentTmNode.key + } + } else { + handleSwitcherClick(pendingNode) + } + } else if (e.code === 'ArrowRight') { + const pendingNode = fNodes[fIndex] + if (pendingNode.isLeaf) return + if (!mergedExpandedKeysRef.value.includes(pendingNodeKey)) { + handleSwitcherClick(pendingNode) + } else { + // Tha same as ArrowDown + fIndex += 1 + while (fIndex < fNodes.length) { + if (!fNodes[fIndex].disabled) { + pendingNodeKeyRef.value = fNodes[fIndex].key + break + } + fIndex += 1 + } + } + } + } + } + function handleKeydown (e: KeyboardEvent): void { + switch (e.code) { + case 'ArrowUp': + case 'ArrowDown': + e.preventDefault() + } + } + return { + pendingNodeKeyRef, + handleKeyup, + handleKeydown + } +} diff --git a/src/tree/src/styles/index.cssr.ts b/src/tree/src/styles/index.cssr.ts index 9733077cc3a..f650f9919ae 100644 --- a/src/tree/src/styles/index.cssr.ts +++ b/src/tree/src/styles/index.cssr.ts @@ -21,9 +21,10 @@ const nodeStateStyle = [ // --node-color-pressed // --node-text-color // --node-text-color-disabled -export default cB('tree', { - fontSize: 'var(--font-size)' -}, [ +export default cB('tree', ` + font-size: var(--font-size); + outline: none; +`, [ c('ul, li', ` margin: 0; padding: 0; @@ -100,6 +101,11 @@ export default cB('tree', { cB('tree-node', [ cNotM('disabled', [ cB('tree-node-content', nodeStateStyle), + cM('pending', [ + cB('tree-node-content', ` + background-color: var(--node-color-hover); + `) + ]), cM('selected', [ cB('tree-node-content', { backgroundColor: 'var(--node-color-active)' @@ -112,6 +118,9 @@ export default cB('tree', { cB('tree-node', [ cNotM('disabled', [ nodeStateStyle, + cM('pending', ` + background-color: var(--node-color-hover); + `), cM('selected', { backgroundColor: 'var(--node-color-active)' }) @@ -172,11 +181,10 @@ export default cB('tree', { cB('tree-node-content', ` position: relative; display: inline-flex; - height: 24px; + align-items: center; + min-height: 24px; box-sizing: border-box; - border-bottom: 3px solid #0000; - border-top: 3px solid #0000; - line-height: 24px; + line-height: 1.5; align-items: center; vertical-align: bottom; padding: 0 6px; @@ -194,7 +202,6 @@ export default cB('tree', { marginBottom: 0 }), cE('text', ` - line-height: 1.25; border-bottom: 1px solid #0000; transition: border-color .3s var(--bezier); `) diff --git a/src/tree/src/utils.ts b/src/tree/src/utils.ts index c768f6b0a2f..90c78d36565 100644 --- a/src/tree/src/utils.ts +++ b/src/tree/src/utils.ts @@ -16,16 +16,19 @@ export function keysWithFilter ( nodes: TreeOption[], pattern: string, filter: (pattern: string, node: TreeOption) => boolean -): [Key[], Key[]] { +): { + expandedKeys: Key[] + highlightKeySet: Set + } { const keys = new Set() - const highlightKeys = new Set() + const highlightKeySet = new Set() const path: TreeOption[] = [] traverse( nodes, (node) => { path.push(node) if (filter(pattern, node)) { - highlightKeys.add(node.key) + highlightKeySet.add(node.key) for (let i = path.length - 2; i >= 0; --i) { if (!keys.has(path[i].key)) { keys.add(path[i].key) @@ -39,7 +42,10 @@ export function keysWithFilter ( path.pop() } ) - return [Array.from(keys), Array.from(highlightKeys)] + return { + expandedKeys: Array.from(keys), + highlightKeySet + } } const emptyImage: HTMLImageElement | null = null @@ -49,4 +55,9 @@ if (typeof window !== 'undefined') { 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==' } +export const defaultFilter = (pattern: string, node: TreeOption): boolean => { + if (!pattern.length) return true + return node.label.toLowerCase().includes(pattern.toLowerCase()) +} + export { emptyImage } diff --git a/src/tree/tests/Tree.spec.ts b/src/tree/tests/Tree.spec.ts index e3e85a88cab..eb9db5440aa 100644 --- a/src/tree/tests/Tree.spec.ts +++ b/src/tree/tests/Tree.spec.ts @@ -5,4 +5,33 @@ describe('n-tree', () => { it('should work with import on demand', () => { mount(NTree) }) + it('should accept proper options', () => { + mount(NTree, { + props: { + options: [ + { + label: '123', + key: '123', + children: [ + { + label: '123', + key: '123' + } + ] + } + ] + } + }) + mount(NTree, { + props: { + options: [ + { + label: '123', + key: '123', + unknown: 'unknown' + } + ] + } + }) + }) })