From dd83124262faf9572b0bdf3bf4d4ff892960482c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?w=C5=AB=20y=C4=81ng?= Date: Tue, 31 Oct 2023 23:04:19 +0800 Subject: [PATCH] fix(Select): optimize keyboard control behavior in virtual scroll mode (#3542) * fix(Select): optimize keyboard control behavior in virtual scroll mode * chore: fix if * chore: update snapshot --- src/_common | 2 +- src/select/_example/virtual-scroll.vue | 1 + src/select/hooks/useKeyboardControl.ts | 144 ++++++++++++++++++ src/select/select-panel.tsx | 8 +- src/select/select.tsx | 131 +++++----------- test/unit/snap/__snapshots__/csr.test.js.snap | 4 +- test/unit/snap/__snapshots__/ssr.test.js.snap | 2 +- 7 files changed, 190 insertions(+), 102 deletions(-) create mode 100644 src/select/hooks/useKeyboardControl.ts diff --git a/src/_common b/src/_common index 8d29d81998..a93f68dbfb 160000 --- a/src/_common +++ b/src/_common @@ -1 +1 @@ -Subproject commit 8d29d81998e3f2fb25fc9696507bffc20dfb9d56 +Subproject commit a93f68dbfb458bfca83bee1cd2216405209936d8 diff --git a/src/select/_example/virtual-scroll.vue b/src/select/_example/virtual-scroll.vue index 40ee6b56cc..1e79bf90a0 100644 --- a/src/select/_example/virtual-scroll.vue +++ b/src/select/_example/virtual-scroll.vue @@ -4,6 +4,7 @@ ; + optionsList: ComputedRef; + innerPopupVisible: Ref; + setInnerPopupVisible: ChangeHandler; + selectPanelRef: Ref<{ isVirtual: boolean; innerRef: HTMLDivElement }>; + isFilterable: ComputedRef; + getSelectedOptions: (selectValue?: SelectValue[] | SelectValue) => TdOptionProps[]; + setInnerValue: Function; + innerValue: Ref; + popupContentRef: ComputedRef; + multiple: boolean; + max: number; +}; + +// 统一处理键盘控制的hooks +export default function useKeyboardControl({ + displayOptions, + optionsList, + innerPopupVisible, + setInnerPopupVisible, + selectPanelRef, + isFilterable, + getSelectedOptions, + setInnerValue, + innerValue, + popupContentRef, + multiple, + max, +}: useKeyboardControlType) { + const hoverIndex = ref(-1); + const virtualFilteredOptions = ref([]); // 处理虚拟滚动下选项过滤通过键盘选择的问题 + const classPrefix = usePrefixClass(); + + const handleKeyDown = (e: KeyboardEvent) => { + const optionsListLength = displayOptions.value.length; + let newIndex = hoverIndex.value; + switch (e.code) { + case 'ArrowUp': + e.preventDefault(); + if (hoverIndex.value === -1) { + newIndex = 0; + } else if (hoverIndex.value === 0 || hoverIndex.value > displayOptions.value.length - 1) { + newIndex = optionsListLength - 1; + } else { + newIndex--; + } + if (optionsList.value[newIndex]?.disabled) { + newIndex--; + } + hoverIndex.value = newIndex; + break; + case 'ArrowDown': + e.preventDefault(); + if (hoverIndex.value === -1 || hoverIndex.value >= optionsListLength - 1) { + newIndex = 0; + } else { + newIndex++; + } + if (optionsList.value[newIndex]?.disabled) { + newIndex++; + } + hoverIndex.value = newIndex; + break; + case 'Enter': + if (hoverIndex.value === -1) break; + if (!innerPopupVisible.value) { + setInnerPopupVisible(true, { e }); + break; + } + const filteredOptions = + selectPanelRef.value.isVirtual && isFilterable.value && virtualFilteredOptions.value.length + ? virtualFilteredOptions.value + : optionsList.value; + + if (!multiple) { + const selectedOptions = getSelectedOptions(filteredOptions[hoverIndex.value].value); + setInnerValue(filteredOptions[hoverIndex.value].value, { + option: selectedOptions?.[0], + selectedOptions: getSelectedOptions(filteredOptions[hoverIndex.value].value), + trigger: 'check', + e, + }); + setInnerPopupVisible(false, { e }); + } else { + if (hoverIndex.value === -1) return; + const optionValue = filteredOptions[hoverIndex.value]?.value; + + if (!optionValue) return; + const newValue = getNewMultipleValue(innerValue.value, optionValue); + + if (newValue.value.length > max) return; // 如果已选达到最大值 则不处理 + const selectedOptions = getSelectedOptions(newValue.value); + setInnerValue(newValue.value, { + option: selectedOptions.find((v) => v.value == optionValue), + selectedOptions, + trigger: newValue.isCheck ? 'check' : 'uncheck', + e, + }); + } + break; + case 'Escape': + setInnerPopupVisible(false, { e }); + break; + } + }; + + watch(innerPopupVisible, (value) => { + if (value) { + // 展开重新恢复初始值 + hoverIndex.value = -1; + virtualFilteredOptions.value = []; + } + }); + + // 处理键盘操作滚动 超出视图时继续自动滚动到键盘所在元素 + watch(hoverIndex, (index) => { + const optionHeight = selectPanelRef.value?.innerRef?.querySelector( + `.${classPrefix.value}-select-option`, + ).clientHeight; + + const scrollHeight = optionHeight * index; + + popupContentRef.value.scrollTo({ + top: scrollHeight, + behavior: 'smooth', + }); + }); + + return { + hoverIndex, + handleKeyDown, + virtualFilteredOptions, + }; +} diff --git a/src/select/select-panel.tsx b/src/select/select-panel.tsx index 4cf700ed28..4643383afb 100644 --- a/src/select/select-panel.tsx +++ b/src/select/select-panel.tsx @@ -1,4 +1,4 @@ -import { computed, defineComponent, inject, PropType, Slots, ref } from 'vue'; +import { computed, defineComponent, inject, Slots, ref } from 'vue'; import omit from 'lodash/omit'; import { Styles } from '../common'; @@ -25,10 +25,6 @@ export default defineComponent({ multiple: TdSelectProps.multiple, filterable: TdSelectProps.filterable, filter: TdSelectProps.filter, - options: { - type: Array as PropType, - default: (): SelectOption[] => [], - }, scroll: TdSelectProps.scroll, size: TdSelectProps.size, }, @@ -110,6 +106,8 @@ export default defineComponent({ expose({ innerRef, + visibleData, + isVirtual, }); const renderPanel = (options: SelectOption[], extraStyle?: Styles) => ( diff --git a/src/select/select.tsx b/src/select/select.tsx index 06358232f9..f12e6f1b0e 100644 --- a/src/select/select.tsx +++ b/src/select/select.tsx @@ -12,17 +12,19 @@ import SelectInput from '../select-input'; import SelectPanel from './select-panel'; import props from './props'; -import { TdSelectProps, SelectValue } from './type'; -import { PopupVisibleChangeContext } from '../popup'; - // hooks import { useFormDisabled } from '../form/hooks'; import useDefaultValue from '../hooks/useDefaultValue'; import useVModel from '../hooks/useVModel'; import { useTNodeJSX } from '../hooks/tnode'; import { useConfig, usePrefixClass } from '../hooks/useConfig'; -import { selectInjectKey, getSingleContent, getMultipleContent, getNewMultipleValue } from './helper'; +import { selectInjectKey, getSingleContent, getMultipleContent } from './helper'; import { useSelectOptions } from './hooks/useSelectOptions'; +import useKeyboardControl from './hooks/useKeyboardControl'; + +import type { PopupVisibleChangeContext } from '../popup'; +import type { SelectInputValueChangeContext } from '../select-input'; +import type { TdSelectProps, SelectValue } from './type'; export default defineComponent({ name: 'TSelect', @@ -48,11 +50,7 @@ export default defineComponent({ value: props.keys?.value || 'value', disabled: props.keys?.disabled || 'disabled', })); - const { options, optionsMap, optionsList, optionsCache, displayOptions } = useSelectOptions( - props, - keys, - innerInputValue, - ); + const { optionsMap, optionsList, optionsCache, displayOptions } = useSelectOptions(props, keys, innerInputValue); // 内部数据,格式化过的 const innerValue = computed(() => { @@ -66,6 +64,7 @@ export default defineComponent({ } return orgValue.value; }); + const setInnerValue: TdSelectProps['onChange'] = (newVal: SelectValue | SelectValue[], context) => { if (props.valueType === 'object') { const { value, label } = keys.value; @@ -159,73 +158,6 @@ export default defineComponent({ setInputValue(''); }; - // 键盘操作逻辑 - const hoverIndex = ref(-1); - const handleKeyDown = (e: KeyboardEvent) => { - const optionsListLength = displayOptions.value.length; - let newIndex = hoverIndex.value; - switch (e.code) { - case 'ArrowUp': - e.preventDefault(); - if (hoverIndex.value === -1) { - newIndex = 0; - } else if (hoverIndex.value === 0 || hoverIndex.value > displayOptions.value.length - 1) { - newIndex = optionsListLength - 1; - } else { - newIndex--; - } - if (optionsList.value[newIndex]?.disabled) { - newIndex--; - } - hoverIndex.value = newIndex; - break; - case 'ArrowDown': - e.preventDefault(); - if (hoverIndex.value === -1 || hoverIndex.value >= optionsListLength - 1) { - newIndex = 0; - } else { - newIndex++; - } - if (optionsList.value[newIndex]?.disabled) { - newIndex++; - } - hoverIndex.value = newIndex; - break; - case 'Enter': - if (hoverIndex.value === -1) break; - if (!innerPopupVisible.value) { - setInnerPopupVisible(true, { e }); - break; - } - if (!props.multiple) { - const selectedOptions = getSelectedOptions(optionsList.value[hoverIndex.value].value); - setInnerValue(optionsList.value[hoverIndex.value].value, { - option: selectedOptions?.[0], - selectedOptions: getSelectedOptions(optionsList.value[hoverIndex.value].value), - trigger: 'check', - e, - }); - setInnerPopupVisible(false, { e }); - } else { - if (hoverIndex.value === -1) return; - const optionValue = optionsList.value[hoverIndex.value]?.value; - if (!optionValue) return; - const newValue = getNewMultipleValue(innerValue.value, optionValue); - const selectedOptions = getSelectedOptions(newValue.value); - setInnerValue(newValue.value, { - option: selectedOptions.find((v) => v.value == optionValue), - selectedOptions, - trigger: newValue.isCheck ? 'check' : 'uncheck', - e, - }); - } - break; - case 'Escape': - setInnerPopupVisible(false, { e }); - break; - } - }; - const popupContentRef = computed(() => selectInputRef.value?.popupRef.getOverlay() as HTMLElement); /** @@ -246,6 +178,21 @@ export default defineComponent({ }); }; + const { hoverIndex, virtualFilteredOptions, handleKeyDown } = useKeyboardControl({ + displayOptions, + optionsList, + innerPopupVisible, + setInnerPopupVisible, + selectPanelRef, + isFilterable, + getSelectedOptions, + setInnerValue, + innerValue, + popupContentRef, + multiple: props.multiple, + max: props.max, + }); + const onCheckAllChange = (checked: boolean) => { if (!props.multiple) return; const value = checked ? optionalList.value.map((option) => option.value) : []; @@ -267,7 +214,7 @@ export default defineComponent({ // 半选 const indeterminate = computed(() => !isCheckAll.value && intersectionLen.value !== 0); - const SelectProvide = computed(() => ({ + const SelectProvider = computed(() => ({ max: props.max, multiple: props.multiple, hoverIndex: hoverIndex.value, @@ -286,7 +233,7 @@ export default defineComponent({ displayOptions: displayOptions.value, })); - provide(selectInjectKey, SelectProvide); + provide(selectInjectKey, SelectProvider); const checkValueInvalid = () => { // 参数类型检测与修复 @@ -297,10 +244,23 @@ export default defineComponent({ setOrgValue([], { selectedOptions: [], trigger: 'default' }); } }; + const handleSearch = debounce((value: string, { e }: { e: KeyboardEvent }) => { props.onSearch?.(`${value}`, { e }); }, 300); + const handlerInputChange = (value: string, context: SelectInputValueChangeContext) => { + if (value) { + setInnerPopupVisible(true, { e: context.e as KeyboardEvent }); + } + setInputValue(value); + handleSearch(`${value}`, { e: context.e as KeyboardEvent }); + + nextTick(() => { + virtualFilteredOptions.value = selectPanelRef.value?.visibleData; + }); + }; + const addCache = (val: SelectValue) => { if (props.multiple) { const newCache = []; @@ -337,12 +297,6 @@ export default defineComponent({ checkValueInvalid(); }, ); - watch(innerPopupVisible, (value) => { - if (value) { - // 显示 - hoverIndex.value = -1; - } - }); // 列表展开时定位置选中项 const updateScrollTop = (content: HTMLDivElement) => { @@ -441,13 +395,7 @@ export default defineComponent({ onPopupVisibleChange={(val: boolean, context) => { setInnerPopupVisible(val, context); }} - onInputChange={(value, context) => { - if (value) { - setInnerPopupVisible(true, { e: context.e as KeyboardEvent }); - } - setInputValue(value); - handleSearch(`${value}`, { e: context.e as KeyboardEvent }); - }} + onInputChange={handlerInputChange} onClear={({ e }) => { setInnerValue(props.multiple ? [] : undefined, { option: null, @@ -489,7 +437,6 @@ export default defineComponent({ 'filter', 'scroll', ])} - options={options.value} inputValue={innerInputValue.value} v-slots={slots} /> diff --git a/test/unit/snap/__snapshots__/csr.test.js.snap b/test/unit/snap/__snapshots__/csr.test.js.snap index 4e7ecac168..3659806745 100644 --- a/test/unit/snap/__snapshots__/csr.test.js.snap +++ b/test/unit/snap/__snapshots__/csr.test.js.snap @@ -110384,16 +110384,14 @@ exports[`csr snapshot test > csr test ./src/select/_example/virtual-scroll.vue 1 class="t-input__wrap" >
diff --git a/test/unit/snap/__snapshots__/ssr.test.js.snap b/test/unit/snap/__snapshots__/ssr.test.js.snap index f6336d71d7..206af584bc 100644 --- a/test/unit/snap/__snapshots__/ssr.test.js.snap +++ b/test/unit/snap/__snapshots__/ssr.test.js.snap @@ -856,7 +856,7 @@ exports[`ssr snapshot test > ssr test ./src/select/_example/size.vue 1`] = `" ssr test ./src/select/_example/status.vue 1`] = `"
"`; -exports[`ssr snapshot test > ssr test ./src/select/_example/virtual-scroll.vue 1`] = `"
"`; +exports[`ssr snapshot test > ssr test ./src/select/_example/virtual-scroll.vue 1`] = `"
"`; exports[`ssr snapshot test > ssr test ./src/select-input/_example/autocomplete.vue 1`] = `"
"`;