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