class Multiselect extends HTMLDivElement { constructor() { super(); this.initialized = false; this.toSearch = ''; this.highlighted = 0; } connectedCallback() { if (!this.initialized) { for (const child of this.children) { switch (child.nodeName) { case 'SELECT': this.select = child; break; case 'LABEL': this.label = child; break; } } if (this.label && this.select) { this.init(); } } } init() { this.initialized = true; this.select.style.display = 'none'; for (const option of this.select.options) { option.dataset.numerusSearch = Multiselect.normalize(option.textContent); } this.tags = document.createElement('div'); this.append(this.tags); this.tags.classList.add('tags'); this.tags.addEventListener('click', () => this.toggle()); this.search = document.createElement('input'); 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); }); this.search.addEventListener('keydown', (e) => { switch (e.code) { case "Enter": e.preventDefault(); 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 === '') { this.deselectLast(); } break; case "Escape": this.close(); break; } }); // Must come after the search input this.tags.append(this.label); 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) => { if (this.contains(e.target)) return; this.close(); }); document.addEventListener('click', (e) => { if (this.contains(e.target)) return; if (!e.target.isConnected) return; this.close(); }); this.rebuild(); } 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; } return this.toSearch === '' || option.dataset.numerusSearch.includes(this.toSearch); } selectOption(option, selected) { if (!option) { return; } option.selected = selected; this.toSearch = this.search.value = ''; this.rebuild(); } selectHighlighted() { if (!this.isOpen()) { return; } const option = this.select.options[this.highlighted]; if (this.isOptionSelectable(option)) { this.selectOption(option, true); } } deselectLast() { const options = this.select.options; for (let i = options.length - 1; i >= 0; i--) { const option = options[i]; if (option.selected) { this.selectOption(option, false); break; } } } rebuild() { this.rebuildTags(); this.rebuildOptions(); } rebuildTags() { this.tags.querySelectorAll('.tag').forEach((tag) => tag.remove()); let empty = true; for (const option of this.select.options) { if (!option.selected) { continue; } empty = false; const tag = document.createElement('div'); this.tags.insertBefore(tag, this.search); tag.classList.add('tag'); tag.dataset.numerusSearch = option.dataset.numerusSearch; const span = document.createElement('span'); tag.append(span); span.textContent = option.textContent; const button = document.createElement('button'); tag.append(button); button.type = 'button'; button.textContent = '×'; button.addEventListener('click', (e) => { e.stopPropagation(); this.selectOption(option, false) }); } if (empty) { this.search.setAttribute('placeholder', this.label.textContent); } else { this.search.removeAttribute('placeholder'); } } close() { this.options.style.display = 'none'; this.search.setAttribute('aria-expanded', 'false'); } open() { this.options.removeAttribute('style'); this.search.setAttribute('aria-expanded', 'true'); } toggle() { if (this.isOpen()) { this.close(); } else { this.open(); } } isOpen() { return !this.options.hasAttribute('style'); } filter(search) { this.toSearch = Multiselect.normalize(search); this.rebuildOptions() } static normalize(s) { return s.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase().trim(); } } customElements.define('numerus-multiselect', Multiselect, {extends: 'div'});