numerus/web/static/numerus.js
jordi fita mas 884c6dc2db Make sure the tag’s condition menu is within the limits of <body>
Otherwise, when the tag input is too close to the right side of the
screen, it may be unreadable without scrolling.
2023-04-17 11:51:10 +02:00

599 lines
19 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;
}
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 (!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();
}
}
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();
},
}));
});