From b1d2f0de069af8c280f0b0ce7cf20a77be42c26b Mon Sep 17 00:00:00 2001 From: msidolphin Date: Tue, 31 Aug 2021 14:17:43 +0800 Subject: [PATCH] feat(components): select-v2 support keyboard operations (#3138) --- .../select-v2/__tests__/select.spec.ts | 84 +++++++++- .../select-v2/src/select-dropdown.vue | 3 +- packages/components/select-v2/src/select.vue | 7 + .../components/select-v2/src/useOption.ts | 4 +- .../components/select-v2/src/useSelect.ts | 151 +++++++++++------- packages/theme-chalk/src/option-item.scss | 5 +- .../theme-chalk/src/select-dropdown-v2.scss | 9 ++ packages/theme-chalk/src/select-v2.scss | 4 + website/docs/en-US/select-v2.md | 8 - website/docs/es/select-v2.md | 8 - website/docs/fr-FR/select-v2.md | 8 - website/docs/jp/select-v2.md | 8 - website/docs/zh-CN/select-v2.md | 8 - 13 files changed, 202 insertions(+), 105 deletions(-) diff --git a/packages/components/select-v2/__tests__/select.spec.ts b/packages/components/select-v2/__tests__/select.spec.ts index 17fcb98f8f..f5ae97890d 100644 --- a/packages/components/select-v2/__tests__/select.spec.ts +++ b/packages/components/select-v2/__tests__/select.spec.ts @@ -323,6 +323,7 @@ describe('Select', () => { await nextTick() const vm = wrapper.vm as any await wrapper.trigger('click') + await nextTick() expect(vm.visible).toBeTruthy() }) @@ -796,7 +797,7 @@ describe('Select', () => { expect(wrapper.find(`.${PLACEHOLDER_CLASS_NAME}`).exists()).toBeFalsy() // Simulate keyboard events const selectInput = wrapper.find('input') - selectInput.trigger('keydown', { + await selectInput.trigger('keydown', { key: EVENT_CODE.backspace, }) await nextTick() @@ -897,4 +898,85 @@ describe('Select', () => { await testRemoteSearch({ multiple: true }) }) }) + + it('keyboard operations', async () => { + const wrapper = createSelect({ + data () { + return { + multiple: true, + options: [ + { + value: 1, + label: 'option 1', + disabled: true, + }, + { + value: 2, + label: 'option 2', + disabled: true, + }, + { + value: 3, + label: 'option 3', + }, + { + value: 4, + label: 'option 4', + }, + { + value: 5, + label: 'option 5', + options: [ + { + value: 51, + label: 'option 5-1', + }, + { + value: 52, + label: 'option 5-2', + }, + { + value: 53, + label: 'option 5-3', + disabled: true, + }, + ], + }, + { + value: 6, + label: 'option 6', + }, + ], + value: [], + } + }, + }) + const select = wrapper.findComponent(Select) + const selectVm = select.vm as any + const vm = wrapper.vm as any + await wrapper.trigger('click') + await nextTick() + expect(selectVm.states.hoveringIndex).toBe(-1) + // should skip the disabled option + selectVm.onKeyboardNavigate('forward') + selectVm.onKeyboardNavigate('forward') + await nextTick() + expect(selectVm.states.hoveringIndex).toBe(3) + // should skip the group option + selectVm.onKeyboardNavigate('backward') + selectVm.onKeyboardNavigate('backward') + selectVm.onKeyboardNavigate('backward') + selectVm.onKeyboardNavigate('backward') + await nextTick() + expect(selectVm.states.hoveringIndex).toBe(5) + selectVm.onKeyboardNavigate('backward') + selectVm.onKeyboardNavigate('backward') + selectVm.onKeyboardNavigate('backward') + await nextTick() + // navigate to the last one + expect(selectVm.states.hoveringIndex).toBe(9) + selectVm.onKeyboardSelect() + await nextTick() + expect(vm.value).toEqual([6]) + }) }) diff --git a/packages/components/select-v2/src/select-dropdown.vue b/packages/components/select-v2/src/select-dropdown.vue index 1cdf1046c0..e0b8570474 100644 --- a/packages/components/select-v2/src/select-dropdown.vue +++ b/packages/components/select-v2/src/select-dropdown.vue @@ -139,7 +139,7 @@ export default defineComponent({ const Comp = isSized ? FixedSizeList : DynamicSizeList - const { props: selectProps, onSelect, onKeyboardNavigate, onKeyboardSelect } = select + const { props: selectProps, onSelect, onHover, onKeyboardNavigate, onKeyboardSelect } = select const { height, modelValue, multiple } = selectProps if (data.length === 0) { @@ -179,6 +179,7 @@ export default defineComponent({ hovering: isItemHovering(index), item, onSelect, + onHover, }, { default: withCtx((props: OptionItemProps) => { diff --git a/packages/components/select-v2/src/select.vue b/packages/components/select-v2/src/select.vue index c8e8288b1f..24fa407bd7 100644 --- a/packages/components/select-v2/src/select.vue +++ b/packages/components/select-v2/src/select.vue @@ -114,6 +114,9 @@ @compositionstart="handleCompositionStart" @compositionupdate="handleCompositionUpdate" @compositionend="handleCompositionEnd" + @keydown.up.stop.prevent="onKeyboardNavigate('backward')" + @keydown.down.stop.prevent="onKeyboardNavigate('forward')" + @keydown.enter.stop.prevent="onKeyboardSelect" @keydown.esc.stop.prevent="handleEsc" @keydown.delete.stop="handleDel" > @@ -154,6 +157,9 @@ @compositionend="handleCompositionEnd" @focus="handleFocus" @input="onInput" + @keydown.up.stop.prevent="onKeyboardNavigate('backward')" + @keydown.down.stop.prevent="onKeyboardNavigate('forward')" + @keydown.enter.stop.prevent="onKeyboardSelect" @keydown.esc.stop.prevent="handleEsc" @update:modelValue="onUpdateInputValue" > @@ -248,6 +254,7 @@ export default defineComponent({ height: API.popupHeight, }), onSelect: API.onSelect, + onHover: API.onHover, onKeyboardNavigate: API.onKeyboardNavigate, onKeyboardSelect: API.onKeyboardSelect, } as any) diff --git a/packages/components/select-v2/src/useOption.ts b/packages/components/select-v2/src/useOption.ts index daa4cacdb3..3ab181db91 100644 --- a/packages/components/select-v2/src/useOption.ts +++ b/packages/components/select-v2/src/useOption.ts @@ -3,7 +3,9 @@ import type { IOptionProps } from './token' export function useOption(props: IOptionProps, { emit }) { return { hoverItem: () => { - emit('hover', props.index) + if (!props.disabled) { + emit('hover', props.index) + } }, selectOptionClick: () => { if (!props.disabled) { diff --git a/packages/components/select-v2/src/useSelect.ts b/packages/components/select-v2/src/useSelect.ts index d9f524349d..cc7476d4d4 100644 --- a/packages/components/select-v2/src/useSelect.ts +++ b/packages/components/select-v2/src/useSelect.ts @@ -159,6 +159,8 @@ const useSelect = (props: ExtractPropTypes, emit) => { }).filter(v => v !== null)) }) + const optionsAllDisabled = computed(() => filteredOptions.value.every(option => option.disabled)) + const selectSize = computed(() => props.size || elFormItem.size || $ELEMENT.size) const collapseTagSize = computed(() => ['small', 'mini'].indexOf(selectSize.value) > -1 ? 'mini' : 'small') @@ -212,7 +214,9 @@ const useSelect = (props: ExtractPropTypes, emit) => { return -1 }) - const dropdownMenuVisible = computed(() => expanded.value && emptyText.value !== false) + const dropdownMenuVisible = computed(() => { + return expanded.value && emptyText.value !== false + }) // hooks const { createNewOption, removeNewOption, selectNewOption, clearAllNewOption } = useAllowCreate(props, states) @@ -228,8 +232,10 @@ const useSelect = (props: ExtractPropTypes, emit) => { if (props.automaticDropdown) return if (!selectDisabled.value) { if (states.isComposing) states.softFocus = true - expanded.value = !expanded.value - inputRef.value?.focus?.() + return nextTick(() => { + expanded.value = !expanded.value + inputRef.value?.focus?.() + }) } } @@ -270,8 +276,9 @@ const useSelect = (props: ExtractPropTypes, emit) => { } const getValueIndex = (arr = [], value: unknown) => { - if (!isObject(value)) return arr.indexOf(value) - + if (!isObject(value)) { + return arr.indexOf(value) + } const valueKey = props.valueKey let index = -1 arr.some((item, i) => { @@ -299,8 +306,10 @@ const useSelect = (props: ExtractPropTypes, emit) => { } const resetInputHeight = () => { - if (props.collapseTags && !props.filterable) return - nextTick(() => { + if (props.collapseTags && !props.filterable) { + return + } + return nextTick(() => { if (!inputRef.value) return const selection = selectionRef.value @@ -340,6 +349,7 @@ const useSelect = (props: ExtractPropTypes, emit) => { selectedOptions = [...selectedOptions, option.value] states.cachedOptions.push(option) selectNewOption(option) + updateHoveringIndex(idx) } update(selectedOptions) if (option.created) { @@ -355,6 +365,7 @@ const useSelect = (props: ExtractPropTypes, emit) => { states.calculatedWidth = calculatorRef.value.getBoundingClientRect().width } resetInputHeight() + setSoftFocus() } else { selectedIndex.value = idx states.selectedLabel = option.label @@ -366,6 +377,7 @@ const useSelect = (props: ExtractPropTypes, emit) => { if (!option.created) { clearAllNewOption() } + updateHoveringIndex(idx) } } @@ -382,8 +394,8 @@ const useSelect = (props: ExtractPropTypes, emit) => { update(value) emit('remove-tag', tag.value) states.softFocus = true - nextTick(focusAndUpdatePopup) removeNewOption(tag) + return nextTick(focusAndUpdatePopup) } event.stopPropagation() } @@ -392,9 +404,6 @@ const useSelect = (props: ExtractPropTypes, emit) => { const focused = states.isComposing states.isComposing = true if (!states.softFocus) { - if (props.automaticDropdown || props.filterable) { - expanded.value = true - } // If already in the focus state, shouldn't trigger event if (!focused) emit('focus', event) } else { @@ -407,7 +416,7 @@ const useSelect = (props: ExtractPropTypes, emit) => { // reset input value when blurred // https://github.com/ElemeFE/element/pull/10822 - nextTick(() => { + return nextTick(() => { inputRef.value?.blur?.() if (calculatorRef.value) { states.calculatedWidth = calculatorRef.value.getBoundingClientRect().width @@ -461,7 +470,7 @@ const useSelect = (props: ExtractPropTypes, emit) => { update(emptyValue) emit('clear') clearAllNewOption() - nextTick(focusAndUpdatePopup) + return nextTick(focusAndUpdatePopup) } const onUpdateInputValue = (val: string) => { @@ -469,59 +478,69 @@ const useSelect = (props: ExtractPropTypes, emit) => { states.inputValue = val } - const onKeyboardNavigate = (direction: 'forward' | 'backward') => { - if (selectDisabled.value) return - - if (props.multiple) { - expanded.value = true + const onKeyboardNavigate = (direction: 'forward' | 'backward', hoveringIndex: number = undefined) => { + const options = filteredOptions.value + if ( + !['forward', 'backward'].includes(direction) || + selectDisabled.value || + options.length <= 0 || + optionsAllDisabled.value + ) { return } - - let newIndex: number - - if (props.options.length === 0 || filteredOptions.value.length === 0) return - - if (filteredOptions.value.length > 0) { - // only two ways: forward or backward - if (direction === 'forward') { - newIndex = selectedIndex.value + 1 - - if (newIndex > filteredOptions.value.length - 1) { - newIndex = 0 - } - // states.hoveringIndex++ - // if (states.hoveringIndex === props.options.length) { - // states.hoveringIndex = 0 - // } - } else { - newIndex = selectedIndex.value - 1 - - if (newIndex < 0) { - newIndex = filteredOptions.value.length - 1 - } + if (!expanded.value) { + return toggleMenu() + } + if (hoveringIndex === undefined) { + hoveringIndex = states.hoveringIndex + } + let newIndex = -1 + if (direction === 'forward') { + newIndex = hoveringIndex + 1 + if (newIndex >= options.length) { + // return to the first option + newIndex = 0 } - - selectedIndex.value = newIndex - const option = filteredOptions.value[newIndex] - if (option.disabled || option.type === 'Group') { - onKeyboardNavigate(direction) - // prevent dispatching multiple nextTick callbacks. - return + } else if (direction === 'backward') { + newIndex = hoveringIndex - 1 + if (newIndex < 0) { + // navigate to the last one + newIndex = options.length - 1 } - - emit(UPDATE_MODEL_EVENT, filteredOptions.value[newIndex]) - emitChange(filteredOptions.value[newIndex]) + } + const option = options[newIndex] + if (option.disabled || option.type === 'Group') { + // prevent dispatching multiple nextTick callbacks. + return onKeyboardNavigate(direction, newIndex) + } else { + updateHoveringIndex(newIndex) + scrollToItem(newIndex) } } const onKeyboardSelect = () => { if (!expanded.value) { - toggleMenu() - } else { + return toggleMenu() + } else if (~states.hoveringIndex) { onSelect(filteredOptions.value[states.hoveringIndex], states.hoveringIndex, false) } } + const updateHoveringIndex = (idx: number) => { + states.hoveringIndex = idx + } + + const resetHoveringIndex = () => { + states.hoveringIndex = -1 + } + + const setSoftFocus = () => { + const _input = inputRef.value + if (_input) { + _input.focus?.() + } + } + const onInput = event => { const value = event.target.value onUpdateInputValue(value) @@ -542,14 +561,15 @@ const useSelect = (props: ExtractPropTypes, emit) => { const handleClickOutside = () => { expanded.value = false - handleBlur() + return handleBlur() } const handleMenuEnter = () => { states.inputValue = states.displayInputValue return nextTick(() => { if (~indexRef.value) { - scrollToItem(indexRef.value) + updateHoveringIndex(indexRef.value) + scrollToItem(states.hoveringIndex) } }) } @@ -559,21 +579,29 @@ const useSelect = (props: ExtractPropTypes, emit) => { } const initStates = () => { + resetHoveringIndex() if (props.multiple) { if ((props.modelValue as Array).length > 0) { + let initHovering = false states.cachedOptions.length = 0; (props.modelValue as Array).map(selected => { - const item = filteredOptions.value.find(option => option.value === selected) - if (item) { - states.cachedOptions.push(item as Option) + const itemIndex = filteredOptions.value.findIndex(option => option.value === selected) + if (~itemIndex) { + states.cachedOptions.push(filteredOptions.value[itemIndex] as Option) + if (!initHovering) { + updateHoveringIndex(itemIndex) + } + initHovering = true } }) } } else { if (props.modelValue) { - const selectedItem = filteredOptions.value.find(o => o.value === props.modelValue) - if (selectedItem) { - states.selectedLabel = selectedItem.label + const options = filteredOptions.value + const selectedItemIndex = options.findIndex(o => o.value === props.modelValue) + if (~selectedItemIndex) { + states.selectedLabel = options[selectedItemIndex].label + updateHoveringIndex(selectedItemIndex) } else { states.selectedLabel = `${props.modelValue}` } @@ -682,6 +710,7 @@ const useSelect = (props: ExtractPropTypes, emit) => { onKeyboardNavigate, onKeyboardSelect, onSelect, + onHover: updateHoveringIndex, onUpdateInputValue, handleCompositionStart, handleCompositionEnd, diff --git a/packages/theme-chalk/src/option-item.scss b/packages/theme-chalk/src/option-item.scss index 3e798b7244..24ae591c2f 100644 --- a/packages/theme-chalk/src/option-item.scss +++ b/packages/theme-chalk/src/option-item.scss @@ -37,7 +37,10 @@ } } - &.hover, + &.hover { + background-color: map.get($--select-option, 'hover-background') !important; + } + &:hover { background-color: map.get($--select-option, 'hover-background'); } diff --git a/packages/theme-chalk/src/select-dropdown-v2.scss b/packages/theme-chalk/src/select-dropdown-v2.scss index 47b7792056..d1e957adf7 100644 --- a/packages/theme-chalk/src/select-dropdown-v2.scss +++ b/packages/theme-chalk/src/select-dropdown-v2.scss @@ -1,6 +1,7 @@ @use "sass:map"; @import 'mixins/mixins'; +@import 'mixins/var'; @import 'common/var'; @include b(select-dropdown) { @@ -11,6 +12,14 @@ .#{$namespace}-scrollbar.is-empty .#{$namespace}-select-dropdown__list { padding: 0; } + + @include e(option-item) { + &:hover { + &:not(.hover) { + background-color: transparent; + } + } + } } @include b(select-dropdown__empty) { diff --git a/packages/theme-chalk/src/select-v2.scss b/packages/theme-chalk/src/select-v2.scss index 86c1617ea0..4323aa8724 100644 --- a/packages/theme-chalk/src/select-v2.scss +++ b/packages/theme-chalk/src/select-v2.scss @@ -9,6 +9,10 @@ $--input-inline-start: 15px !default; +@include b(select-v2) { + @include set-component-css-var('select', $--select); +} + @include b(select-v2) { display: inline-block; position: relative; diff --git a/website/docs/en-US/select-v2.md b/website/docs/en-US/select-v2.md index a11d6df4a2..2492f052bd 100644 --- a/website/docs/en-US/select-v2.md +++ b/website/docs/en-US/select-v2.md @@ -438,14 +438,6 @@ Enter keywords and search data from server. ``` ::: -### Keyboard navigation - -WIP 👷‍♀️ - -:::tip -Some APIs are still undergoing (comparing to the non-virtualized select), because there were lots of legacy API refactors and new designs, the current version only implements the simplest and most used functionalities. -::: - ### SelectV2 Attributes | Param | Description | Type | Accepted Values | Default | |---------- |-------------- |---------- |-------------------------------- |-------- | diff --git a/website/docs/es/select-v2.md b/website/docs/es/select-v2.md index cc0d05a7e1..44d94ed949 100644 --- a/website/docs/es/select-v2.md +++ b/website/docs/es/select-v2.md @@ -439,14 +439,6 @@ Introduzca palabras y datos para buscar desde el servidor. ``` ::: -### Keyboard navigation - -WIP 👷‍♀️ - -:::tip -Some APIs are still undergoing (comparing to the non-virtualized select), because there were lots of legacy API refactors and new designs, the current version only implements the simplest and most used functionalities. -::: - ### SelectV2 Attributes | Param | Description | Type | Accepted Values | Default | |---------- |-------------- |---------- |-------------------------------- |-------- | diff --git a/website/docs/fr-FR/select-v2.md b/website/docs/fr-FR/select-v2.md index 635c940d5a..64003474ee 100644 --- a/website/docs/fr-FR/select-v2.md +++ b/website/docs/fr-FR/select-v2.md @@ -440,14 +440,6 @@ Vous pouvez aller chercher les options sur le serveur de manière dynamique. ``` ::: -### Keyboard navigation - -WIP 👷‍♀️ - -:::tip -Some APIs are still undergoing (comparing to the non-virtualized select), because there were lots of legacy API refactors and new designs, the current version only implements the simplest and most used functionalities. -::: - ### SelectV2 Attributes | Param | Description | Type | Accepted Values | Default | |---------- |-------------- |---------- |-------------------------------- |-------- | diff --git a/website/docs/jp/select-v2.md b/website/docs/jp/select-v2.md index 6c7498c22f..3055d8a722 100644 --- a/website/docs/jp/select-v2.md +++ b/website/docs/jp/select-v2.md @@ -438,14 +438,6 @@ We can clear all the selected options at once, also applicable for single select ``` ::: -### Keyboard navigation - -WIP 👷‍♀️ - -:::tip -Some APIs are still undergoing (comparing to the non-virtualized select), because there were lots of legacy API refactors and new designs, the current version only implements the simplest and most used functionalities. -::: - ### SelectV2 Attributes | Param | Description | Type | Accepted Values | Default | |---------- |-------------- |---------- |-------------------------------- |-------- | diff --git a/website/docs/zh-CN/select-v2.md b/website/docs/zh-CN/select-v2.md index dd1ed085d7..ed4400fa7d 100644 --- a/website/docs/zh-CN/select-v2.md +++ b/website/docs/zh-CN/select-v2.md @@ -438,14 +438,6 @@ ``` ::: -### 键盘操作 - -WIP (该功能还在施工中👷‍♀️) - -:::tip -有一些 API 暂时还没有被实现(相较于当前的 select 而言),因为还需要更多设计以及一些遗留 API 的改动,所以当前仅支持一些最简单的展示功能。 -::: - ### SelectV2 Attributes | 参数 | 说明 | 类型 | 可选值 | 默认值 | |---------- |-------------- |---------- |-------------------------------- |-------- |