UNPKG

@schukai/monster

Version:

Monster is a simple library for creating fast, robust and lightweight websites.

756 lines (667 loc) 18.7 kB
/** * Copyright © schukai GmbH and all contributing authors, {{copyRightYear}}. All rights reserved. * Node module: @schukai/monster * * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3). * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html * * For those who do not wish to adhere to the AGPLv3, a commercial license is available. * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms. * For more information about purchasing a commercial license, please contact schukai GmbH. * * SPDX-License-Identifier: AGPL-3.0 */ import { instanceSymbol } from "../../../constants.mjs"; import { assembleMethodSymbol, registerCustomElement, } from "../../../dom/customelement.mjs"; import { FilterRangeStyleSheet } from "../stylesheet/filter-range.mjs"; import { FilterControlsDefaultsStyleSheet } from "../stylesheet/filter-controls-defaults.mjs"; import { AbstractBase } from "./abstract-base.mjs"; import { positionPopper } from "../../form/util/floating-ui.mjs"; import { ATTRIBUTE_ROLE } from "../../../dom/constants.mjs"; import { getDocument } from "../../../dom/util.mjs"; import { STYLE_DISPLAY_MODE_BLOCK } from "../constants.mjs"; import { DeadMansSwitch } from "../../../util/deadmansswitch.mjs"; import { addAttributeToken, removeAttributeToken, } from "../../../dom/attributes.mjs"; import { findTargetElementFromEvent } from "../../../dom/events.mjs"; import { getLocaleOfDocument } from "../../../dom/locale.mjs"; export { Range }; /** * @private * @type {symbol} */ const timerCallbackSymbol = Symbol("timerCallback"); /** * local symbol * @private * @type {symbol} */ const resizeObserverSymbol = Symbol("resizeObserver"); /** * @private * @type {symbol} */ const controlElementSymbol = Symbol("controlElement"); /** * local symbol * @private * @type {symbol} */ const closeEventHandler = Symbol("closeEventHandler"); /** * @private * @type {symbol} */ const inputElementSymbol = Symbol("inputElement"); /** * @private * @type {symbol} */ const formContainerElementSymbol = Symbol("formContainerElement"); /** * local symbol * @private * @type {symbol} */ const popperElementSymbol = Symbol("popperElement"); /** * local symbol * @private * @type {symbol} */ const arrowElementSymbol = Symbol("arrowElement"); /** * The range filter control is used to filter a data set by a range. * * <img src="./images/range.png"> * * Dependencies: the system uses functions of the [monsterjs](https://monsterjs.org/) library * * You can create this control either by specifying the HTML tag <monster-filter-range />` directly in the HTML or using * Javascript via the `document.createElement('monster-filter-range');` method. * * ```html * <monster-filter-range></monster-filter-range> * ``` * * Or you can create this CustomControl directly in Javascript: * * ```js * import '@schukai/component-datatable/source/filter/range.mjs'; * document.createElement('monster-filter-range'); * ``` * * @startuml range.png * skinparam monochrome true * skinparam shadowing false * HTMLElement <|-- CustomElement * CustomElement <|-- AbstractBase * AbstractBase <|-- Range * @enduml * * @copyright schukai GmbH * @summary A range filter control */ class Range extends AbstractBase { /** * This method is called by the `instanceof` operator. * @return {symbol} */ static get [instanceSymbol]() { return Symbol.for("@schukai/monster/components/filter/range@@instance"); } /** * * @return {FilterButton} */ [assembleMethodSymbol]() { super[assembleMethodSymbol](); initControlReferences.call(this); initEventHandler.call(this); } /** * This is a method of [internal api](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals) * * @param {*} value */ set value(value) { this[inputElementSymbol].value = value; } /** * @return {*} */ get value() { return this[inputElementSymbol].value; } /** * To set the options via the HTML tag, the attribute `data-monster-options` must be used. * @see {@link https://monsterjs.org/en/doc/#configurate-a-monster-control} * * The individual configuration values can be found in the table. * * @return {Object} * * @property {Object} templates * @property {string} templates.main * @property {Object} labels * @property {Object} features */ get defaults() { const d = Object.assign({}, super.defaults, { templates: { main: getTemplate(), }, labels: getTranslations(), features: {}, popper: { placement: "bottom", middleware: ["flip", "offset:1"], }, }); return initOptionsFromArguments.call(this, d); } /** * * @return {CSSStyleSheet[]} */ static getCSSStyleSheet() { return [FilterControlsDefaultsStyleSheet, FilterRangeStyleSheet]; } /** * * @return {string} */ static getTag() { return "monster-filter-range"; } /** * @return {void} */ connectedCallback() { super.connectedCallback(); const document = getDocument(); for (const [, type] of Object.entries(["click", "touch"])) { // close on outside ui-events document.addEventListener(type, this[closeEventHandler]); } updatePopper.call(this); attachResizeObserver.call(this); } /** * @return {void} */ disconnectedCallback() { super.disconnectedCallback(); // close on outside ui-events for (const [, type] of Object.entries(["click", "touch"])) { document.removeEventListener(type, this[closeEventHandler]); } disconnectResizeObserver.call(this); } /** * * @return {Range} */ showDialog() { show.call(this); return this; } /** * * @return {Range} */ hideDialog() { hide.call(this); return this; } /** * * @return {Range} */ toggleDialog() { if (this[popperElementSymbol].style.display === STYLE_DISPLAY_MODE_BLOCK) { this.hideDialog(); } else { this.showDialog(); } return this; } } /** * @private * @returns {object} */ function getTranslations() { const locale = getLocaleOfDocument(); switch (locale.language) { case "de": return { singleValue: "Wert", fromValue: "Von", toValue: "Bis", rangeFrom: "Von", rangeTo: "Bis", }; case "fr": return { singleValue: "Valeur", fromValue: "De", toValue: "À", rangeFrom: "De", rangeTo: "À", }; case "sp": return { singleValue: "Valor", fromValue: "Desde", toValue: "Hasta", rangeFrom: "Desde", rangeTo: "Hasta", }; case "it": return { singleValue: "Valore", fromValue: "Da", toValue: "A", rangeFrom: "Da", rangeTo: "A", }; case "pl": return { singleValue: "Wartość", fromValue: "Od", toValue: "Do", rangeFrom: "Od", rangeTo: "Do", }; case "no": return { singleValue: "Verdi", fromValue: "Fra", toValue: "Til", rangeFrom: "Fra", rangeTo: "Til", }; case "dk": return { singleValue: "Værdi", fromValue: "Fra", toValue: "Til", rangeFrom: "Fra", rangeTo: "Til", }; case "sw": return { singleValue: "Värde", fromValue: "Från", toValue: "Till", rangeFrom: "Från", rangeTo: "Till", }; default: case "en": return { singleValue: "Value", fromValue: "From", toValue: "To", rangeFrom: "From", rangeTo: "To", }; } } /** * @private * @param type * @param self * @param value * @param element */ function updateFilterValue(type, self, value, element) { if (type === "single") { self[inputElementSymbol].value = value; } else if (type === "from") { self[inputElementSymbol].value = value + "-"; } else if (type === "to") { self[inputElementSymbol].value = "-" + value; } else if (type === "from-to") { // this option contain two input fields, we check which one was changed and read the other one // the other field is in the same form group, for simplification we get both controls from the group here const group = self[formContainerElementSymbol].querySelectorAll( "input[type=radio][value=" + type + "] ~ input[type=number]", ); let from = group[0].value; if (from === "" || from === null || from < 0) { from = 0; } let to = group[1].value; if (to === "" || to === null || to < 0) { to = 0; } if (element.name === "rangeTo") { if (parseInt(from) > parseInt(to)) { group[0].value = to; from = to; } } else { if (parseInt(from) > parseInt(to)) { group[1].value = from; to = from; } } let range; if (from === 0 && to === 0) { range = ""; } else if (from === 0) { range = "-" + to; } else if (to === 0) { range = from + "-"; } else if (from === to) { range = from; } else { range = from + "-" + to; } self[inputElementSymbol].value = range; } } /** * @private * @return {initEventHandler} */ function initEventHandler() { this[closeEventHandler] = (event) => { const path = event.composedPath(); for (const [, element] of Object.entries(path)) { if (element === this) { return; } } hide.call(this); }; this[inputElementSymbol].addEventListener("click", () => { this.toggleDialog(); }); this[formContainerElementSymbol].addEventListener("click", (event) => { const element = findTargetElementFromEvent( event, "data-monster-role", "range-type", ); if (!element) { return; } const type = element.getAttribute("data-monster-range-type"); const radio = this[formContainerElementSymbol].querySelector( "input[type=radio][value=" + type + "]", ); if (!radio) { return; } radio.checked = true; // enable input from this group and disable the other const group = this[formContainerElementSymbol].querySelectorAll( "input[type=radio][value=" + type + "] ~ input[type=number]", ); for (const [, element] of Object.entries(group)) { element.disabled = false; } const otherGroup = this[formContainerElementSymbol].querySelectorAll( "input[type=radio]:not([value=" + type + "]) ~ input[type=number]", ); for (const [, element] of Object.entries(otherGroup)) { element.disabled = true; } }); // if the user change the value of the input field, we have to update the main input field this[formContainerElementSymbol].addEventListener("change", (event) => { const element = findTargetElementFromEvent(event, "type", "number"); if (!element) { return; } const typeElement = findTargetElementFromEvent( event, "data-monster-role", "range-type", ); if (!typeElement) { return; } const type = typeElement.getAttribute("data-monster-range-type"); const value = element.value; updateFilterValue(type, this, value, element); }); // we should watch the change event of self[inputElementSymbol] and call updateInputFromValue // if the value is changed from outside this[inputElementSymbol].addEventListener("change", () => { updateInputFromValue.call(this); }); this.addEventListener("keydown", (event) => { // if key code esc than hide dialog if (event.key === "Escape") { hide.call(this); queueMicrotask(() => { this[inputElementSymbol].focus(); }); return; } const input = findTargetElementFromEvent( event, "data-monster-role", "input", ); if (!input) { return; } // key code down should activate first radio with name="singleValue" if (event.key === "ArrowDown") { show.call(this); const radio = this[formContainerElementSymbol].querySelector( "input[type=radio][value=single]", ); if (radio) { radio.checked = true; } queueMicrotask(() => { // focus the input field const input = this[formContainerElementSymbol].querySelector( "input[type=radio][value=single] ~ input[name=singleValue]", ); if (input) { input.focus(); } }); } }); return this; } /** * @private */ function attachResizeObserver() { // against flickering this[resizeObserverSymbol] = new ResizeObserver((entries) => { if (this[timerCallbackSymbol] instanceof DeadMansSwitch) { try { this[timerCallbackSymbol].touch(); return; } catch (e) { delete this[timerCallbackSymbol]; } } this[timerCallbackSymbol] = new DeadMansSwitch(200, () => { updatePopper.call(this); }); }); this[resizeObserverSymbol].observe(this.parentElement); } function disconnectResizeObserver() { if (this[resizeObserverSymbol] instanceof ResizeObserver) { this[resizeObserverSymbol].disconnect(); } } /** * @private */ function hide() { this[popperElementSymbol].style.display = "none"; removeAttributeToken(this[controlElementSymbol], "class", "open"); } /** * @private * @this PopperButton */ function show() { if (this.getOption("disabled", false) === true) { return; } updateInputFromValue.call(this); if (this[popperElementSymbol].style.display === STYLE_DISPLAY_MODE_BLOCK) { return; } this[popperElementSymbol].style.visibility = "hidden"; this[popperElementSymbol].style.display = STYLE_DISPLAY_MODE_BLOCK; addAttributeToken(this[controlElementSymbol], "class", "open"); updatePopper.call(this); } /** * @private */ function updateInputFromValue() { const value = this[inputElementSymbol].value.trim(); const formContainer = this[formContainerElementSymbol]; const rangeTypes = ["single", "from", "to", "from-to"]; const rangeValues = value.split("-"); const hasDash = value.includes("-"); if (!/^(-?\d+(-\d+)?|-)?$/.test(value)) { return; // Skip if input contains invalid characters } rangeTypes.forEach((rangeType) => { const radio = formContainer.querySelector( `input[type=radio][value=${rangeType}]`, ); const inputs = formContainer.querySelectorAll( `input[type=radio][value=${rangeType}] ~ input[type=number]`, ); inputs.forEach((input) => (input.value = "")); if ( (rangeType === "single" && !hasDash) || (rangeType === "to" && value.startsWith("-")) || (rangeType === "from" && value.endsWith("-")) || (rangeType === "from-to" && hasDash && rangeValues.length === 2) ) { if (rangeType === "single") { inputs[0].value = value; } else if (rangeType !== "from-to") { inputs[0].value = rangeValues.pop(); } else { if (isNaN(rangeValues[0]) || isNaN(rangeValues[1])) { rangeValues[0] = rangeValues[1] = ""; } else if (parseInt(rangeValues[0]) > parseInt(rangeValues[1])) { rangeValues.reverse(); this[inputElementSymbol].value = rangeValues.join("-"); } inputs.forEach((input, index) => (input.value = rangeValues[index])); } radio.click(); inputs[0].focus(); } }); } /** * @private * @return {Monster.Components.Datatable.Filter.Range} */ function initControlReferences() { if (!this.shadowRoot) { throw new Error("no shadow-root is defined"); } this[controlElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}=control]`, ); this[inputElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}=input]`, ); this[popperElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}=popper]`, ); this[arrowElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}=arrow]`, ); this[formContainerElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}=form]`, ); return this; } /** * @private * @param {object} options * @return {object} */ function initOptionsFromArguments(options) { return options; } /** * @private */ function updatePopper() { if (this[popperElementSymbol].style.display !== STYLE_DISPLAY_MODE_BLOCK) { return; } if (this.getOption("disabled", false) === true) { return; } positionPopper.call( this, this[controlElementSymbol], this[popperElementSymbol], this.getOption("popper", {}), ); } /** * @private * @return {string} */ function getTemplate() { // language=HTML return ` <div data-monster-role="control" part="control"> <input data-monster-attributes="disabled path:disabled | if:true, class path:classes.input" data-monster-role="input" part="button" data-monster-replace="path:labels.button"> <div data-monster-role="popper" part="popper" tabindex="-1" class="monster-color-primary-1"> <div data-monster-role="arrow"></div> <div part="filter" class="flex" data-monster-replace="path:filter"> <div class="form-container" data-monster-role="form"> <div class="form-group" data-monster-role="range-type" data-monster-range-type="single"> <input type="radio" name="rangeType" value="single" tabindex="0"> <label for="single"><span data-monster-replace="path:labels.singleValue"></span></label> <input type="number" min="0" name="singleValue" disabled tabindex="0"> </div> <div class="form-group" data-monster-role="range-type" data-monster-range-type="from"> <input type="radio" name="rangeType" value="from" tabindex="0"> <label for="from"><span data-monster-replace="path:labels.fromValue"></span></label> <input type="number" min="0" name="fromValue" disabled tabindex="0"> </div> <div class="form-group" data-monster-role="range-type" data-monster-range-type="to"> <input type="radio" name="rangeType" value="to" tabindex="0"> <label for="to"><span data-monster-replace="path:labels.toValue"></span></label> <input type="number" min="0" name="toValue" disabled tabindex="0"> </div> <div class="form-group" data-monster-role="range-type" data-monster-range-type="from-to"> <input type="radio" name="rangeType" value="from-to" tabindex="0"> <label for="rangeFrom"><span data-monster-replace="path:labels.rangeFrom"></span></label> <input type="number" min="0" name="rangeFrom" disabled tabindex="0"> <label for="rangeTo"><span data-monster-replace="path:labels.toValue"></span></label> <input type="number" min="0" name="rangeTo" disabled tabindex="0"> </div> </div> </div> </div> `; } registerCustomElement(Range);