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;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
Loading…
Reference in New Issue