numerus/web/static/numerus.js

491 lines
15 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.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'});
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', () => {
htmx.trigger(target, 'closeModal');
});
}
}
})
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();
},
}));
});