From 473952001c3633dd4d33354ad0b42833b31f0513 Mon Sep 17 00:00:00 2001 From: Thomas Cazade Date: Sun, 26 Oct 2025 09:56:12 +0100 Subject: [PATCH 1/3] feat(select): wai-aria keyboard navigation improvements --- docs/props.md | 14 ++ playground/PlaygroundLayout.vue | 1 + playground/demos/KeyboardNavigation.vue | 213 ++++++++++++++++++++++++ playground/main.ts | 2 + src/Menu.vue | 19 +++ src/Select.spec.ts | 159 ++++++++++++++++++ src/Select.vue | 19 +++ src/types/props.ts | 6 + 8 files changed, 433 insertions(+) create mode 100644 playground/demos/KeyboardNavigation.vue diff --git a/docs/props.md b/docs/props.md index 2e5c30d..682bf9b 100644 --- a/docs/props.md +++ b/docs/props.md @@ -339,3 +339,17 @@ The label of an option is displayed in the dropdown and as the selected option ( Resolves option data to a string to compare options and specify value attributes. This function can be used if you don't want to use the standard `option.value` as the value of the option. + +## selectOnBlur + +**Type**: `boolean` + +**Default**: `true` + +When set to `true`, the focused option will be automatically selected when the component loses focus. This behavior is useful for WAI-ARIA compliance and provides a better user experience for keyboard navigation. + +When set to `false`, the component will not select any option when losing focus, requiring users to explicitly select options using Enter, Space, or mouse clicks. + +::: info +This prop only affects the behavior when the dropdown menu is open and an option is focused. If no option is focused or the menu is closed, no selection will occur regardless of this prop's value. +::: diff --git a/playground/PlaygroundLayout.vue b/playground/PlaygroundLayout.vue index c91145e..6a5745e 100644 --- a/playground/PlaygroundLayout.vue +++ b/playground/PlaygroundLayout.vue @@ -18,6 +18,7 @@ const links = [ { value: "/controlled-menu", label: "Controlled Menu" }, { value: "/menu-header", label: "Menu Header" }, { value: "/menu-positioning", label: "Menu Positioning" }, + { value: "/keyboard-navigation", label: "Keyboard Navigation" }, ]; const router = useRouter(); diff --git a/playground/demos/KeyboardNavigation.vue b/playground/demos/KeyboardNavigation.vue new file mode 100644 index 0000000..4ccfede --- /dev/null +++ b/playground/demos/KeyboardNavigation.vue @@ -0,0 +1,213 @@ + + + + + diff --git a/playground/main.ts b/playground/main.ts index 6d73f9b..20a0537 100644 --- a/playground/main.ts +++ b/playground/main.ts @@ -7,6 +7,7 @@ import CustomPlaceholder from "./demos/CustomPlaceholder.vue"; import CustomSearchFilter from "./demos/CustomSearchFilter.vue"; import CustomTagContent from "./demos/CustomTagContent.vue"; import ExtraOptionProperties from "./demos/ExtraOptionProperties.vue"; +import KeyboardNavigation from "./demos/KeyboardNavigation.vue"; import MenuHeader from "./demos/MenuHeader.vue"; import MenuPositioning from "./demos/MenuPositioning.vue"; import MultiSelect from "./demos/MultiSelect.vue"; @@ -33,6 +34,7 @@ const router = createRouter({ { path: "/controlled-menu", component: ControlledMenu }, { path: "/menu-header", component: MenuHeader }, { path: "/menu-positioning", component: MenuPositioning }, + { path: "/keyboard-navigation", component: KeyboardNavigation }, ], }); diff --git a/src/Menu.vue b/src/Menu.vue index f778713..4ab2ee6 100644 --- a/src/Menu.vue +++ b/src/Menu.vue @@ -96,6 +96,25 @@ const handleNavigation = (e: KeyboardEvent) => { sharedData.closeMenu(); } + if (e.key === "PageDown") { + e.preventDefault(); + + const lastOptionIndex = sharedData.availableOptions.value.reduce( + (acc, option, i) => (!option.disabled ? i : acc), + -1, + ); + + sharedData.focusedOption.value = lastOptionIndex; + } + + if (e.key === "PageUp") { + e.preventDefault(); + + const firstOptionIndex = sharedData.availableOptions.value.findIndex((option) => !option.disabled); + + sharedData.focusedOption.value = firstOptionIndex; + } + const hasSelectedValue = sharedProps.isMulti && Array.isArray(selected.value) ? selected.value.length > 0 : !!selected.value; // When pressing backspace with no search, remove the last selected option. diff --git a/src/Select.spec.ts b/src/Select.spec.ts index 6d07b5f..ff11d92 100644 --- a/src/Select.spec.ts +++ b/src/Select.spec.ts @@ -928,6 +928,165 @@ describe("menu positioning data attribute", () => { }); }); +// eslint-disable-next-line test/prefer-lowercase-title +describe("WAI-ARIA compliance keyboard behaviors", () => { + it("should open menu when pressing up arrow on focused input", async () => { + const wrapper = mount(VueSelect, { props: { modelValue: null, options } }); + + await wrapper.get("input").trigger("focus"); + await wrapper.get("input").trigger("keydown", { key: "ArrowUp" }); + + expect(wrapper.findAll("div[role='option']").length).toBe(options.length); + }); + + it("should open menu when pressing down arrow on focused input", async () => { + const wrapper = mount(VueSelect, { props: { modelValue: null, options } }); + + await wrapper.get("input").trigger("focus"); + await wrapper.get("input").trigger("keydown", { key: "ArrowDown" }); + + expect(wrapper.findAll("div[role='option']").length).toBe(options.length); + }); + + it("should not open menu when pressing arrow keys if menu is already open", async () => { + const wrapper = mount(VueSelect, { props: { modelValue: null, options } }); + + await openMenu(wrapper); + const initialOptionCount = wrapper.findAll("div[role='option']").length; + + await wrapper.get("input").trigger("keydown", { key: "ArrowUp" }); + await wrapper.get("input").trigger("keydown", { key: "ArrowDown" }); + + expect(wrapper.findAll("div[role='option']").length).toBe(initialOptionCount); + }); + + it("should select focused option when component loses focus with selectOnBlur enabled", async () => { + const wrapper = mount(VueSelect, { props: { modelValue: null, options, selectOnBlur: true } }); + + await openMenu(wrapper); + await dispatchEvent(wrapper, new KeyboardEvent("keydown", { key: "ArrowDown" })); + + expect(wrapper.get(".focused[role='option']").text()).toBe(options[1]?.label); + + await wrapper.get("input").trigger("blur"); + + expect(wrapper.emitted("update:modelValue")).toStrictEqual([[options[1]?.value]]); + expect(wrapper.get(".single-value").text()).toBe(options[1]?.label); + }); + + it("should not select focused option when component loses focus with selectOnBlur disabled", async () => { + const wrapper = mount(VueSelect, { props: { modelValue: null, options, selectOnBlur: false } }); + + await openMenu(wrapper); + await dispatchEvent(wrapper, new KeyboardEvent("keydown", { key: "ArrowDown" })); + + expect(wrapper.get(".focused[role='option']").text()).toBe(options[1]?.label); + + await wrapper.get("input").trigger("blur"); + + expect(wrapper.emitted("update:modelValue")).toBeUndefined(); + expect(wrapper.find(".single-value").exists()).toBe(false); + }); + + it("should not select disabled option when component loses focus", async () => { + const options = [ + { label: "France", value: "FR" }, + { label: "Spain", value: "ES", disabled: true }, + { label: "United Kingdom", value: "GB" }, + ]; + const wrapper = mount(VueSelect, { props: { modelValue: null, options, selectOnBlur: true } }); + + await openMenu(wrapper); + + // Manually set focus to the disabled option to test the behavior + const disabledOptionIndex = options.findIndex((option) => option.disabled); + // Access the internal focusedOption ref directly + (wrapper.vm as any).focusedOption = disabledOptionIndex; + + expect(wrapper.get(".focused[role='option']").text()).toBe("Spain"); + + await wrapper.get("input").trigger("blur"); + + expect(wrapper.emitted("update:modelValue")).toBeUndefined(); + expect(wrapper.find(".single-value").exists()).toBe(false); + }); + + it("should navigate to first option with Page Up key", async () => { + const wrapper = mount(VueSelect, { props: { modelValue: null, options } }); + + await openMenu(wrapper); + await dispatchEvent(wrapper, new KeyboardEvent("keydown", { key: "ArrowDown" })); + await dispatchEvent(wrapper, new KeyboardEvent("keydown", { key: "ArrowDown" })); + + expect(wrapper.get(".focused[role='option']").text()).toBe(options[2]?.label); + + await dispatchEvent(wrapper, new KeyboardEvent("keydown", { key: "PageUp" })); + + expect(wrapper.get(".focused[role='option']").text()).toBe(options[0]?.label); + }); + + it("should navigate to last option with Page Down key", async () => { + const wrapper = mount(VueSelect, { props: { modelValue: null, options } }); + + await openMenu(wrapper); + + expect(wrapper.get(".focused[role='option']").text()).toBe(options[0]?.label); + + await dispatchEvent(wrapper, new KeyboardEvent("keydown", { key: "PageDown" })); + + expect(wrapper.get(".focused[role='option']").text()).toBe(options[options.length - 1]?.label); + }); + + it("should navigate to first available option with Page Up when first option is disabled", async () => { + const options = [ + { label: "Spain", value: "ES", disabled: true }, + { label: "France", value: "FR" }, + { label: "United Kingdom", value: "GB" }, + ]; + const wrapper = mount(VueSelect, { props: { modelValue: null, options } }); + + await openMenu(wrapper); + await dispatchEvent(wrapper, new KeyboardEvent("keydown", { key: "ArrowDown" })); + + expect(wrapper.get(".focused[role='option']").text()).toBe("United Kingdom"); + + await dispatchEvent(wrapper, new KeyboardEvent("keydown", { key: "PageUp" })); + + expect(wrapper.get(".focused[role='option']").text()).toBe("France"); + }); + + it("should navigate to last available option with Page Down when last option is disabled", async () => { + const options = [ + { label: "France", value: "FR" }, + { label: "United Kingdom", value: "GB" }, + { label: "Spain", value: "ES", disabled: true }, + ]; + const wrapper = mount(VueSelect, { props: { modelValue: null, options } }); + + await openMenu(wrapper); + + expect(wrapper.get(".focused[role='option']").text()).toBe("France"); + + await dispatchEvent(wrapper, new KeyboardEvent("keydown", { key: "PageDown" })); + + expect(wrapper.get(".focused[role='option']").text()).toBe("United Kingdom"); + }); + + it("should work with multi-select mode", async () => { + const wrapper = mount(VueSelect, { props: { modelValue: [], isMulti: true, options, selectOnBlur: true } }); + + await openMenu(wrapper); + await dispatchEvent(wrapper, new KeyboardEvent("keydown", { key: "ArrowDown" })); + + expect(wrapper.get(".focused[role='option']").text()).toBe(options[1]?.label); + + await wrapper.get("input").trigger("blur"); + + expect(wrapper.emitted("update:modelValue")).toStrictEqual([[[options[1]?.value]]]); + expect(wrapper.get(".multi-value").text()).toBe(options[1]?.label); + }); +}); + describe("exposed component methods and refs", () => { it("should expose inputRef for direct DOM access", async () => { const wrapper = mount(VueSelect, { props: { modelValue: null, options } }); diff --git a/src/Select.vue b/src/Select.vue index a70fee0..d1a29c0 100644 --- a/src/Select.vue +++ b/src/Select.vue @@ -33,6 +33,7 @@ const props = withDefaults( aria: undefined, disableInvalidVModelWarn: false, inputAttrs: undefined, + selectOnBlur: true, filterBy: (option: GenericOption, label: string, search: string) => label.toLowerCase().includes(search.toLowerCase()), getOptionValue: (option: GenericOption) => option.value, getOptionLabel: (option: GenericOption) => option.label, @@ -289,6 +290,23 @@ const handleInputKeydown = (e: KeyboardEvent) => { e.stopImmediatePropagation(); openMenu(); } + else if ((e.key === "ArrowDown" || e.key === "ArrowUp") && !menuOpen.value) { + e.preventDefault(); + e.stopImmediatePropagation(); + openMenu(); + } +}; + +const handleInputBlur = () => { + if (props.selectOnBlur && menuOpen.value && focusedOption.value >= 0) { + const focusedOptionData = availableOptions.value[focusedOption.value]; + + if (focusedOptionData && !focusedOptionData.disabled) { + setOption(focusedOptionData); + } + } + + closeMenu(); }; provide(PROPS_KEY, props); @@ -453,6 +471,7 @@ watch( placeholder="" @mousedown="handleInputMousedown" @keydown="handleInputKeydown" + @blur="handleInputBlur" > diff --git a/src/types/props.ts b/src/types/props.ts index 9c8f422..bbb4592 100644 --- a/src/types/props.ts +++ b/src/types/props.ts @@ -155,4 +155,10 @@ export type Props = { * Useful for form integration (tabindex, autocomplete, required, etc.). */ inputAttrs?: Record>; + + /** + * When set to true, selecting the focused option when the component loses focus. + * This is useful for WAI-ARIA compliance. Defaults to true. + */ + selectOnBlur?: boolean; }; From 799eb3abfe166e97154cd4f7a2b4cb277322109e Mon Sep 17 00:00:00 2001 From: Thomas Cazade Date: Sun, 26 Oct 2025 09:56:53 +0100 Subject: [PATCH 2/3] chore: update package-lock --- package-lock.json | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5ce2b23..95fe574 100644 --- a/package-lock.json +++ b/package-lock.json @@ -206,7 +206,6 @@ "integrity": "sha512-/IYpF10BpthGZEJQZMhMqV4AqWr5avcWfZm/SIKK1RvUDmzGqLoW/+xeJVX9C8ZnNkIC8hivbIQFaNaRw0BFZQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@algolia/client-common": "5.39.0", "@algolia/requester-browser-xhr": "5.39.0", @@ -511,7 +510,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -1097,7 +1095,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1144,7 +1141,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2783,7 +2779,6 @@ "integrity": "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "json-schema-traverse": "^1.0.0", @@ -3035,7 +3030,6 @@ "integrity": "sha512-IeZF+8H0ns6prg4VrkhgL+yrvDXWDH2cKchrbh80ejG9dQgZWp10epHMbgRuQvgchLII/lfh6Xn3lu6+6L86Hw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.9.0", "@typescript-eslint/types": "^8.46.1", @@ -3247,7 +3241,6 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -3748,7 +3741,6 @@ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.22.tgz", "integrity": "sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==", "license": "MIT", - "peer": true, "dependencies": { "@babel/parser": "^7.28.4", "@vue/compiler-core": "3.5.22", @@ -4122,7 +4114,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4215,7 +4206,6 @@ "integrity": "sha512-DzTfhUxzg9QBNGzU/0kZkxEV72TeA4MmPJ7RVfLnQwHNhhliPo7ynglEWJS791rNlLFoTyrKvkapwr/P3EXV9A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@algolia/abtesting": "1.5.0", "@algolia/client-abtesting": "5.39.0", @@ -4453,7 +4443,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -5287,7 +5276,6 @@ "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6192,7 +6180,6 @@ "integrity": "sha512-7Ke1jyybbbPZyZXFxEftUtxFGLMpE2n6A+z//m4CRDlj0hW+o3iYSmh8nFlYMurOiJVDmJRilUQtJr08KfIxlg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tabbable": "^6.2.0" } @@ -7068,7 +7055,6 @@ "integrity": "sha512-uuPNLJkKN8NXAlZlQ6kmUF9qO+T6Kyd7oV4+/7yy8Jz6+MZNyhPq8EdLpdfnPVzUC8qSf1b4j1azKaGnFsjmsw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "acorn": "^8.5.0", "eslint-visitor-keys": "^3.0.0", @@ -8836,7 +8822,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -9171,7 +9156,6 @@ "integrity": "sha512-RIDh866U8agLgiIcdpB+COKnlCreHJLfIhWC3LVflku5YHfpnsIKigRZeFfMfCc4dVcqNVfQQ5gO/afOck064A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -9963,7 +9947,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10170,7 +10153,6 @@ "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -10873,7 +10855,6 @@ "integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -10934,7 +10915,6 @@ "integrity": "sha512-IUSop8jgaT7w0g1yOM/35qVtKjr/8Va4PrjzH1OUb0YH4c3OXB2lCZDkMAB6glA8T5w8S164oJGsbcmAecr4sA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.3", "@vitest/mocker": "4.0.3", @@ -11026,7 +11006,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz", "integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.22", "@vue/compiler-sfc": "3.5.22", @@ -11056,7 +11035,6 @@ "integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0", From 735807b165602055271bf7c749634b13cdfc6999 Mon Sep 17 00:00:00 2001 From: Thomas Cazade Date: Sun, 26 Oct 2025 10:00:37 +0100 Subject: [PATCH 3/3] test: missing nextTick on failing test --- src/Select.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Select.spec.ts b/src/Select.spec.ts index ff11d92..4f47541 100644 --- a/src/Select.spec.ts +++ b/src/Select.spec.ts @@ -1002,6 +1002,7 @@ describe("WAI-ARIA compliance keyboard behaviors", () => { const disabledOptionIndex = options.findIndex((option) => option.disabled); // Access the internal focusedOption ref directly (wrapper.vm as any).focusedOption = disabledOptionIndex; + await wrapper.vm.$nextTick(); expect(wrapper.get(".focused[role='option']").text()).toBe("Spain");