feat(components): select-v2 support keyboard operations (#3138)

This commit is contained in:
msidolphin 2021-08-31 14:17:43 +08:00 committed by GitHub
parent 0743e79826
commit b1d2f0de06
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 202 additions and 105 deletions

View File

@ -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])
})
})

View File

@ -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) => {

View File

@ -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)

View File

@ -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) {

View File

@ -159,6 +159,8 @@ const useSelect = (props: ExtractPropTypes<typeof SelectProps>, 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<typeof SelectProps>, 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<typeof SelectProps>, 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<typeof SelectProps>, 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<typeof SelectProps>, 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<typeof SelectProps>, 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<typeof SelectProps>, 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<typeof SelectProps>, emit) => {
if (!option.created) {
clearAllNewOption()
}
updateHoveringIndex(idx)
}
}
@ -382,8 +394,8 @@ const useSelect = (props: ExtractPropTypes<typeof SelectProps>, 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<typeof SelectProps>, 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<typeof SelectProps>, 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<typeof SelectProps>, emit) => {
update(emptyValue)
emit('clear')
clearAllNewOption()
nextTick(focusAndUpdatePopup)
return nextTick(focusAndUpdatePopup)
}
const onUpdateInputValue = (val: string) => {
@ -469,59 +478,69 @@ const useSelect = (props: ExtractPropTypes<typeof SelectProps>, 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<typeof SelectProps>, 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<typeof SelectProps>, emit) => {
}
const initStates = () => {
resetHoveringIndex()
if (props.multiple) {
if ((props.modelValue as Array<any>).length > 0) {
let initHovering = false
states.cachedOptions.length = 0;
(props.modelValue as Array<any>).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<typeof SelectProps>, emit) => {
onKeyboardNavigate,
onKeyboardSelect,
onSelect,
onHover: updateHoveringIndex,
onUpdateInputValue,
handleCompositionStart,
handleCompositionEnd,

View File

@ -37,7 +37,10 @@
}
}
&.hover,
&.hover {
background-color: map.get($--select-option, 'hover-background') !important;
}
&:hover {
background-color: map.get($--select-option, 'hover-background');
}

View File

@ -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) {

View File

@ -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;

View File

@ -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 |
|---------- |-------------- |---------- |-------------------------------- |-------- |

View File

@ -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 |
|---------- |-------------- |---------- |-------------------------------- |-------- |

View File

@ -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 |
|---------- |-------------- |---------- |-------------------------------- |-------- |

View File

@ -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 |
|---------- |-------------- |---------- |-------------------------------- |-------- |

View File

@ -438,14 +438,6 @@
```
:::
### 键盘操作
WIP (该功能还在施工中👷‍♀️)
:::tip
有一些 API 暂时还没有被实现(相较于当前的 select 而言),因为还需要更多设计以及一些遗留 API 的改动,所以当前仅支持一些最简单的展示功能。
:::
### SelectV2 Attributes
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|---------- |-------------- |---------- |-------------------------------- |-------- |