Add missing ARIA attributes and keyboard controls to multiselect

I use MDN’s documentation[0] as guid for both.

[0]: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/combobox_role
This commit is contained in:
jordi fita mas 2023-03-18 07:17:28 +01:00
parent 2dde25c862
commit 041017adc3
2 changed files with 81 additions and 8 deletions

View File

@ -667,7 +667,7 @@ main > nav {
padding: 1rem 2rem; padding: 1rem 2rem;
} }
[is="numerus-multiselect"] .options li:hover { [is="numerus-multiselect"] .options li:hover, [is="numerus-multiselect"] .options .highlight {
background-color: var(--numerus--color--light-gray); background-color: var(--numerus--color--light-gray);
} }

View File

@ -3,6 +3,7 @@ class Multiselect extends HTMLDivElement {
super(); super();
this.initialized = false; this.initialized = false;
this.toSearch = ''; this.toSearch = '';
this.highlighted = 0;
} }
connectedCallback() { connectedCallback() {
@ -40,6 +41,10 @@ class Multiselect extends HTMLDivElement {
this.tags.append(this.search); this.tags.append(this.search);
this.search.id = this.select.id; this.search.id = this.select.id;
this.select.removeAttribute('id'); this.select.removeAttribute('id');
this.select.setAttribute('aria-hidden', 'true');
this.search.setAttribute('spellcheck', 'false');
this.search.setAttribute('autocomplete', 'false');
this.search.setAttribute('role', 'combobox');
this.search.addEventListener('input', (e) => { this.search.addEventListener('input', (e) => {
this.open(); this.open();
this.filter(e.target.value); this.filter(e.target.value);
@ -48,7 +53,23 @@ class Multiselect extends HTMLDivElement {
switch (e.code) { switch (e.code) {
case "Enter": case "Enter":
e.preventDefault(); e.preventDefault();
this.selectFirst(); this.selectHighlighted();
break;
case "ArrowDown":
if (e.altKey) {
this.open();
} else {
e.preventDefault();
this.highlightNext();
}
break;
case "ArrowUp":
if (e.altKey) {
this.close();
} else {
e.preventDefault();
this.highlightPrev();
}
break; break;
case "Backspace": case "Backspace":
if (e.target.value === '') { if (e.target.value === '') {
@ -66,7 +87,10 @@ class Multiselect extends HTMLDivElement {
this.options = document.createElement('ul'); this.options = document.createElement('ul');
this.append(this.options); this.append(this.options);
this.options.id = this.search.id + '-options-' + Array.from(crypto.getRandomValues(new Uint8Array(4))).map(n => n.toString(16)).join('');
this.search.setAttribute('aria-controls', this.options.id);
this.options.classList.add('options'); this.options.classList.add('options');
this.options.setAttribute('role', 'listbox');
this.close(); this.close();
window.addEventListener('focusin', (e) => { window.addEventListener('focusin', (e) => {
@ -85,18 +109,64 @@ class Multiselect extends HTMLDivElement {
rebuildOptions() { rebuildOptions() {
this.options.innerHTML = ''; this.options.innerHTML = '';
let highlight = true;
for (const option of this.select.options) { for (const option of this.select.options) {
if (!this.isOptionSelectable(option)) { if (!this.isOptionSelectable(option)) {
continue; continue;
} }
const li = document.createElement('li'); const li = document.createElement('li');
this.options.append(li); this.options.append(li);
li.id = this.options.id + '-' + option.index;
li.dataset.index = option.index;
li.textContent = option.textContent; li.textContent = option.textContent;
li.addEventListener('click', () => this.selectOption(option, true)); li.addEventListener('click', () => this.selectOption(option, true));
if (highlight && (option.index >= this.highlighted)) {
highlight = false;
this.highlight(null, li);
}
}
if (highlight) {
this.highlight(null, this.options.querySelector('li:last-child'));
} }
} }
highlightNext() {
const current = this.currentHighlighted();
if (!current) {
return;
}
this.highlight(current, current.nextElementSibling);
}
highlightPrev() {
const current = this.currentHighlighted();
if (!current) {
return;
}
this.highlight(current, current.previousElementSibling);
}
highlight(current, upcoming) {
if (!upcoming) {
return;
}
if (current) {
current.classList.remove('highlight');
}
upcoming.classList.remove('highlight');
this.search.setAttribute('aria-activedescendant', upcoming.id);
this.highlighted = upcoming.dataset.index;
upcoming.classList.add('highlight');
}
currentHighlighted() {
return document.getElementById(this.search.getAttribute('aria-activedescendant'));
}
isOptionSelectable(option) { isOptionSelectable(option) {
if (!option) {
return false;
}
if (option.selected) { if (option.selected) {
return false; return false;
} }
@ -104,20 +174,21 @@ class Multiselect extends HTMLDivElement {
} }
selectOption(option, selected) { selectOption(option, selected) {
if (!option) {
return;
}
option.selected = selected; option.selected = selected;
this.toSearch = this.search.value = ''; this.toSearch = this.search.value = '';
this.rebuild(); this.rebuild();
} }
selectFirst() { selectHighlighted() {
if (!this.isOpen()) { if (!this.isOpen()) {
return; return;
} }
for (const option of this.select.options) { const option = this.select.options[this.highlighted];
if (this.isOptionSelectable(option)) { if (this.isOptionSelectable(option)) {
this.selectOption(option, true); this.selectOption(option, true);
break;
}
} }
} }
@ -172,10 +243,12 @@ class Multiselect extends HTMLDivElement {
close() { close() {
this.options.style.display = 'none'; this.options.style.display = 'none';
this.search.setAttribute('aria-expanded', 'false');
} }
open() { open() {
this.options.removeAttribute('style'); this.options.removeAttribute('style');
this.search.setAttribute('aria-expanded', 'true');
} }
toggle() { toggle() {