diff --git a/frontend/src/components/common/Select.vue b/frontend/src/components/common/Select.vue index a534f5c246..6f68a53429 100644 --- a/frontend/src/components/common/Select.vue +++ b/frontend/src/components/common/Select.vue @@ -55,6 +55,7 @@ :class="[instanceId]" :style="dropdownStyle" role="listbox" + tabindex="-1" @click.stop @mousedown.stop @keydown="onDropdownKeyDown" @@ -384,6 +385,8 @@ watch(isOpen, (open) => { if (isSearchable.value) { nextTick(() => searchInputRef.value?.focus()) + } else { + nextTick(() => dropdownRef.value?.focus()) } // Add scroll listener to update position window.addEventListener('scroll', updateTriggerRect, { capture: true, passive: true }) diff --git a/frontend/src/components/common/__tests__/Select.keyboard.spec.ts b/frontend/src/components/common/__tests__/Select.keyboard.spec.ts new file mode 100644 index 0000000000..e4af8a4593 --- /dev/null +++ b/frontend/src/components/common/__tests__/Select.keyboard.spec.ts @@ -0,0 +1,55 @@ +import { mount } from '@vue/test-utils' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' +import Select from '../Select.vue' + +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) +let wrapper: ReturnType +afterEach(() => { wrapper?.unmount(); document.body.innerHTML = '' }) + +async function press(key: string) { + document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })) + await nextTick() +} + +async function open(searchable: boolean | 'auto' = 'auto') { + wrapper = mount(Select, { + attachTo: document.body, + props: { modelValue: 'alpha', searchable, options: [ + { value: 'alpha', label: 'Alpha' }, + { value: 'disabled', label: 'Disabled', disabled: true }, + { value: 'beta', label: 'Beta' }, + ] }, + }) + wrapper.get('button').element.focus() + await press('ArrowDown') + await nextTick() +} + +describe('Select keyboard focus', () => { + it.each([false, 'auto'] as const)('selects an enabled option without a search input (%s)', async (searchable) => { + await open(searchable) + expect(document.activeElement).toBe(document.querySelector('[role="listbox"]')) + await press('ArrowDown') + await press('Enter') + expect(wrapper.emitted('update:modelValue')).toEqual([['beta']]) + expect(wrapper.get('button').attributes('aria-expanded')).toBe('false') + expect(document.activeElement).toBe(wrapper.get('button').element) + }) + + it('allows Escape to close a non-searchable dropdown and restore trigger focus', async () => { + await open(false) + await press('Escape') + expect(wrapper.get('button').attributes('aria-expanded')).toBe('false') + expect(document.activeElement).toBe(wrapper.get('button').element) + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) + + it('keeps focus in the search input when search is enabled', async () => { + await open(true) + expect(document.activeElement).toBe(document.querySelector('.select-search-input')) + await press('ArrowDown') + await press('Enter') + expect(wrapper.emitted('update:modelValue')).toEqual([['beta']]) + }) +})