Skip to content

Commit 060b487

Browse files
committed
feat: a11y improvements
1 parent b769cec commit 060b487

File tree

6 files changed

+178
-78
lines changed

6 files changed

+178
-78
lines changed

src/Multiselect.vue

Lines changed: 52 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,19 @@
55
:class="classList.container"
66
:id="searchable ? undefined : id"
77
:dir="rtl ? 'rtl' : undefined"
8+
@focusin="handleFocusIn"
9+
@focusout="handleFocusOut"
10+
@keydown="handleKeydown"
11+
@keyup="handleKeyup"
12+
@mousedown="handleMousedown"
13+
814
:aria-owns="ariaOwns"
9-
:aria-expanded="isOpen"
1015
:aria-label="ariaLabel"
1116
:aria-placeholder="ariaPlaceholder"
17+
:aria-expanded="isOpen"
1218
:aria-activedescendant="ariaActiveDescendant"
13-
@focusin="activate"
14-
@focusout="deactivate"
15-
@keydown="handleKeydown"
16-
@keyup="handleKeyup"
17-
@focus="handleFocus"
18-
@mousedown="handleMousedown"
19-
role="combobox"
19+
:aria-multiselectable="ariaMultiselectable"
20+
role="listbox"
2021
>
2122
<!-- Search -->
2223
<template v-if="mode !== 'tags' && searchable && !disabled">
@@ -28,16 +29,18 @@
2829
:autocomplete="autocomplete"
2930
:id="searchable ? id : undefined"
3031
v-bind="attrs"
31-
:aria-owns="ariaOwns"
32-
:aria-expanded="isOpen"
33-
:aria-label="ariaLabel"
34-
:aria-placeholder="ariaPlaceholder"
35-
:aria-activedescendant="ariaActiveDescendant"
3632
@input="handleSearchInput"
3733
@keypress="handleKeypress"
3834
@paste.stop="handlePaste"
3935
ref="input"
40-
role="combobox"
36+
37+
:aria-owns="ariaOwns"
38+
:aria-label="ariaLabel"
39+
:aria-placeholder="ariaPlaceholder"
40+
:aria-expanded="isOpen"
41+
:aria-activedescendant="ariaActiveDescendant"
42+
:aria-multiselectable="ariaMultiselectable"
43+
role="listbox"
4144
/>
4245
</template>
4346

@@ -56,6 +59,8 @@
5659
tabindex="-1"
5760
@keyup.enter="handleTagRemove(option, $event)"
5861
:key="key"
62+
63+
:aria-label="ariaTagLabel(option[label])"
5964
>
6065
{{ option[label] }}
6166
<span
@@ -82,16 +87,18 @@
8287
:id="searchable ? id : undefined"
8388
:autocomplete="autocomplete"
8489
v-bind="attrs"
85-
:aria-owns="ariaOwns"
86-
:aria-expanded="isOpen"
87-
:aria-label="ariaLabel"
88-
:aria-placeholder="ariaPlaceholder"
89-
:aria-activedescendant="ariaActiveDescendant"
9090
@input="handleSearchInput"
9191
@keypress="handleKeypress"
9292
@paste.stop="handlePaste"
9393
ref="input"
94-
role="combobox"
94+
95+
:aria-owns="ariaOwns"
96+
:aria-label="ariaLabel"
97+
:aria-placeholder="ariaPlaceholder"
98+
:aria-expanded="isOpen"
99+
:aria-activedescendant="ariaActiveDescendant"
100+
:aria-multiselectable="ariaMultiselectable"
101+
role="listbox"
95102
/>
96103
</div>
97104
</div>
@@ -100,7 +107,7 @@
100107
<!-- Single label -->
101108
<template v-if="mode == 'single' && hasSelected && !search && iv">
102109
<slot name="singlelabel" :value="iv">
103-
<div :class="classList.singleLabel">
110+
<div :class="classList.singleLabel" aria-hidden="true">
104111
<span :class="classList.singleLabelText" v-html="iv[label]"></span>
105112
</div>
106113
</slot>
@@ -109,28 +116,30 @@
109116
<!-- Multiple label -->
110117
<template v-if="mode == 'multiple' && hasSelected && !search">
111118
<slot name="multiplelabel" :values="iv">
112-
<div :class="classList.multipleLabel" v-html="multipleLabelText"></div>
119+
<div :class="classList.multipleLabel" v-html="multipleLabelText" aria-hidden="true"></div>
113120
</slot>
114121
</template>
115122

116123
<!-- Placeholder -->
117124
<template v-if="placeholder && !hasSelected && !search">
118125
<slot name="placeholder">
119-
<div :class="classList.placeholder">
126+
<div :class="classList.placeholder" aria-hidden="true">
120127
{{ placeholder }}
121128
</div>
122129
</slot>
123130
</template>
124131

125132
<!-- Spinner -->
126133
<slot v-if="loading || resolving" name="spinner">
127-
<span :class="classList.spinner"></span>
134+
<span :class="classList.spinner" aria-hidden="true"></span>
128135
</slot>
129136

130137
<!-- Clear -->
131138
<slot v-if="hasSelected && !disabled && canClear && !busy" name="clear" :clear="clear">
132139
<span
133140
tabindex="0"
141+
role="button"
142+
aria-label=""
134143
:class="classList.clear"
135144
@click="clear"
136145
@keyup.enter="clear"
@@ -139,7 +148,7 @@
139148

140149
<!-- Caret -->
141150
<slot v-if="caret && showOptions" name="caret">
142-
<span :class="classList.caret" @click="handleCaretClick"></span>
151+
<span :class="classList.caret" @click="handleCaretClick" aria-hidden="true"></span>
143152
</slot>
144153

145154
<!-- Options -->
@@ -149,19 +158,23 @@
149158
>
150159
<slot name="beforelist" :options="fo"></slot>
151160

152-
<ul :class="classList.options" :id="ariaOwns" role="listbox">
161+
<ul :class="classList.options" :id="ariaOwns">
153162
<template v-if="groups">
154163
<li
155164
v-for="(group, i, key) in fg"
156165
:class="classList.group"
157166
:key="key"
167+
168+
:id="ariaGroupId(group, i)"
169+
:aria-label="ariaGroupLabel(group)"
170+
:aria-selected="isSelected(group)"
171+
role="option"
158172
>
159173
<div
160174
:class="classList.groupLabel(group)"
161175
:data-pointed="isPointed(group)"
162-
@mouseenter="setPointer(group)"
176+
@mouseenter="setPointer(group, i)"
163177
@click="handleGroupClick(group)"
164-
role="none"
165178
>
166179
<slot name="grouplabel" :group="group" :is-selected="isSelected" :is-pointed="isPointed">
167180
<span v-html="group[groupLabel]"></span>
@@ -170,19 +183,22 @@
170183

171184
<ul
172185
:class="classList.groupOptions"
186+
173187
:aria-label="ariaGroupLabel(group)"
174188
role="group"
175189
>
176190
<li
177191
v-for="(option, i, key) in group.__VISIBLE__"
178192
:class="classList.option(option, group)"
179-
:key="key"
180193
:data-pointed="isPointed(option)"
181194
:data-selected="isSelected(option) || undefined"
182-
:id="ariaOptionId(option)"
183-
:aria-label="ariaOptionLabel(option)"
195+
:key="key"
184196
@mouseenter="setPointer(option)"
185197
@click="handleOptionClick(option)"
198+
199+
:id="ariaOptionId(option)"
200+
:aria-selected="isSelected(option)"
201+
:aria-label="ariaOptionLabel(option)"
186202
role="option"
187203
>
188204
<slot name="option" :option="option" :is-selected="isSelected" :is-pointed="isPointed" :search="search">
@@ -195,14 +211,16 @@
195211
<template v-else>
196212
<li
197213
v-for="(option, i, key) in fo"
198-
:id="ariaOptionId(option)"
199-
:aria-label="ariaOptionLabel(option)"
200214
:class="classList.option(option)"
201-
:key="key"
202215
:data-pointed="isPointed(option)"
203216
:data-selected="isSelected(option) || undefined"
217+
:key="key"
204218
@mouseenter="setPointer(option)"
205219
@click="handleOptionClick(option)"
220+
221+
:id="ariaOptionId(option)"
222+
:aria-selected="isSelected(option)"
223+
:aria-label="ariaOptionLabel(option)"
206224
role="option"
207225
>
208226
<slot name="option" :option="option" :isSelected="isSelected" :is-pointed="isPointed" :search="search">

src/composables/useA11y.js

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,20 @@ export default function useScroll (props, context, dep)
88

99
const pointer = dep.pointer
1010
const iv = dep.iv
11-
const isSelected = dep.isSelected
1211
const hasSelected = dep.hasSelected
1312
const multipleLabelText = dep.multipleLabelText
13+
const isOpen = dep.isOpen
1414

1515
// ================ DATA ================
1616

1717
const label = ref(null)
1818

1919
// ============== COMPUTED ==============
2020

21+
const mainRole = computed(() => {
22+
return mode.value === 'single' ? 'select' : 'combobox'
23+
})
24+
2125
const ariaOwns = computed(() => {
2226
let texts = []
2327

@@ -37,10 +41,10 @@ export default function useScroll (props, context, dep)
3741
texts.push(id.value)
3842
}
3943

40-
texts.push('multiselect-option')
44+
if (pointer.value) {
45+
texts.push(pointer.value.group ? 'multiselect-group' : 'multiselect-option')
4146

42-
if (pointer.value && pointer.value[valueProp.value] !== undefined) {
43-
texts.push(pointer.value[valueProp.value])
47+
texts.push(pointer.value.group ? pointer.value.index : pointer.value[valueProp.value])
4448

4549
return texts.join('-')
4650
}
@@ -54,20 +58,22 @@ export default function useScroll (props, context, dep)
5458
texts.push(label.value)
5559
}
5660

57-
if (placeholder.value && !hasSelected.value) {
58-
texts.push(placeholder.value)
59-
}
61+
if (!pointer.value || !isOpen.value) {
62+
if (placeholder.value && !hasSelected.value) {
63+
texts.push(placeholder.value)
64+
}
6065

61-
if (mode.value === 'single' && iv.value && iv.value[labelProp.value] !== undefined) {
62-
texts.push(iv.value[labelProp.value])
63-
}
66+
if (mode.value === 'single' && iv.value && iv.value[labelProp.value] !== undefined) {
67+
texts.push(iv.value[labelProp.value])
68+
}
6469

65-
if (mode.value === 'multiple' && hasSelected.value) {
66-
texts.push(multipleLabelText.value)
67-
}
70+
if (mode.value === 'multiple' && hasSelected.value) {
71+
texts.push(multipleLabelText.value)
72+
}
6873

69-
if (mode.value === 'tags' && hasSelected.value) {
70-
texts.push(...iv.value.map(v => v[labelProp.value]))
74+
if (mode.value === 'tags' && hasSelected.value) {
75+
texts.push(...iv.value.map(v => v[labelProp.value]))
76+
}
7177
}
7278

7379
return texts.join(', ')
@@ -77,6 +83,10 @@ export default function useScroll (props, context, dep)
7783
return ariaLabel.value
7884
})
7985

86+
const ariaMultiselectable = computed(() => {
87+
return mode.value !== 'single'
88+
})
89+
8090
// =============== METHODS ==============
8191

8292
const ariaOptionId = (option) => {
@@ -93,13 +103,23 @@ export default function useScroll (props, context, dep)
93103
return texts.join('-')
94104
}
95105

96-
const ariaOptionLabel = (option) => {
106+
const ariaGroupId = (option, index) => {
97107
let texts = []
98108

99-
if (isSelected(option)) {
100-
texts.push('✓')
109+
if (id && id.value) {
110+
texts.push(id.value)
101111
}
102112

113+
texts.push('multiselect-group')
114+
115+
texts.push(index)
116+
117+
return texts.join('-')
118+
}
119+
120+
const ariaOptionLabel = (option) => {
121+
let texts = []
122+
103123
texts.push(option[labelProp.value])
104124

105125
return texts.join(' ')
@@ -113,6 +133,10 @@ export default function useScroll (props, context, dep)
113133
return texts.join(' ')
114134
}
115135

136+
const ariaTagLabel = (label) => {
137+
return `${label} ❎`
138+
}
139+
116140
// =============== HOOKS ================
117141

118142
onMounted(() => {
@@ -124,12 +148,16 @@ export default function useScroll (props, context, dep)
124148
})
125149

126150
return {
151+
mainRole,
127152
ariaOwns,
128153
ariaLabel,
129154
ariaPlaceholder,
155+
ariaMultiselectable,
130156
ariaActiveDescendant,
131157
ariaOptionId,
132158
ariaOptionLabel,
159+
ariaGroupId,
133160
ariaGroupLabel,
161+
ariaTagLabel,
134162
}
135163
}

src/composables/useKeyboard.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export default function useKeyboard (props, context, dep)
9191
case 'Enter':
9292
e.preventDefault()
9393

94-
if (activeIndex !== -1) {
94+
if (activeIndex !== -1 && activeIndex !== undefined) {
9595
update([...iv.value].filter((v, k) => k !== activeIndex))
9696

9797
if (activeIndex === tagList.length - 1) {

0 commit comments

Comments
 (0)