numerus/web/static/numerus.js

772 lines
25 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.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();
},
}));
});
function updateCurrentMenuItem() {
const path = window.location.pathname;
const items = document.querySelectorAll('body > nav a');
items.forEach((item, i) => {
const matches = path === item.pathname;
if (matches || (i !== 0 && path.startsWith(item.pathname))) {
item.setAttribute('aria-current', 'page');
} else {
item.removeAttribute('aria-current');
}
});
}
htmx.on('htmx:pushedIntoHistory', updateCurrentMenuItem);
htmx.on('htmx:replacedInHistory', updateCurrentMenuItem);