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:
parent
2dde25c862
commit
041017adc3
|
@ -667,7 +667,7 @@ main > nav {
|
|||
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);
|
||||
}
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ class Multiselect extends HTMLDivElement {
|
|||
super();
|
||||
this.initialized = false;
|
||||
this.toSearch = '';
|
||||
this.highlighted = 0;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
|
@ -40,6 +41,10 @@ class Multiselect extends HTMLDivElement {
|
|||
this.tags.append(this.search);
|
||||
this.search.id = this.select.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.open();
|
||||
this.filter(e.target.value);
|
||||
|
@ -48,7 +53,23 @@ class Multiselect extends HTMLDivElement {
|
|||
switch (e.code) {
|
||||
case "Enter":
|
||||
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;
|
||||
case "Backspace":
|
||||
if (e.target.value === '') {
|
||||
|
@ -66,7 +87,10 @@ class Multiselect extends HTMLDivElement {
|
|||
|
||||
this.options = document.createElement('ul');
|
||||
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.setAttribute('role', 'listbox');
|
||||
this.close();
|
||||
|
||||
window.addEventListener('focusin', (e) => {
|
||||
|
@ -85,18 +109,64 @@ class Multiselect extends HTMLDivElement {
|
|||
|
||||
rebuildOptions() {
|
||||
this.options.innerHTML = '';
|
||||
let highlight = true;
|
||||
for (const option of this.select.options) {
|
||||
if (!this.isOptionSelectable(option)) {
|
||||
continue;
|
||||
}
|
||||
const li = document.createElement('li');
|
||||
this.options.append(li);
|
||||
li.id = this.options.id + '-' + option.index;
|
||||
li.dataset.index = option.index;
|
||||
li.textContent = option.textContent;
|
||||
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) {
|
||||
if (!option) {
|
||||
return false;
|
||||
}
|
||||
if (option.selected) {
|
||||
return false;
|
||||
}
|
||||
|
@ -104,20 +174,21 @@ class Multiselect extends HTMLDivElement {
|
|||
}
|
||||
|
||||
selectOption(option, selected) {
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
option.selected = selected;
|
||||
this.toSearch = this.search.value = '';
|
||||
this.rebuild();
|
||||
}
|
||||
|
||||
selectFirst() {
|
||||
selectHighlighted() {
|
||||
if (!this.isOpen()) {
|
||||
return;
|
||||
}
|
||||
for (const option of this.select.options) {
|
||||
if (this.isOptionSelectable(option)) {
|
||||
this.selectOption(option, true);
|
||||
break;
|
||||
}
|
||||
const option = this.select.options[this.highlighted];
|
||||
if (this.isOptionSelectable(option)) {
|
||||
this.selectOption(option, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -172,10 +243,12 @@ class Multiselect extends HTMLDivElement {
|
|||
|
||||
close() {
|
||||
this.options.style.display = 'none';
|
||||
this.search.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
|
||||
open() {
|
||||
this.options.removeAttribute('style');
|
||||
this.search.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
|
||||
toggle() {
|
||||
|
|
Loading…
Reference in New Issue