@schukai/monster
Version:
Monster is a simple library for creating fast, robust and lightweight websites.
756 lines (667 loc) • 18.7 kB
JavaScript
/**
* 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);