class Multiselect extends HTMLDivElement { constructor() { super(); this.initialized = false; this.toSearch = ''; this.highlighted = 0; } connectedCallback() { if (!this.isConnected) { return; } 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(); } } if (this.initialized) { this.onClickHandler = () => this.toggle(); this.tags.addEventListener('click', this.onClickHandler); this.onSearchInputHandler = (e) => { this.open(); this.filter(e.target.value); } this.onSearchKeydownHandler = (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; } }; this.search.addEventListener('input', this.onSearchInputHandler); this.search.addEventListener('keydown', this.onSearchKeydownHandler); this.onFocusOutHandler = (e) => { if (this.contains(e.target)) return; if (!e.target.isConnected) return; this.close(); } window.addEventListener('focusin', this.onFocusOutHandler); document.addEventListener('click', this.onFocusOutHandler); this.rebuild() } } disconnectedCallback() { if (this.initialized) { this.removeTags(); this.options.innerHTML = ''; document.removeEventListener('click', this.onFocusOutHandler); window.removeEventListener('focusin', this.onFocusOutHandler); this.onFocusOutHandler = null; this.search.removeEventListener('keydown', this.onSearchKeydownHandler); this.search.removeEventListener('input', this.onSearchInputHandler); this.onSearchInputHandler = null; this.onSearchKeydownHandler = null; this.tags.removeEventListener('click', this.onClickHandler); this.onClickHandler = null; } } 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.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'); // 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(); } 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(); } removeTags() { this.tags.querySelectorAll('.tag').forEach((tag) => tag.remove()); } rebuildTags() { this.removeTags(); 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.isConnected) { return; } 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(); } } if (this.initialized) { this.onSearchKeydownHandler = (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; } } this.search.addEventListener('keydown', this.onSearchKeydownHandler); this.onFocusOutHandler = (e) => { if (this.contains(e.target)) return; if (this.search.value && this.search.value.trim() !== '') { this.createTag(); } }; window.addEventListener('focusin', this.onFocusOutHandler); this.rebuild(); } } disconnectedCallback() { if (this.initialized) { this.removeTags(); window.removeEventListener('focusin', this.onFocusOutHandler); this.onFocusOutHandler = null; this.search.removeEventListener('keydown', this.onSearchKeydownHandler); this.onSearchKeydownHandler = null; } } 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'); // Must come after the search input this.tagList.append(this.label); } removeTags() { this.tagList.querySelectorAll('.tag').forEach((tag) => tag.remove()); } rebuild() { const newValue = this.tags.join(','); if (newValue !== this.input.value) { this.input.value = newValue; this.input.dispatchEvent(new Event('change', {bubbles: true})); } this.removeTags(); 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'}); let savedTitle = ''; htmx.onLoad((target) => { if (target.tagName === 'DIALOG') { const details = document.querySelectorAll('details[open]'); for (const detail of details) { detail.removeAttribute('open'); } savedTitle = document.title; document.title = target.querySelector('h2').textContent + ' — ' + savedTitle.substring(savedTitle.indexOf("—") + 1); target.showModal(); const button = target.querySelector('.close-dialog'); if (button) { button.addEventListener('click', (e) => { htmx.trigger(e.target, 'closeModal'); }, {once: true}); } } }) htmx.on('htmx:configRequest', function (e) { const element = e.detail.elt; if (element && element.nodeName === 'FORM') { let submitter = e.detail.triggeringEvent.submitter; if (submitter) { const action = submitter.attributes['formaction']; if (action && action.value) { e.detail.path = action.value; } } } }) htmx.on('closeModal', () => { const openDialog = document.querySelector('dialog[open]'); if (!openDialog) { return; } openDialog.close(); openDialog.remove(); document.title = savedTitle; }); htmx.on(document, 'alpine:init', () => { Alpine.data('snackbar', () => ({ show: false, toast: "", toasts: [], timeoutId: null, init() { htmx.on('htmx:error', (error) => { this.showError(error.detail.errorInfo.error); }); }, showError(message) { this.toasts.push(message); this.popUp(); }, popUp() { if (this.toasts.length === 0) { return; } if (this.show) { this.dismiss(); return; } if (this.toast !== "") { // It will show after remove calls popUp again. return; } this.toast = this.toasts[0]; this.show = true; this.timeoutId = setTimeout(this.dismiss.bind(this), 4000); }, dismiss() { if (!this.show) { // already dismissed return; } this.show = false; clearTimeout(this.timeoutId); this.timeoutId = setTimeout(this.remove.bind(this), 350); }, remove() { clearTimeout(this.timeoutId); this.toasts.splice(0, 1); this.toast = ""; this.popUp(); }, })); });