277 lines
8.2 KiB
JavaScript
277 lines
8.2 KiB
JavaScript
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'});
|