mirror of
https://github.com/element-plus/element-plus.git
synced 2024-12-21 02:50:11 +08:00
feat(components): select-v2 support keyboard operations (#3138)
This commit is contained in:
parent
0743e79826
commit
b1d2f0de06
@ -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])
|
||||
})
|
||||
})
|
||||
|
@ -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) => {
|
||||
|
@ -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)
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
|
@ -37,7 +37,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.hover,
|
||||
&.hover {
|
||||
background-color: map.get($--select-option, 'hover-background') !important;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: map.get($--select-option, 'hover-background');
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
|
@ -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 |
|
||||
|---------- |-------------- |---------- |-------------------------------- |-------- |
|
||||
|
@ -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 |
|
||||
|---------- |-------------- |---------- |-------------------------------- |-------- |
|
||||
|
@ -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 |
|
||||
|---------- |-------------- |---------- |-------------------------------- |-------- |
|
||||
|
@ -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 |
|
||||
|---------- |-------------- |---------- |-------------------------------- |-------- |
|
||||
|
@ -438,14 +438,6 @@
|
||||
```
|
||||
:::
|
||||
|
||||
### 键盘操作
|
||||
|
||||
WIP (该功能还在施工中👷♀️)
|
||||
|
||||
:::tip
|
||||
有一些 API 暂时还没有被实现(相较于当前的 select 而言),因为还需要更多设计以及一些遗留 API 的改动,所以当前仅支持一些最简单的展示功能。
|
||||
:::
|
||||
|
||||
### SelectV2 Attributes
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|---------- |-------------- |---------- |-------------------------------- |-------- |
|
||||
|
Loading…
Reference in New Issue
Block a user