|
15 | 15 | this.isMultiple = this.$el.prop('multiple');
|
16 | 16 | this.el.tabIndex = -1;
|
17 | 17 | this._values = [];
|
| 18 | + this.labelEl = null; |
| 19 | + this._labelFor = false; |
18 | 20 | this._setupDropdown();
|
19 | 21 | this._setupEventHandlers();
|
20 | 22 | }
|
|
29 | 31 | return domElem.M_FormSelect;
|
30 | 32 | }
|
31 | 33 | destroy() {
|
| 34 | + // Returns label to its original owner |
| 35 | + if (this._labelFor) this.labelEl.setAttribute("for", this.el.id); |
32 | 36 | this._removeEventHandlers();
|
33 | 37 | this._removeDropdown();
|
34 | 38 | this.el.M_FormSelect = undefined;
|
|
41 | 45 | .find('li:not(.optgroup)')
|
42 | 46 | .each((el) => {
|
43 | 47 | el.addEventListener('click', this._handleOptionClickBound);
|
| 48 | + el.addEventListener('keydown', (e) => { |
| 49 | + if (e.key === " " || e.key === "Enter") this._handleOptionClickBound(e); |
| 50 | + }); |
44 | 51 | });
|
45 | 52 | this.el.addEventListener('change', this._handleSelectChangeBound);
|
46 | 53 | this.input.addEventListener('click', this._handleInputClickBound);
|
|
95 | 102 | if (!this.isMultiple) this.dropdown.close();
|
96 | 103 | }
|
97 | 104 | _handleInputClick() {
|
98 |
| - if (this.dropdown && this.dropdown.isOpen) { |
| 105 | + if (this.dropdown && this.dropdown.isOpen) { |
99 | 106 | this._setValueToInput();
|
100 | 107 | this._setSelectedStates();
|
101 | 108 | }
|
|
119 | 126 | $(this.dropdownOptions).addClass(
|
120 | 127 | 'dropdown-content select-dropdown ' + (this.isMultiple ? 'multiple-select-dropdown' : '')
|
121 | 128 | );
|
| 129 | + this.dropdownOptions.setAttribute("role", "listbox"); |
| 130 | + this.dropdownOptions.setAttribute("aria-required", this.$el.hasAttribute("required")); |
| 131 | + this.dropdownOptions.setAttribute("aria-multiselectable", this.isMultiple); |
122 | 132 |
|
123 | 133 | // Create dropdown structure
|
124 | 134 | if (this.$selectOptions.length) {
|
|
133 | 143 | } else if ($(realOption).is('optgroup')) {
|
134 | 144 | // Optgroup
|
135 | 145 | 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); |
141 | 152 | selectOptions.each((realOption) => {
|
142 | 153 | const virtualOption = this._createAndAppendOptionWithIcon(
|
143 | 154 | realOption,
|
144 | 155 | 'optgroup-option'
|
145 | 156 | );
|
| 157 | + let cId = "opt-child-" + M.guid(); |
| 158 | + virtualOption.id = cId; |
| 159 | + groupChildren.push(cId); |
146 | 160 | this._addOptionToValues(realOption, virtualOption);
|
147 | 161 | });
|
| 162 | + groupParent.setAttribute("aria-owns", groupChildren.join(" ")); |
148 | 163 | }
|
149 | 164 | });
|
150 | 165 | }
|
151 | 166 | $(this.wrapper).append(this.dropdownOptions);
|
152 | 167 |
|
153 | 168 | // Add input dropdown
|
154 | 169 | this.input = document.createElement('input');
|
| 170 | + this.input.id = "m_select-input-" + M.guid(); |
155 | 171 | $(this.input).addClass('select-dropdown dropdown-trigger');
|
156 | 172 | this.input.setAttribute('type', 'text');
|
157 | 173 | this.input.setAttribute('readonly', 'true');
|
158 | 174 | this.input.setAttribute('data-target', this.dropdownOptions.id);
|
| 175 | + this.input.setAttribute('aria-readonly', 'true'); |
159 | 176 | if (this.el.disabled) $(this.input).prop('disabled', 'true');
|
160 | 177 |
|
| 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 | + |
161 | 220 | $(this.wrapper).prepend(this.input);
|
162 | 221 | this._setValueToInput();
|
163 | 222 |
|
164 | 223 | // Add caret
|
165 | 224 | 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>' |
167 | 226 | );
|
168 | 227 | $(this.wrapper).prepend(dropdownIcon[0]);
|
169 | 228 | // Initialize dropdown
|
170 | 229 | if (!this.el.disabled) {
|
171 | 230 | let dropdownOptions = $.extend({}, this.options.dropdownOptions);
|
172 | 231 | dropdownOptions.coverTrigger = false;
|
173 | 232 | let userOnOpenEnd = dropdownOptions.onOpenEnd;
|
| 233 | + let userOnCloseEnd = dropdownOptions.onCloseEnd; |
174 | 234 | // Add callback for centering selected option when dropdown content is scrollable
|
175 | 235 | dropdownOptions.onOpenEnd = (el) => {
|
176 | 236 | let selectedOption = $(this.dropdownOptions)
|
|
191 | 251 | this.dropdownOptions.scrollTop = scrollOffset;
|
192 | 252 | }
|
193 | 253 | }
|
| 254 | + // Sets "aria-expanded" to "true" |
| 255 | + this.input.setAttribute("aria-expanded", true); |
194 | 256 | // Handle user declared onOpenEnd if needed
|
195 | 257 | if (userOnOpenEnd && typeof userOnOpenEnd === 'function')
|
196 | 258 | userOnOpenEnd.call(this.dropdown, this.el);
|
197 | 259 | };
|
| 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 | + }; |
198 | 268 | // Prevent dropdown from closing too early
|
199 | 269 | dropdownOptions.closeOnClick = false;
|
200 | 270 | this.dropdown = M.Dropdown.init(this.input, dropdownOptions);
|
|
216 | 286 | }
|
217 | 287 | _createAndAppendOptionWithIcon(realOption, type) {
|
218 | 288 | 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 | + } |
220 | 294 | if (type === 'optgroup-option') li.classList.add(type);
|
221 | 295 | // Text / Checkbox
|
222 | 296 | const span = document.createElement('span');
|
|
231 | 305 | const classes = realOption.getAttribute('class');
|
232 | 306 | if (iconUrl) {
|
233 | 307 | const img = $(`<img alt="" class="${classes}" src="${iconUrl}">`);
|
| 308 | + img[0].setAttribute("aria-hidden", true); |
234 | 309 | li.prepend(img[0]);
|
235 | 310 | }
|
236 | 311 | // Check for multiple type
|
|
241 | 316 | _selectValue(value) {
|
242 | 317 | value.el.selected = true;
|
243 | 318 | value.optionEl.classList.add('selected');
|
| 319 | + value.optionEl.setAttribute("aria-selected", true); |
244 | 320 | const checkbox = value.optionEl.querySelector('input[type="checkbox"]');
|
245 | 321 | if (checkbox) checkbox.checked = true;
|
246 | 322 | }
|
247 | 323 | _deselectValue(value) {
|
248 | 324 | value.el.selected = false;
|
249 | 325 | value.optionEl.classList.remove('selected');
|
| 326 | + value.optionEl.setAttribute("aria-selected", false); |
250 | 327 | const checkbox = value.optionEl.querySelector('input[type="checkbox"]');
|
251 | 328 | if (checkbox) checkbox.checked = false;
|
252 | 329 | }
|
|
290 | 367 | .prop('checked', optionIsSelected);
|
291 | 368 | if (optionIsSelected) {
|
292 | 369 | 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 | + } |
294 | 374 | });
|
295 | 375 | }
|
296 | 376 | _activateOption(ul, li) {
|
297 | 377 | if (!li) return;
|
298 | 378 | if (!this.isMultiple) ul.find('li.selected').removeClass('selected');
|
299 | 379 | $(li).addClass('selected');
|
| 380 | + $(li).attr("aria-selected", true); |
300 | 381 | }
|
301 | 382 |
|
302 | 383 | getSelectedValues() {
|
|
0 commit comments