204 lines
5.6 KiB
JavaScript
204 lines
5.6 KiB
JavaScript
|
class Multiselect extends HTMLDivElement {
|
|||
|
constructor() {
|
|||
|
super();
|
|||
|
this.initialized = false;
|
|||
|
this.toSearch = '';
|
|||
|
}
|
|||
|
|
|||
|
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.search.addEventListener('input', (e) => {
|
|||
|
this.open();
|
|||
|
this.filter(e.target.value);
|
|||
|
});
|
|||
|
this.search.addEventListener('keydown', (e) => {
|
|||
|
switch (e.code) {
|
|||
|
case "Enter":
|
|||
|
e.preventDefault();
|
|||
|
this.selectFirst();
|
|||
|
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.classList.add('options');
|
|||
|
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 = '';
|
|||
|
for (const option of this.select.options) {
|
|||
|
if (!this.isOptionSelectable(option)) {
|
|||
|
continue;
|
|||
|
}
|
|||
|
const li = document.createElement('li');
|
|||
|
this.options.append(li);
|
|||
|
li.textContent = option.textContent;
|
|||
|
li.addEventListener('click', () => this.selectOption(option, true));
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
isOptionSelectable(option) {
|
|||
|
if (option.selected) {
|
|||
|
return false;
|
|||
|
}
|
|||
|
return this.toSearch === '' || option.dataset.numerusSearch.includes(this.toSearch);
|
|||
|
}
|
|||
|
|
|||
|
selectOption(option, selected) {
|
|||
|
option.selected = selected;
|
|||
|
this.toSearch = this.search.value = '';
|
|||
|
this.rebuild();
|
|||
|
}
|
|||
|
|
|||
|
selectFirst() {
|
|||
|
if (!this.isOpen()) {
|
|||
|
return;
|
|||
|
}
|
|||
|
for (const option of this.select.options) {
|
|||
|
if (this.isOptionSelectable(option)) {
|
|||
|
this.selectOption(option, true);
|
|||
|
break;
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
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';
|
|||
|
}
|
|||
|
|
|||
|
open() {
|
|||
|
this.options.removeAttribute('style');
|
|||
|
}
|
|||
|
|
|||
|
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'});
|