Skip to content

Commit ca09256

Browse files
committed
feat(select): wai-aria keyboard navigation improvements
1 parent 782bcb6 commit ca09256

File tree

8 files changed

+433
-0
lines changed

8 files changed

+433
-0
lines changed

docs/props.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,3 +339,17 @@ The label of an option is displayed in the dropdown and as the selected option (
339339
Resolves option data to a string to compare options and specify value attributes.
340340

341341
This function can be used if you don't want to use the standard `option.value` as the value of the option.
342+
343+
## selectOnBlur
344+
345+
**Type**: `boolean`
346+
347+
**Default**: `true`
348+
349+
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.
350+
351+
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.
352+
353+
::: info
354+
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.
355+
:::

playground/PlaygroundLayout.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const links = [
1818
{ value: "/controlled-menu", label: "Controlled Menu" },
1919
{ value: "/menu-header", label: "Menu Header" },
2020
{ value: "/menu-positioning", label: "Menu Positioning" },
21+
{ value: "/keyboard-navigation", label: "Keyboard Navigation" },
2122
];
2223
2324
const router = useRouter();
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
<script setup lang="ts">
2+
import { ref } from "vue";
3+
import VueSelect from "../../src/Select.vue";
4+
5+
const selectedCountry = ref<string | null>(null);
6+
const selectedCountries = ref<string[]>([]);
7+
const manualSelection = ref<string | null>(null);
8+
9+
const countries = [
10+
{ label: "France", value: "FR" },
11+
{ label: "Germany", value: "DE" },
12+
{ label: "Italy", value: "IT" },
13+
{ label: "Spain", value: "ES" },
14+
{ label: "United Kingdom", value: "GB" },
15+
{ label: "United States", value: "US" },
16+
{ label: "Canada", value: "CA" },
17+
{ label: "Australia", value: "AU" },
18+
{ label: "Japan", value: "JP" },
19+
{ label: "Brazil", value: "BR" },
20+
{ label: "India", value: "IN" },
21+
{ label: "China", value: "CN" },
22+
];
23+
</script>
24+
25+
<template>
26+
<div class="keyboard-navigation-demo">
27+
<h2>WAI-ARIA Keyboard Navigation Demo</h2>
28+
29+
<div class="demo-section">
30+
<h3>Basic Keyboard Navigation</h3>
31+
<p>Try these keyboard interactions:</p>
32+
<ul>
33+
<li><strong>Focus the input</strong> and press <kbd>↑</kbd> or <kbd>↓</kbd> to open the dropdown</li>
34+
<li><strong>Navigate</strong> with <kbd>↑</kbd> and <kbd>↓</kbd> arrow keys</li>
35+
<li><strong>Jump to first/last</strong> with <kbd>Page Up</kbd> and <kbd>Page Down</kbd></li>
36+
<li><strong>Select</strong> with <kbd>Enter</kbd> or <kbd>Space</kbd></li>
37+
<li><strong>Auto-select on blur</strong> - navigate to an option and click outside to select it</li>
38+
</ul>
39+
40+
<VueSelect
41+
v-model="selectedCountry"
42+
:options="countries"
43+
placeholder="Select a country (try keyboard navigation)"
44+
class="demo-select"
45+
/>
46+
47+
<div v-if="selectedCountry" class="selected-info">
48+
<strong>Selected:</strong> {{ selectedCountry }}
49+
</div>
50+
</div>
51+
52+
<div class="demo-section">
53+
<h3>Multi-Select with Keyboard Navigation</h3>
54+
<p>Same keyboard interactions work with multi-select:</p>
55+
56+
<VueSelect
57+
v-model="selectedCountries"
58+
:options="countries"
59+
:is-multi="true"
60+
placeholder="Select multiple countries"
61+
class="demo-select"
62+
/>
63+
64+
<div v-if="selectedCountries.length" class="selected-info">
65+
<strong>Selected:</strong> {{ selectedCountries.join(', ') }}
66+
</div>
67+
</div>
68+
69+
<div class="demo-section">
70+
<h3>Disabled Auto-Select on Blur</h3>
71+
<p>With <code>selectOnBlur: false</code>, you must explicitly select options:</p>
72+
73+
<VueSelect
74+
v-model="manualSelection"
75+
:options="countries"
76+
:select-on-blur="false"
77+
placeholder="Navigate and click outside - no auto-selection"
78+
class="demo-select"
79+
/>
80+
81+
<div v-if="manualSelection" class="selected-info">
82+
<strong>Selected:</strong> {{ manualSelection }}
83+
</div>
84+
</div>
85+
86+
<div class="demo-section">
87+
<h3>Keyboard Shortcuts Reference</h3>
88+
<div class="shortcuts-grid">
89+
<div class="shortcut-item">
90+
<kbd>↑</kbd> / <kbd>↓</kbd>
91+
<span>Open dropdown (when closed) or navigate options</span>
92+
</div>
93+
<div class="shortcut-item">
94+
<kbd>Page Up</kbd>
95+
<span>Jump to first option</span>
96+
</div>
97+
<div class="shortcut-item">
98+
<kbd>Page Down</kbd>
99+
<span>Jump to last option</span>
100+
</div>
101+
<div class="shortcut-item">
102+
<kbd>Enter</kbd> / <kbd>Space</kbd>
103+
<span>Select focused option</span>
104+
</div>
105+
<div class="shortcut-item">
106+
<kbd>Escape</kbd>
107+
<span>Close dropdown</span>
108+
</div>
109+
<div class="shortcut-item">
110+
<kbd>Tab</kbd>
111+
<span>Close dropdown and move to next element</span>
112+
</div>
113+
<div class="shortcut-item">
114+
<kbd>Backspace</kbd>
115+
<span>Remove last selected option (multi-select)</span>
116+
</div>
117+
<div class="shortcut-item">
118+
<span class="blur-action">Click outside</span>
119+
<span>Auto-select focused option (when selectOnBlur: true)</span>
120+
</div>
121+
</div>
122+
</div>
123+
</div>
124+
</template>
125+
126+
<style scoped>
127+
.keyboard-navigation-demo {
128+
max-width: 800px;
129+
margin: 0 auto;
130+
padding: 20px;
131+
}
132+
133+
.demo-section {
134+
margin-bottom: 40px;
135+
padding: 20px;
136+
border: 1px solid #e4e4e7;
137+
border-radius: 8px;
138+
background: #fafafa;
139+
}
140+
141+
.demo-section h3 {
142+
margin-top: 0;
143+
color: #18181b;
144+
}
145+
146+
.demo-select {
147+
margin: 16px 0;
148+
}
149+
150+
.selected-info {
151+
margin-top: 12px;
152+
padding: 8px 12px;
153+
background: #dbeafe;
154+
border-radius: 4px;
155+
color: #1e40af;
156+
}
157+
158+
.shortcuts-grid {
159+
display: grid;
160+
grid-template-columns: 1fr;
161+
gap: 12px;
162+
margin-top: 16px;
163+
}
164+
165+
.shortcut-item {
166+
display: flex;
167+
align-items: center;
168+
gap: 12px;
169+
padding: 8px 12px;
170+
background: white;
171+
border-radius: 4px;
172+
border: 1px solid #e4e4e7;
173+
}
174+
175+
.shortcut-item kbd {
176+
display: inline-block;
177+
padding: 2px 6px;
178+
background: #f4f4f5;
179+
border: 1px solid #d4d4d8;
180+
border-radius: 3px;
181+
font-family: monospace;
182+
font-size: 12px;
183+
min-width: 20px;
184+
text-align: center;
185+
}
186+
187+
.blur-action {
188+
display: inline-block;
189+
padding: 2px 6px;
190+
background: #fef3c7;
191+
border: 1px solid #f59e0b;
192+
border-radius: 3px;
193+
font-size: 12px;
194+
color: #92400e;
195+
}
196+
197+
ul {
198+
margin: 12px 0;
199+
padding-left: 20px;
200+
}
201+
202+
li {
203+
margin: 8px 0;
204+
}
205+
206+
code {
207+
background: #f4f4f5;
208+
padding: 2px 4px;
209+
border-radius: 3px;
210+
font-family: monospace;
211+
font-size: 14px;
212+
}
213+
</style>

playground/main.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import CustomPlaceholder from "./demos/CustomPlaceholder.vue";
77
import CustomSearchFilter from "./demos/CustomSearchFilter.vue";
88
import CustomTagContent from "./demos/CustomTagContent.vue";
99
import ExtraOptionProperties from "./demos/ExtraOptionProperties.vue";
10+
import KeyboardNavigation from "./demos/KeyboardNavigation.vue";
1011
import MenuHeader from "./demos/MenuHeader.vue";
1112
import MenuPositioning from "./demos/MenuPositioning.vue";
1213
import MultiSelect from "./demos/MultiSelect.vue";
@@ -33,6 +34,7 @@ const router = createRouter({
3334
{ path: "/controlled-menu", component: ControlledMenu },
3435
{ path: "/menu-header", component: MenuHeader },
3536
{ path: "/menu-positioning", component: MenuPositioning },
37+
{ path: "/keyboard-navigation", component: KeyboardNavigation },
3638
],
3739
});
3840

src/Menu.vue

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,25 @@ const handleNavigation = (e: KeyboardEvent) => {
9696
sharedData.closeMenu();
9797
}
9898
99+
if (e.key === "PageDown") {
100+
e.preventDefault();
101+
102+
const lastOptionIndex = sharedData.availableOptions.value.reduce(
103+
(acc, option, i) => (!option.disabled ? i : acc),
104+
-1,
105+
);
106+
107+
sharedData.focusedOption.value = lastOptionIndex;
108+
}
109+
110+
if (e.key === "PageUp") {
111+
e.preventDefault();
112+
113+
const firstOptionIndex = sharedData.availableOptions.value.findIndex((option) => !option.disabled);
114+
115+
sharedData.focusedOption.value = firstOptionIndex;
116+
}
117+
99118
const hasSelectedValue = sharedProps.isMulti && Array.isArray(selected.value) ? selected.value.length > 0 : !!selected.value;
100119
101120
// When pressing backspace with no search, remove the last selected option.

0 commit comments

Comments
 (0)