429 lines
13 KiB
JavaScript
429 lines
13 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();
|
||
}
|
||
}
|
||
|
||
class Tags extends HTMLDivElement {
|
||
constructor() {
|
||
super();
|
||
this.initialized = false;
|
||
this.tags = [];
|
||
}
|
||
|
||
connectedCallback() {
|
||
if (!this.initialized) {
|
||
for (const child of this.children) {
|
||
switch (child.nodeName) {
|
||
case 'INPUT':
|
||
this.input = child;
|
||
break;
|
||
case 'LABEL':
|
||
this.label = child;
|
||
break;
|
||
}
|
||
}
|
||
if (this.label && this.input) {
|
||
this.init();
|
||
}
|
||
}
|
||
}
|
||
|
||
init() {
|
||
this.initialized = true;
|
||
|
||
this.tagList = document.createElement('div');
|
||
this.append(this.tagList);
|
||
this.tagList.classList.add('tags');
|
||
|
||
this.input.type = 'hidden';
|
||
const tagsText = this.input.value.split(',');
|
||
for (const tagText of tagsText) {
|
||
const tagNormalized = Tags.normalize(tagText);
|
||
if (tagNormalized !== '' && this.tags.indexOf(tagNormalized) === -1) {
|
||
this.tags.push(tagNormalized);
|
||
}
|
||
}
|
||
|
||
this.search = document.createElement('input');
|
||
this.tagList.append(this.search);
|
||
this.search.id = this.input.id;
|
||
this.input.removeAttribute('id');
|
||
this.input.setAttribute('aria-hidden', 'true');
|
||
this.search.setAttribute('spellcheck', 'false');
|
||
this.search.setAttribute('autocomplete', 'false');
|
||
this.search.addEventListener('keydown', (e) => {
|
||
switch (e.code) {
|
||
case "Space":
|
||
e.preventDefault();
|
||
// fallthrough
|
||
case "Enter":
|
||
if (e.target.value.trim() !== '') {
|
||
e.preventDefault();
|
||
this.createTag();
|
||
}
|
||
break;
|
||
case "Backspace":
|
||
if (e.target.value === '') {
|
||
this.removeLastTag();
|
||
}
|
||
break;
|
||
}
|
||
});
|
||
|
||
// Must come after the search input
|
||
this.tagList.append(this.label);
|
||
|
||
this.rebuild();
|
||
}
|
||
|
||
rebuild() {
|
||
this.input.value = this.tags.join(',');
|
||
this.tagList.querySelectorAll('.tag').forEach((tag) => tag.remove());
|
||
if (this.tags.length === 0) {
|
||
this.search.setAttribute('placeholder', this.label.textContent);
|
||
} else {
|
||
this.search.removeAttribute('placeholder');
|
||
for (const tagText of this.tags) {
|
||
const tag = document.createElement('div');
|
||
this.tagList.insertBefore(tag, this.search);
|
||
tag.classList.add('tag');
|
||
|
||
const span = document.createElement('span');
|
||
tag.append(span);
|
||
span.textContent = tagText;
|
||
|
||
const button = document.createElement('button');
|
||
tag.append(button);
|
||
button.type = 'button';
|
||
button.textContent = '×';
|
||
button.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
this.removeTag(tagText)
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
createTag() {
|
||
const tagText = Tags.normalize(this.search.value);
|
||
this.search.value = '';
|
||
if (this.tags.indexOf(tagText) === -1) {
|
||
this.tags.push(tagText);
|
||
this.rebuild();
|
||
}
|
||
}
|
||
|
||
static normalize(s) {
|
||
return s.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase().trim();
|
||
}
|
||
|
||
removeLastTag() {
|
||
this.tags.pop();
|
||
this.rebuild();
|
||
}
|
||
|
||
removeTag(tagText) {
|
||
this.tags.splice(this.tags.indexOf(tagText), 1);
|
||
this.rebuild();
|
||
}
|
||
}
|
||
|
||
customElements.define('numerus-multiselect', Multiselect, {extends: 'div'});
|
||
customElements.define('numerus-tags', Tags, {extends: 'div'});
|
||
|
||
|
||
htmx.onLoad((target) => {
|
||
if (target.tagName === 'DIALOG') {
|
||
const details = document.querySelectorAll('details[open]');
|
||
for (const detail of details) {
|
||
detail.removeAttribute('open');
|
||
}
|
||
target.showModal();
|
||
const button = target.querySelector('.close-dialog');
|
||
if (button) {
|
||
button.addEventListener('click', () => {
|
||
htmx.trigger(target, 'closeModal');
|
||
});
|
||
}
|
||
}
|
||
})
|
||
|
||
htmx.on('closeModal', () => {
|
||
const openDialog = document.querySelector('dialog[open]');
|
||
if (!openDialog) {
|
||
return;
|
||
}
|
||
openDialog.close();
|
||
openDialog.remove();
|
||
});
|