numerus/web/static/numerus.js

277 lines
8.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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'});