diff --git a/components/dropdown/__tests__/dropdown-842.spec.ts b/components/dropdown/__tests__/dropdown-842.spec.ts new file mode 100644 index 00000000..86257276 --- /dev/null +++ b/components/dropdown/__tests__/dropdown-842.spec.ts @@ -0,0 +1,68 @@ +import { mount } from '@vue/test-utils'; +import { h } from 'vue'; +import Dropdown from '../dropdown.tsx'; + +const OPTIONS = [ + { value: '1', label: '删除' }, + { value: '2', label: '修改' }, + { value: '3', label: '添加' }, + { value: '4', label: '评论' }, + { value: '5', label: '收藏' }, +]; + +const _mount = (props = {}) => + mount(Dropdown, { + props, + slots: { + default: () => h('div', { class: 'test-trigger' }, '下拉菜单'), + }, + attachTo: 'body', + }); + +describe('Dropdown keyboard navigation #842', () => { + test('ArrowDown moves to next item', async () => { + const wrapper = _mount({ + options: OPTIONS, + trigger: 'click', + appendToContainer: false, + }); + await wrapper.find('.test-trigger').trigger('click'); + const menu = wrapper.find('.fes-dropdown-menu'); + expect(menu.exists()).toBe(true); + + await menu.trigger('keydown', { key: 'ArrowDown' }); + expect(wrapper.find('.fes-dropdown-option.is-active').text()).toBe('删除'); + + await menu.trigger('keydown', { key: 'ArrowDown' }); + expect(wrapper.find('.fes-dropdown-option.is-active').text()).toBe('修改'); + + await menu.trigger('keydown', { key: 'ArrowDown' }); + expect(wrapper.find('.fes-dropdown-option.is-active').text()).toBe('添加'); + + await menu.trigger('keydown', { key: 'ArrowDown' }); + expect(wrapper.find('.fes-dropdown-option.is-active').text()).toBe('评论'); + + await menu.trigger('keydown', { key: 'ArrowDown' }); + expect(wrapper.find('.fes-dropdown-option.is-active').text()).toBe('收藏'); + + await menu.trigger('keydown', { key: 'ArrowDown' }); + expect(wrapper.find('.fes-dropdown-option.is-active').text()).toBe('删除'); + }); + + test('Enter triggers active item click', async () => { + const wrapper = _mount({ + options: OPTIONS, + trigger: 'click', + appendToContainer: false, + }); + await wrapper.find('.test-trigger').trigger('click'); + const menu = wrapper.find('.fes-dropdown-menu'); + + await menu.trigger('keydown', { key: 'ArrowDown' }); + expect(wrapper.find('.fes-dropdown-option.is-active').text()).toBe('删除'); + + await menu.trigger('keydown', { key: 'Enter' }); + expect(wrapper.emitted()).toHaveProperty('click'); + expect(wrapper.emitted().click[0]).toEqual(['1']); + }); +}); diff --git a/components/dropdown/dropdown.tsx b/components/dropdown/dropdown.tsx index 2a1d36f8..4fe5e199 100644 --- a/components/dropdown/dropdown.tsx +++ b/components/dropdown/dropdown.tsx @@ -11,6 +11,9 @@ import { type DropdownValue, type DropdownOption as Option, dropdownProps } from const prefixCls = getPrefixCls('dropdown'); +const NAVIGATE_KEYS = ['ArrowDown', 'ArrowUp', 'Enter', 'Escape'] as const; +type NavigateKey = (typeof NAVIGATE_KEYS)[number]; + export default defineComponent({ name: 'FDropdown', props: dropdownProps, @@ -23,10 +26,59 @@ export default defineComponent({ prop: 'visible', }); + const activeIndex = ref(-1); + const hasIcon = computed(() => props.options.some((option) => option.icon), ); + const getNextValidIndex = ( + current: number, + direction: 1 | -1, + ): number => { + const len = props.options.length; + let index = current + direction; + while (index !== current) { + if (index < 0) index = len - 1; + if (index >= len) index = 0; + if (!props.options[index]?.disabled) break; + index += direction; + } + return index === current ? -1 : index; + }; + + const handleKeydown = (event: KeyboardEvent) => { + if (!visible.value) return; + const key = event.key as NavigateKey; + if (!NAVIGATE_KEYS.includes(key)) return; + event.preventDefault(); + if (key === 'Escape') { + updateVisible(false); + activeIndex.value = -1; + return; + } + if (key === 'ArrowDown') { + activeIndex.value = getNextValidIndex( + activeIndex.value, + 1, + ); + return; + } + if (key === 'ArrowUp') { + activeIndex.value = getNextValidIndex( + activeIndex.value, + -1, + ); + return; + } + if (key === 'Enter' && activeIndex.value >= 0) { + const option = props.options[activeIndex.value]; + if (option && !option.disabled) { + handleClick(option, event as unknown as Event); + } + } + }; + const handleClick = (option: Option, event: Event) => { event.stopPropagation(); if (option.disabled) { @@ -46,54 +98,61 @@ export default defineComponent({ }); const renderOptions = () => ( - { - emit('scroll', event); - }} - containerClass={[ - `${prefixCls}-option-wrapper`, - hasIcon.value ? 'has-icon' : '', - ]} +
- {props.options.map((option) => { - const value = option[props.valueField] as Option['value']; - const label = option[props.labelField] as Option['label']; - const isSelected - = props.showSelectedOption - && currentValue.value === value; - const optionClassList = [ - `${prefixCls}-option`, - option.disabled && 'is-disabled', - isSelected && 'is-selected', - ].filter(Boolean); - return ( -
{ - handleClick(option, event); - }} - > - {option.icon && ( - - {option.icon?.()} - - )} - - {isFunction(label) ? label(option) : label} - - {props.showSelectedOption && ( - - + { + emit('scroll', event); + }} + containerClass={[ + `${prefixCls}-option-wrapper`, + hasIcon.value ? 'has-icon' : '', + ]} + > + {props.options.map((option, index) => { + const value = option[props.valueField] as Option['value']; + const label = option[props.labelField] as Option['label']; + const isSelected + = props.showSelectedOption + && currentValue.value === value; + const optionClassList = [ + `${prefixCls}-option`, + option.disabled && 'is-disabled', + isSelected && 'is-selected', + activeIndex.value === index && 'is-active', + ].filter(Boolean); + return ( +
{ + handleClick(option, event); + }} + > + {option.icon && ( + + {option.icon?.()} + + )} + + {isFunction(label) ? label(option) : label} - )} -
- ); - })} -
+ {props.showSelectedOption && ( + + + + )} +
+ ); + })} + +
); return () => ( diff --git a/components/dropdown/style/index.less b/components/dropdown/style/index.less index 0179ab25..d89ee3f7 100644 --- a/components/dropdown/style/index.less +++ b/components/dropdown/style/index.less @@ -57,6 +57,9 @@ font-size: @font-size-head; } } + &.is-active { + background: @dropdown-option-hover-color; + } &:hover { background: @dropdown-option-hover-color; transition: background-color @animation-duration-fast @ease-base-in;