UNPKG

@schukai/monster

Version:

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

371 lines (331 loc) 10.8 kB
/** * Copyright © Volker Schukai 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 Volker Schukai. */ import { instanceSymbol } from "../../constants.mjs"; import { ATTRIBUTE_ROLE } from "../../dom/constants.mjs"; import { CustomControl } from "../../dom/customcontrol.mjs"; import { assembleMethodSymbol, registerCustomElement, } from "../../dom/customelement.mjs"; import { fireCustomEvent } from "../../dom/events.mjs"; import { QuantityStyleSheet } from "./stylesheet/quantity.mjs"; import "./input-group.mjs"; export { Quantity }; /** * @private */ const controlElementSymbol = Symbol("quantityControl"); /** * @private */ const decrementButtonSymbol = Symbol("decrementButton"); /** * @private */ const incrementButtonSymbol = Symbol("incrementButton"); /** * @private */ const inputElementSymbol = Symbol("quantityInput"); /** * @private */ const holdTimerSymbol = Symbol("holdTimer"); /** * @private */ const holdIntervalSymbol = Symbol("holdInterval"); /** * This Control shows an input field with increment and decrement buttons. * * @fragments /fragments/components/form/quantity/ * * @example /examples/components/form/quantity-simple * * @since 4.41.0 * @copyright Volker Schukai * @summary A beautiful quantity control with increment and decrement buttons * @fires monster-quantity-change */ class Quantity extends CustomControl { static get [instanceSymbol]() { return Symbol.for("@schukai/monster/components/form/quantity@@instance"); } [assembleMethodSymbol]() { super[assembleMethodSymbol](); initControlReferences.call(this); initEventHandler.call(this); applyEditableState.call(this); clampAndRender.call(this, this.getOption("value")); return this; } /** * Current numeric value * @return {number|null} */ get value() { return this.getOption("value"); } /** * Sets the value programmatically (including clamping & FormValue) * @param {number|string|null} v */ set value(v) { const n = normalizeNumber(v, this.getOption("precision")); clampAndRender.call(this, n); } /** * Options * * @property {Object} templates * @property {string} templates.main Main template * @property {Object} templateMapping * @property {string} templateMapping.plus Icon (SVG-Path) Plus * @property {string} templateMapping.minus Icon (SVG-Path) Minus * @property {Object} classes CSS classes * @property {string} classes.button Button class (e.g. monster-button-outline-primary) * @property {string} classes.input Additional class for input * @property {Object} features Feature toggles * @property {boolean} features.editable Allow manual input * @property {boolean} features.hold Press-and-hold accelerates * @property {boolean} features.enforceBounds Clamp value when manual input is out of bounds * @property {number} value Current value * @property {number} min Use Number.NEGATIVE_INFINITY and Number.POSITIVE_INFINITY for no bounds * @property {number} max Use Number.NEGATIVE_INFINITY and Number.POSITIVE_INFINITY for no bounds * @property {number} step Increment/decrement step * @property {number} precision Round to N decimal places (null = no explicit rounding) * @property {boolean} disabled Disable the input field (also disables manual input) * @property {string} placeholder Placeholder text * @property {string} inputmode For mobile keyboards */ get defaults() { return Object.assign({}, super.defaults, { templates: { main: getTemplate() }, templateMapping: { plus: ` <path d="M8 1a1 1 0 0 1 1 1v5h5a1 1 0 1 1 0 2H9v5a1 1 0 1 1-2 0V9H2a1 1 0 1 1 0-2h5V2a1 1 0 0 1 1-1z"/>`, minus: ` <path d="M2 7.5a1 1 0 0 0 0 1H14a1 1 0 1 0 0-2H2a1 1 0 0 0 0 1z"/>`, }, classes: { button: "monster-button-outline-primary", input: "", }, features: { editable: true, hold: true, enforceBounds: true, }, value: 0, min: 0, max: Number.POSITIVE_INFINITY, step: 1, precision: null, disabled: false, placeholder: "", inputmode: "decimal", }); } static getTag() { return "monster-quantity"; } // If you want a stylesheet, return it here. static getCSSStyleSheet() { return [QuantityStyleSheet]; } } function getFiniteNumberOr(optionValue, fallback) { const n = Number(optionValue); return Number.isFinite(n) ? n : fallback; } /** * @private * @description Initialize references to important control elements * @this {Quantity} * @return {void} */ function initControlReferences() { this[controlElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}="control"]`, ); this[decrementButtonSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}="decrement"]`, ); this[incrementButtonSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}="increment"]`, ); this[inputElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}="input"]`, ); } /** * @private * @description Initialize event handlers * @this {Quantity} * @return {void} */ function initEventHandler() { const stepOnce = (dir) => { const step = Number(this.getOption("step")) || 1; const cur = toNumberOr(this.value, 0); const next = cur + (dir > 0 ? step : -step); clampAndRender.call(this, next, { fire: true, kind: dir > 0 ? "increment" : "decrement", }); }; const startHold = (dir) => { if (!this.getOption("features.hold")) return; clearTimeout(this[holdTimerSymbol]); clearInterval(this[holdIntervalSymbol]); // After a short delay, repeat faster this[holdTimerSymbol] = setTimeout(() => { this[holdIntervalSymbol] = setInterval(() => stepOnce(dir), 60); }, 300); }; const stopHold = () => { clearTimeout(this[holdTimerSymbol]); clearInterval(this[holdIntervalSymbol]); }; // Buttons this[decrementButtonSymbol].addEventListener("click", (e) => stepOnce(-1)); this[incrementButtonSymbol].addEventListener("click", (e) => stepOnce(1)); // Press & hold (Mouse/Touch) ["mousedown", "pointerdown", "touchstart"].forEach((ev) => { this[decrementButtonSymbol].addEventListener(ev, () => startHold(-1)); this[incrementButtonSymbol].addEventListener(ev, () => startHold(1)); }); ["mouseup", "mouseleave", "pointerup", "touchend", "touchcancel"].forEach( (ev) => { this[decrementButtonSymbol].addEventListener(ev, stopHold); this[incrementButtonSymbol].addEventListener(ev, stopHold); }, ); // Keyboard on input this[inputElementSymbol].addEventListener("keydown", (e) => { if (e.key === "ArrowUp") { e.preventDefault(); stepOnce(1); } else if (e.key === "ArrowDown") { e.preventDefault(); stepOnce(-1); } }); // Manual input this[inputElementSymbol].addEventListener("input", () => { if (!this.getOption("features.editable")) return; // Only store temporarily, clamp on blur/enter – but update FormValue immediately const raw = this[inputElementSymbol].value; const n = normalizeNumber(raw, this.getOption("precision")); this.setOption("value", n); this.setFormValue(n); fireChanged.call(this, "input"); }); this[inputElementSymbol].addEventListener("blur", () => { if (!this.getOption("features.editable")) return; const n = normalizeNumber( this[inputElementSymbol].value, this.getOption("precision"), ); clampAndRender.call(this, n, { fire: true, kind: "blur" }); }); } function applyEditableState() { const editable = !!this.getOption("features.editable"); this[inputElementSymbol].toggleAttribute("readonly", !editable); this[inputElementSymbol].toggleAttribute( "disabled", !!this.getOption("disabled"), ); } function clampAndRender(n, opts = {}) { const min = getFiniteNumberOr( this.getOption("min"), Number.NEGATIVE_INFINITY, ); const max = getFiniteNumberOr( this.getOption("max"), Number.POSITIVE_INFINITY, ); let value = n; if (this.getOption("features.enforceBounds")) { value = Math.min(max, Math.max(min, toNumberOr(n, 0))); } // Precision const p = this.getOption("precision"); if (Number.isInteger(p) && p >= 0) { value = Number(toFixedSafe(value, p)); } // Render into input this[inputElementSymbol].value = value === null || Number.isNaN(value) ? "" : String(value); // Options + FormValue this.setOption("value", value); this.setFormValue(value); if (opts.fire) fireChanged.call(this, opts.kind || "programmatic"); } function fireChanged(kind) { fireCustomEvent(this, "monster-quantity-change", { element: this, value: this.value, kind, // 'increment' | 'decrement' | 'input' | 'blur' | 'programmatic' }); } function normalizeNumber(v, precision) { if (v === null || v === undefined || v === "") return null; let n = Number(v); if (!Number.isFinite(n)) return null; if (Number.isInteger(precision) && precision >= 0) { n = Number(toFixedSafe(n, precision)); } return n; } function toNumberOr(v, dflt) { const n = Number(v); return Number.isFinite(n) ? n : dflt; } function toFixedSafe(n, p) { // Prevents 1.00000000000002 effects return (Math.round(n * Math.pow(10, p)) / Math.pow(10, p)).toFixed(p); } function getTemplate() { // language=HTML return ` <div data-monster-role="control" part="control"> <monster-input-group part="input-group"> <button type="button" part="decrement-button" data-monster-attributes="class path:classes.button" data-monster-role="decrement" aria-label="decrement"> <svg viewBox="0 0 16 16" aria-hidden="true" fill="currentColor" >\${minus}</svg> </button> <input data-monster-role="input" part="input" data-monster-attributes=" class path:classes.input, placeholder path:placeholder, inputmode path:inputmode" autocomplete="off" /> <button type="button" part="increment-button" data-monster-attributes="class path:classes.button" data-monster-role="increment" aria-label="increment"> <svg viewBox="0 0 16 16" aria-hidden="true" fill="currentColor">\${plus}</svg> </button> </monster-input-group> </div> `; } registerCustomElement(Quantity);