Skip to content

Commit 54f9861

Browse files
fix(select): select accessibility
Improve select support to assistive technologies (e.g.: screen readers): - Label reference for created input: - Input element references orignal select label on init (if exists); - New "labelEl" attribute: points to original select element label. - Label state is restored on destroy (if used "for" attribute). - Add keyboard event handlers for element selection; - Add missing ARIA attributes to dropdown: - Dropdown now acts as listbox; - Dropdown is now "owned" by custom input; - Enhanced support to "optgroups". - Add missing ARIA attributes to dropdown elements: - Each element acts as a regular "option"; - Support to disabled elements; - Support to "selected" items. - Add missing ARIA attributes to custom input element: - Custom input should act as combobox; - Added relationship references (accessibility) to dropdown; - Added support to "expanded state". - Custom images are hidden from screen readers.
1 parent 17faa57 commit 54f9861

File tree

1 file changed

+90
-9
lines changed

1 file changed

+90
-9
lines changed

js/select.js

+90-9
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
this.isMultiple = this.$el.prop('multiple');
1616
this.el.tabIndex = -1;
1717
this._values = [];
18+
this.labelEl = null;
19+
this._labelFor = false;
1820
this._setupDropdown();
1921
this._setupEventHandlers();
2022
}
@@ -29,6 +31,8 @@
2931
return domElem.M_FormSelect;
3032
}
3133
destroy() {
34+
// Returns label to its original owner
35+
if (this._labelFor) this.labelEl.setAttribute("for", this.el.id);
3236
this._removeEventHandlers();
3337
this._removeDropdown();
3438
this.el.M_FormSelect = undefined;
@@ -41,6 +45,9 @@
4145
.find('li:not(.optgroup)')
4246
.each((el) => {
4347
el.addEventListener('click', this._handleOptionClickBound);
48+
el.addEventListener('keydown', (e) => {
49+
if (e.key === " " || e.key === "Enter") this._handleOptionClickBound(e);
50+
});
4451
});
4552
this.el.addEventListener('change', this._handleSelectChangeBound);
4653
this.input.addEventListener('click', this._handleInputClickBound);
@@ -95,7 +102,7 @@
95102
if (!this.isMultiple) this.dropdown.close();
96103
}
97104
_handleInputClick() {
98-
if (this.dropdown && this.dropdown.isOpen) {
105+
if (this.dropdown && this.dropdown.isOpen) {
99106
this._setValueToInput();
100107
this._setSelectedStates();
101108
}
@@ -119,6 +126,9 @@
119126
$(this.dropdownOptions).addClass(
120127
'dropdown-content select-dropdown ' + (this.isMultiple ? 'multiple-select-dropdown' : '')
121128
);
129+
this.dropdownOptions.setAttribute("role", "listbox");
130+
this.dropdownOptions.setAttribute("aria-required", this.$el.hasAttribute("required"));
131+
this.dropdownOptions.setAttribute("aria-multiselectable", this.isMultiple);
122132

123133
// Create dropdown structure
124134
if (this.$selectOptions.length) {
@@ -133,44 +143,94 @@
133143
} else if ($(realOption).is('optgroup')) {
134144
// Optgroup
135145
const selectOptions = $(realOption).children('option');
136-
$(this.dropdownOptions).append(
137-
$(
138-
'<li class="optgroup"><span>' + realOption.getAttribute('label') + '</span></li>'
139-
)[0]
140-
);
146+
let lId = "opt-group-" + M.guid();
147+
let groupParent = $(
148+
`<li class="optgroup" role="group" aria-labelledby="${lId}"><span id="${lId}" role="presentation">${realOption.getAttribute('label')}</span></li>`
149+
)[0];
150+
let groupChildren = [];
151+
$(this.dropdownOptions).append(groupParent);
141152
selectOptions.each((realOption) => {
142153
const virtualOption = this._createAndAppendOptionWithIcon(
143154
realOption,
144155
'optgroup-option'
145156
);
157+
let cId = "opt-child-" + M.guid();
158+
virtualOption.id = cId;
159+
groupChildren.push(cId);
146160
this._addOptionToValues(realOption, virtualOption);
147161
});
162+
groupParent.setAttribute("aria-owns", groupChildren.join(" "));
148163
}
149164
});
150165
}
151166
$(this.wrapper).append(this.dropdownOptions);
152167

153168
// Add input dropdown
154169
this.input = document.createElement('input');
170+
this.input.id = "m_select-input-" + M.guid();
155171
$(this.input).addClass('select-dropdown dropdown-trigger');
156172
this.input.setAttribute('type', 'text');
157173
this.input.setAttribute('readonly', 'true');
158174
this.input.setAttribute('data-target', this.dropdownOptions.id);
175+
this.input.setAttribute('aria-readonly', 'true');
159176
if (this.el.disabled) $(this.input).prop('disabled', 'true');
160177

178+
// Makes new element to assume HTML's select label and
179+
// aria-attributes, if exists
180+
if (this.el.hasAttribute("aria-labelledby")){
181+
this.labelEl = document.getElementById(this.el.getAttribute("aria-labelledby"));
182+
}
183+
else if (this.el.id != ""){
184+
let lbl = $(`label[for='${this.el.id}']`);
185+
if (lbl.length){
186+
this.labelEl = lbl[0];
187+
this.labelEl.removeAttribute("for");
188+
this._labelFor = true;
189+
}
190+
}
191+
// Tries to find a valid label in parent element
192+
if (!this.labelEl){
193+
let el = this.el.parentElement;
194+
if (el) el = el.getElementsByTagName("label")[0];
195+
if (el) this.labelEl = el;
196+
}
197+
if (this.labelEl && this.labelEl.id == ""){
198+
this.labelEl.id = "m_select-label-" + M.guid();
199+
}
200+
201+
if (this.labelEl){
202+
this.labelEl.setAttribute("for", this.input.id);
203+
this.dropdownOptions.setAttribute("aria-labelledby", this.labelEl.id);
204+
}
205+
else this.dropdownOptions.setAttribute("aria-label", "");
206+
207+
let attrs = this.el.attributes;
208+
for (let i = 0; i < attrs.length; ++i){
209+
const attr = attrs[i];
210+
if (attr.name.startsWith("aria-"))
211+
this.input.setAttribute(attr.name, attr.value);
212+
}
213+
214+
// Adds aria-attributes to input element
215+
this.input.setAttribute("role", "combobox");
216+
this.input.setAttribute("aria-owns", this.dropdownOptions.id);
217+
this.input.setAttribute("aria-controls", this.dropdownOptions.id);
218+
this.input.setAttribute("aria-expanded", false);
219+
161220
$(this.wrapper).prepend(this.input);
162221
this._setValueToInput();
163222

164223
// Add caret
165224
let dropdownIcon = $(
166-
'<svg class="caret" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M7 10l5 5 5-5z"/><path d="M0 0h24v24H0z" fill="none"/></svg>'
225+
'<svg class="caret" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M7 10l5 5 5-5z"/><path d="M0 0h24v24H0z" fill="none"/></svg>'
167226
);
168227
$(this.wrapper).prepend(dropdownIcon[0]);
169228
// Initialize dropdown
170229
if (!this.el.disabled) {
171230
let dropdownOptions = $.extend({}, this.options.dropdownOptions);
172231
dropdownOptions.coverTrigger = false;
173232
let userOnOpenEnd = dropdownOptions.onOpenEnd;
233+
let userOnCloseEnd = dropdownOptions.onCloseEnd;
174234
// Add callback for centering selected option when dropdown content is scrollable
175235
dropdownOptions.onOpenEnd = (el) => {
176236
let selectedOption = $(this.dropdownOptions)
@@ -191,10 +251,20 @@
191251
this.dropdownOptions.scrollTop = scrollOffset;
192252
}
193253
}
254+
// Sets "aria-expanded" to "true"
255+
this.input.setAttribute("aria-expanded", true);
194256
// Handle user declared onOpenEnd if needed
195257
if (userOnOpenEnd && typeof userOnOpenEnd === 'function')
196258
userOnOpenEnd.call(this.dropdown, this.el);
197259
};
260+
// Add callback for reseting "expanded" state
261+
dropdownOptions.onCloseEnd = (el) => {
262+
// Sets "aria-expanded" to "false"
263+
this.input.setAttribute("aria-expanded", false);
264+
// Handle user declared onOpenEnd if needed
265+
if (userOnCloseEnd && typeof userOnCloseEnd === 'function')
266+
userOnCloseEnd.call(this.dropdown, this.el);
267+
};
198268
// Prevent dropdown from closing too early
199269
dropdownOptions.closeOnClick = false;
200270
this.dropdown = M.Dropdown.init(this.input, dropdownOptions);
@@ -216,7 +286,11 @@
216286
}
217287
_createAndAppendOptionWithIcon(realOption, type) {
218288
const li = document.createElement('li');
219-
if (realOption.disabled) li.classList.add('disabled');
289+
li.setAttribute("role", "option");
290+
if (realOption.disabled){
291+
li.classList.add('disabled');
292+
li.setAttribute("aria-disabled", true);
293+
}
220294
if (type === 'optgroup-option') li.classList.add(type);
221295
// Text / Checkbox
222296
const span = document.createElement('span');
@@ -231,6 +305,7 @@
231305
const classes = realOption.getAttribute('class');
232306
if (iconUrl) {
233307
const img = $(`<img alt="" class="${classes}" src="${iconUrl}">`);
308+
img[0].setAttribute("aria-hidden", true);
234309
li.prepend(img[0]);
235310
}
236311
// Check for multiple type
@@ -241,12 +316,14 @@
241316
_selectValue(value) {
242317
value.el.selected = true;
243318
value.optionEl.classList.add('selected');
319+
value.optionEl.setAttribute("aria-selected", true);
244320
const checkbox = value.optionEl.querySelector('input[type="checkbox"]');
245321
if (checkbox) checkbox.checked = true;
246322
}
247323
_deselectValue(value) {
248324
value.el.selected = false;
249325
value.optionEl.classList.remove('selected');
326+
value.optionEl.setAttribute("aria-selected", false);
250327
const checkbox = value.optionEl.querySelector('input[type="checkbox"]');
251328
if (checkbox) checkbox.checked = false;
252329
}
@@ -290,13 +367,17 @@
290367
.prop('checked', optionIsSelected);
291368
if (optionIsSelected) {
292369
this._activateOption($(this.dropdownOptions), $(value.optionEl));
293-
} else $(value.optionEl).removeClass('selected');
370+
} else {
371+
$(value.optionEl).removeClass('selected');
372+
$(value.optionEl).attr("aria-selected", false);
373+
}
294374
});
295375
}
296376
_activateOption(ul, li) {
297377
if (!li) return;
298378
if (!this.isMultiple) ul.find('li.selected').removeClass('selected');
299379
$(li).addClass('selected');
380+
$(li).attr("aria-selected", true);
300381
}
301382

302383
getSelectedValues() {

0 commit comments

Comments
 (0)