Skip to content

Commit 217abce

Browse files
authored
Merge pull request #2 from TotomInc/fix/accessibility-issues
Accessibility improvements (keyboard navigation & aria attributes)
2 parents 51175ac + 5c9b910 commit 217abce

File tree

4 files changed

+102
-25
lines changed

4 files changed

+102
-25
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ Teleport the menu outside of the component DOM tree. You can pass a valid string
102102

103103
**Note**: top and left properties are calculated using a ref on the `.vue-select` with a `container.getBoundingClientRect()`.
104104

105+
**aria**: `{ labelledby?: string; required?: boolean; }` (default: `undefined`)
106+
107+
Aria attributes to be passed to the select control to improve accessibility.
108+
105109
**getOptionLabel**: `(option: Option) => string` (default: `option => option.label`)
106110

107111
A function to get the label of an option. This is useful when you want to use a property different from `label` as the label of the option.

src/MenuOption.vue

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
<script setup lang="ts">
2-
import { nextTick, ref, watch } from "vue";
2+
import { ref, watch } from "vue";
33
44
const props = defineProps<{
5+
menu: HTMLDivElement | null;
6+
index: number;
57
isFocused: boolean;
68
isSelected: boolean;
79
}>();
@@ -15,29 +17,38 @@ const option = ref<HTMLButtonElement | null>(null);
1517
// Scroll the focused option into view when it's out of the menu's viewport.
1618
watch(
1719
() => props.isFocused,
18-
async () => {
19-
if (props.isFocused) {
20-
// Use nextTick to wait for the next DOM render.
21-
await nextTick(() => {
22-
option.value?.parentElement?.scrollTo({
23-
top: option.value?.offsetTop - option.value?.parentElement?.offsetHeight + option.value?.offsetHeight,
24-
behavior: "instant",
25-
});
26-
});
20+
() => {
21+
if (props.isFocused && props.menu) {
22+
// Get child element with index
23+
const option = props.menu.children[props.index] as HTMLDivElement;
24+
25+
const optionTop = option.offsetTop;
26+
const optionBottom = optionTop + option.clientHeight;
27+
const menuScrollTop = props.menu.scrollTop;
28+
const menuHeight = props.menu.clientHeight;
29+
30+
if (optionTop < menuScrollTop) {
31+
// eslint-disable-next-line vue/no-mutating-props
32+
props.menu.scrollTop = optionTop;
33+
}
34+
else if (optionBottom > menuScrollTop + menuHeight) {
35+
// eslint-disable-next-line vue/no-mutating-props
36+
props.menu.scrollTop = optionBottom - menuHeight;
37+
}
2738
}
2839
},
2940
);
3041
</script>
3142

3243
<template>
33-
<button
44+
<div
3445
ref="option"
35-
type="button"
36-
class="menu-option"
3746
tabindex="-1"
47+
role="option"
3848
:class="{ focused: isFocused, selected: isSelected }"
49+
:aria-disabled="false"
3950
@click="emit('select')"
4051
>
4152
<slot />
42-
</button>
53+
</div>
4354
</template>

src/Select.vue

Lines changed: 72 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
2+
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
33
44
import type { Option } from "./types";
55
import ChevronDownIcon from "./icons/ChevronDownIcon.vue";
@@ -45,6 +45,13 @@ const props = withDefaults(
4545
* JavaScript, instead of using CSS absolute & relative positioning.
4646
*/
4747
teleport?: string;
48+
/**
49+
* ARIA attributes to describe the select component. This is useful for accessibility.
50+
*/
51+
aria?: {
52+
labelledby?: string;
53+
required?: boolean;
54+
};
4855
/**
4956
* A function to get the label of an option. By default, it assumes the option is an
5057
* object with a `label` property. Used to display the selected option in the input &
@@ -70,6 +77,7 @@ const props = withDefaults(
7077
isMulti: false,
7178
closeOnSelect: true,
7279
teleport: undefined,
80+
aria: undefined,
7381
getOptionLabel: (option: Option) => option.label,
7482
getMultiValueLabel: (option: Option) => option.label,
7583
},
@@ -90,8 +98,9 @@ const selected = defineModel<string | string[]>({
9098
},
9199
});
92100
93-
const container = ref<HTMLElement | null>(null);
101+
const container = ref<HTMLDivElement | null>(null);
94102
const input = ref<HTMLInputElement | null>(null);
103+
const menu = ref<HTMLDivElement | null>(null);
95104
96105
const search = ref("");
97106
const menuOpen = ref(false);
@@ -121,7 +130,7 @@ const filteredOptions = computed(() => {
121130
122131
const selectedOptions = computed(() => {
123132
if (props.isMulti) {
124-
return props.options.filter((option) => (selected.value as string[]).includes(option.value));
133+
return (selected.value as string[]).map((value) => props.options.find((option) => option.value === value)!);
125134
}
126135
127136
const found = props.options.find((option) => option.value === selected.value);
@@ -138,10 +147,9 @@ const openMenu = (options?: { focusInput?: boolean }) => {
138147
}
139148
};
140149
141-
const focusInput = () => {
142-
if (input.value) {
143-
input.value.focus();
144-
}
150+
const closeMenu = () => {
151+
menuOpen.value = false;
152+
search.value = "";
145153
};
146154
147155
const setOption = (value: string) => {
@@ -202,11 +210,43 @@ const handleNavigation = (e: KeyboardEvent) => {
202210
setOption(filteredOptions.value[focusedOption.value].value);
203211
}
204212
213+
// When pressing space with menu open but no search, select the focused option.
214+
if (e.code === "Space" && search.value.length === 0) {
215+
e.preventDefault();
216+
setOption(filteredOptions.value[focusedOption.value].value);
217+
}
218+
205219
if (e.key === "Escape") {
206220
e.preventDefault();
207221
menuOpen.value = false;
208222
search.value = "";
209223
}
224+
225+
// When pressing backspace with no search, remove the last selected option.
226+
if (e.key === "Backspace" && search.value.length === 0 && selected.value.length > 0) {
227+
e.preventDefault();
228+
229+
if (props.isMulti) {
230+
selected.value = (selected.value as string[]).slice(0, -1);
231+
}
232+
else {
233+
selected.value = "";
234+
}
235+
}
236+
}
237+
};
238+
239+
/**
240+
* When pressing space inside the input, open the menu only if the search is
241+
* empty. Otherwise, the user is typing and we should skip this action.
242+
*
243+
* @param e KeyboardEvent
244+
*/
245+
const handleInputSpace = (e: KeyboardEvent) => {
246+
if (!menuOpen.value && search.value.length === 0) {
247+
e.preventDefault();
248+
e.stopImmediatePropagation();
249+
openMenu();
210250
}
211251
};
212252
@@ -232,6 +272,16 @@ const calculateMenuPosition = () => {
232272
return { top: "0px", left: "0px" };
233273
};
234274
275+
// When focusing the input and typing, open the menu automatically.
276+
watch(
277+
() => search.value,
278+
() => {
279+
if (search.value && !menuOpen.value) {
280+
openMenu();
281+
}
282+
},
283+
);
284+
235285
onMounted(() => {
236286
document.addEventListener("click", handleClickOutside);
237287
document.addEventListener("keydown", handleNavigation);
@@ -256,12 +306,16 @@ onBeforeUnmount(() => {
256306
:class="{ multi: isMulti }"
257307
role="combobox"
258308
:aria-expanded="menuOpen"
259-
:aria-label="placeholder"
309+
:aria-describedby="placeholder"
310+
:aria-description="placeholder"
311+
:aria-labelledby="aria?.labelledby"
312+
:aria-label="selectedOptions.length ? selectedOptions.map(getOptionLabel).join(', ') : ''"
313+
:aria-required="aria?.required"
260314
>
261315
<div
262316
v-if="!props.isMulti && selectedOptions[0]"
263317
class="single-value"
264-
@click="focusInput"
318+
@click="input?.focus()"
265319
>
266320
<slot name="value" :option="selectedOptions[0]">
267321
{{ getOptionLabel(selectedOptions[0]) }}
@@ -294,7 +348,9 @@ onBeforeUnmount(() => {
294348
tabindex="0"
295349
:disabled="isDisabled"
296350
:placeholder="selectedOptions.length === 0 ? placeholder : ''"
297-
@focus="openMenu({ focusInput: false })"
351+
@mousedown="openMenu()"
352+
@keydown.tab="closeMenu"
353+
@keydown.space="handleInputSpace"
298354
>
299355
</div>
300356

@@ -329,7 +385,11 @@ onBeforeUnmount(() => {
329385
<Teleport :to="teleport" :disabled="!teleport">
330386
<div
331387
v-if="menuOpen"
388+
ref="menu"
332389
class="menu"
390+
role="listbox"
391+
:aria-label="aria?.labelledby"
392+
:aria-multiselectable="isMulti"
333393
:style="{
334394
width: props.teleport ? `${container?.getBoundingClientRect().width}px` : '100%',
335395
top: props.teleport ? calculateMenuPosition().top : 'unset',
@@ -342,6 +402,8 @@ onBeforeUnmount(() => {
342402
type="button"
343403
class="menu-option"
344404
:class="{ focused: focusedOption === i, selected: option.value === selected }"
405+
:menu="menu"
406+
:index="i"
345407
:is-focused="focusedOption === i"
346408
:is-selected="option.value === selected"
347409
@select="setOption(option.value)"

website/Website.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import CustomOption from "./CustomOption.vue";
1717
<a
1818
href="https://github.com/TotomInc/vue3-select-component"
1919
target="_blank"
20-
class="mt-4 border border-neutral-200 bg-white rounded text-sm font-medium px-4 py-2 text-neutral-950 flex items-center self-start mx-auto hover:bg-neutral-50 focus:outline-none gap-1.5"
20+
class="mt-4 border border-neutral-200 bg-white rounded text-sm font-medium px-4 py-2 text-neutral-950 flex items-center self-start mx-auto hover:bg-neutral-50 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-1 gap-1.5"
2121
>
2222
View docs on GitHub
2323
<svg

0 commit comments

Comments
 (0)