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; } if (option.selected !== selected) { option.selected = selected; this.select.dispatchEvent(new Event('change', {bubbles: true})); 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 (!e.target.isConnected) return; if (this.search.value && this.search.value.trim() !== '') { this.createTag(); } this.dispatchEvent(new CustomEvent("numerus-tags-out", {bubbles: true})) }; window.addEventListener('focusin', this.onFocusOutHandler); document.addEventListener('click', this.onFocusOutHandler); this.rebuild(); } } disconnectedCallback() { if (this.initialized) { this.removeTags(); document.removeEventListener('click', this.onFocusOutHandler); 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.search.setAttribute('type', 'search'); 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); const conditionsId = this.input.dataset.conditions; if (conditionsId !== "") { const conditions = document.getElementById(conditionsId); if (conditions) { const details = document.createElement('details'); details.classList.add('menu'); this.tagList.append(details); const summary = document.createElement('summary'); details.append(summary); const ul = document.createElement('ul'); ul.setAttribute('role', 'menu'); details.append(ul); const options = conditions.querySelectorAll('label'); if (options && options.length > 0) { summary.textContent = options[0].textContent.trim(); } for (const option of options) { const li = document.createElement('li'); li.setAttribute('role', 'presentation'); li.append(option); ul.append(li); const checkbox = option.querySelector('input'); if (checkbox && checkbox.checked) { summary.textContent = option.textContent.trim(); } } conditions.remove(); details.addEventListener('toggle', function (e) { const menu = e.target.querySelector('[role="menu"]'); if (e.target.open) { const rect = menu.getBoundingClientRect(); if (rect.right > document.body.clientWidth) { menu.style.left = Math.ceil(document.body.clientWidth - rect.right) + 'px'; } console.log(rect, document.body.clientWidth); } else { menu.style.left = 0; } }); } } } 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); this.search.focus(); }); } } } 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(); } } class ProductSearch 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 "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 "Escape": this.close(); break; } }; this.input.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); } } disconnectedCallback() { if (this.initialized) { document.removeEventListener('click', this.onFocusOutHandler); window.removeEventListener('focusin', this.onFocusOutHandler); this.onFocusOutHandler = null; this.input.removeEventListener('keydown', this.onSearchKeydownHandler); this.onSearchKeydownHandler = null; } } init() { this.initialized = true; this.input.setAttribute('spellcheck', 'false'); this.input.setAttribute('autocomplete', 'false'); this.input.setAttribute('role', 'combobox'); this.options = document.createElement('ul'); this.append(this.options); this.options.classList.add('options') this.options.setAttribute('role', 'listbox') this.options.dataset.hxOn = "htmx:afterSettle: this.parentNode.settled()"; const indicator = document.createElement('img'); this.append(indicator); indicator.classList.add('htmx-indicator'); indicator.src = '/static/bars.svg'; this.close(); } settled() { this.open(); } open() { this.options.removeAttribute('style'); this.input.setAttribute('aria-expanded', 'true'); const products = this.options.querySelectorAll('li[id]'); if (products.length > 0) { this.highlight(this.currentHighlighted(), products.item(0)); } } close() { this.input.removeAttribute('aria-activedescendant'); this.input.setAttribute('aria-expanded', 'false'); this.options.style.display = 'none'; } 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'); } this.input.setAttribute('aria-activedescendant', upcoming.id); this.highlighted = upcoming.dataset.index; upcoming.classList.add('highlight'); } selectHighlighted() { const highlighted = this.currentHighlighted(); if (highlighted) { highlighted.click(); } } currentHighlighted() { return document.getElementById(this.input.getAttribute('aria-activedescendant')); } } customElements.define('numerus-multiselect', Multiselect, {extends: 'div'}); customElements.define('numerus-tags', Tags, {extends: 'div'}); customElements.define('numerus-product-search', ProductSearch, {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', () => { document.body.classList.remove('filters-visible'); 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(); }, })); });