diff --git a/web/static/numerus.css b/web/static/numerus.css index 29fdca8..fbe5c95 100644 --- a/web/static/numerus.css +++ b/web/static/numerus.css @@ -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); } diff --git a/web/static/numerus.js b/web/static/numerus.js index 68c804a..c51eebe 100644 --- a/web/static/numerus.js +++ b/web/static/numerus.js @@ -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() {