@schukai/monster 4.40.1 → 4.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Copyright © schukai GmbH and all contributing authors, {{copyRightYear}}. All rights reserved.
3
+ * Node module: @schukai/monster
4
+ *
5
+ * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3).
6
+ * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html
7
+ *
8
+ * For those who do not wish to adhere to the AGPLv3, a commercial license is available.
9
+ * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms.
10
+ * For more information about purchasing a commercial license, please contact schukai GmbH.
11
+ */
12
+
13
+ import { instanceSymbol } from "../../constants.mjs";
14
+ import { ATTRIBUTE_ROLE } from "../../dom/constants.mjs";
15
+ import { CustomControl } from "../../dom/customcontrol.mjs";
16
+ import {
17
+ assembleMethodSymbol,
18
+ registerCustomElement,
19
+ } from "../../dom/customelement.mjs";
20
+ import { fireCustomEvent } from "../../dom/events.mjs";
21
+
22
+ import { QuantityStyleSheet } from "./stylesheet/quantity.mjs";
23
+ import "./input-group.mjs";
24
+
25
+ export { Quantity };
26
+
27
+ const controlElementSymbol = Symbol("quantityControl");
28
+ const decrementButtonSymbol = Symbol("decrementButton");
29
+ const incrementButtonSymbol = Symbol("incrementButton");
30
+ const inputElementSymbol = Symbol("quantityInput");
31
+ const holdTimerSymbol = Symbol("holdTimer");
32
+ const holdIntervalSymbol = Symbol("holdInterval");
33
+
34
+ /**
35
+ * This Control shows an input field with increment and decrement buttons.
36
+ *
37
+ * @fragments /fragments/components/form/quantity/
38
+ *
39
+ * @example /examples/components/form/quantity-simple
40
+ *
41
+ * @since 4.41.0
42
+ * @copyright schukai GmbH
43
+ * @summary A beautiful quantity control with increment and decrement buttons
44
+ * @fires monster-quantity-change
45
+ */
46
+ class Quantity extends CustomControl {
47
+ static get [instanceSymbol]() {
48
+ return Symbol.for("@schukai/monster/components/form/quantity@@instance");
49
+ }
50
+
51
+ [assembleMethodSymbol]() {
52
+ super[assembleMethodSymbol]();
53
+
54
+ initControlReferences.call(this);
55
+ initEventHandler.call(this);
56
+ applyEditableState.call(this);
57
+ clampAndRender.call(this, this.getOption("value"));
58
+
59
+ return this;
60
+ }
61
+
62
+ /**
63
+ * Current numeric value
64
+ * @return {number|null}
65
+ */
66
+ get value() {
67
+ return this.getOption("value");
68
+ }
69
+
70
+ /**
71
+ * Sets the value programmatically (including clamping & FormValue)
72
+ * @param {number|string|null} v
73
+ */
74
+ set value(v) {
75
+ const n = normalizeNumber(v, this.getOption("precision"));
76
+ clampAndRender.call(this, n);
77
+ }
78
+
79
+ /**
80
+ * Options
81
+ *
82
+ * @property {Object} templates
83
+ * @property {string} templates.main Main template
84
+ * @property {Object} templateMapping
85
+ * @property {string} templateMapping.plus Icon (SVG-Path) Plus
86
+ * @property {string} templateMapping.minus Icon (SVG-Path) Minus
87
+ * @property {Object} classes CSS classes
88
+ * @property {string} classes.button Button class (e.g. monster-button-outline-primary)
89
+ * @property {string} classes.input Additional class for input
90
+ * @property {Object} features Feature toggles
91
+ * @property {boolean} features.editable Allow manual input
92
+ * @property {boolean} features.hold Press-and-hold accelerates
93
+ * @property {boolean} features.enforceBounds Clamp value when manual input is out of bounds
94
+ * @property {number} value Current value
95
+ * @property {number} min Use Number.NEGATIVE_INFINITY and Number.POSITIVE_INFINITY for no bounds
96
+ * @property {number} max Use Number.NEGATIVE_INFINITY and Number.POSITIVE_INFINITY for no bounds
97
+ * @property {number} step Increment/decrement step
98
+ * @property {number} precision Round to N decimal places (null = no explicit rounding)
99
+ * @property {boolean} disabled Disable the input field (also disables manual input)
100
+ * @property {string} placeholder Placeholder text
101
+ * @property {string} inputmode For mobile keyboards
102
+ */
103
+ get defaults() {
104
+ return Object.assign({}, super.defaults, {
105
+ templates: { main: getTemplate() },
106
+ templateMapping: {
107
+ plus: `
108
+ <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"/>`,
109
+ minus: `
110
+ <path d="M2 7.5a1 1 0 0 0 0 1H14a1 1 0 1 0 0-2H2a1 1 0 0 0 0 1z"/>`,
111
+ },
112
+ classes: {
113
+ button: "monster-button-outline-primary",
114
+ input: "",
115
+ },
116
+ features: {
117
+ editable: true,
118
+ hold: true,
119
+ enforceBounds: true,
120
+ },
121
+ value: 0,
122
+ min: 0,
123
+ max: Number.POSITIVE_INFINITY,
124
+ step: 1,
125
+ precision: null,
126
+
127
+ disabled: false,
128
+ placeholder: "",
129
+ inputmode: "decimal",
130
+ });
131
+ }
132
+
133
+ static getTag() {
134
+ return "monster-quantity";
135
+ }
136
+
137
+ // If you want a stylesheet, return it here.
138
+ static getCSSStyleSheet() {
139
+ return [QuantityStyleSheet];
140
+ }
141
+ }
142
+
143
+ /* -------------------------- internals ----------------------------------- */
144
+
145
+ function initControlReferences() {
146
+ this[controlElementSymbol] = this.shadowRoot.querySelector(
147
+ `[${ATTRIBUTE_ROLE}="control"]`,
148
+ );
149
+ this[decrementButtonSymbol] = this.shadowRoot.querySelector(
150
+ `[${ATTRIBUTE_ROLE}="decrement"]`,
151
+ );
152
+ this[incrementButtonSymbol] = this.shadowRoot.querySelector(
153
+ `[${ATTRIBUTE_ROLE}="increment"]`,
154
+ );
155
+ this[inputElementSymbol] = this.shadowRoot.querySelector(
156
+ `[${ATTRIBUTE_ROLE}="input"]`,
157
+ );
158
+ }
159
+
160
+ function initEventHandler() {
161
+ const stepOnce = (dir) => {
162
+ const step = Number(this.getOption("step")) || 1;
163
+ const cur = toNumberOr(this.value, 0);
164
+ const next = cur + (dir > 0 ? step : -step);
165
+ clampAndRender.call(this, next, {
166
+ fire: true,
167
+ kind: dir > 0 ? "increment" : "decrement",
168
+ });
169
+ };
170
+
171
+ const startHold = (dir) => {
172
+ if (!this.getOption("features.hold")) return;
173
+ clearTimeout(this[holdTimerSymbol]);
174
+ clearInterval(this[holdIntervalSymbol]);
175
+
176
+ // After a short delay, repeat faster
177
+ this[holdTimerSymbol] = setTimeout(() => {
178
+ this[holdIntervalSymbol] = setInterval(() => stepOnce(dir), 60);
179
+ }, 300);
180
+ };
181
+
182
+ const stopHold = () => {
183
+ clearTimeout(this[holdTimerSymbol]);
184
+ clearInterval(this[holdIntervalSymbol]);
185
+ };
186
+
187
+ // Buttons
188
+ this[decrementButtonSymbol].addEventListener("click", (e) => stepOnce(-1));
189
+ this[incrementButtonSymbol].addEventListener("click", (e) => stepOnce(1));
190
+
191
+ // Press & hold (Mouse/Touch)
192
+ ["mousedown", "pointerdown", "touchstart"].forEach((ev) => {
193
+ this[decrementButtonSymbol].addEventListener(ev, () => startHold(-1));
194
+ this[incrementButtonSymbol].addEventListener(ev, () => startHold(1));
195
+ });
196
+ ["mouseup", "mouseleave", "pointerup", "touchend", "touchcancel"].forEach(
197
+ (ev) => {
198
+ this[decrementButtonSymbol].addEventListener(ev, stopHold);
199
+ this[incrementButtonSymbol].addEventListener(ev, stopHold);
200
+ },
201
+ );
202
+
203
+ // Keyboard on input
204
+ this[inputElementSymbol].addEventListener("keydown", (e) => {
205
+ if (e.key === "ArrowUp") {
206
+ e.preventDefault();
207
+ stepOnce(1);
208
+ } else if (e.key === "ArrowDown") {
209
+ e.preventDefault();
210
+ stepOnce(-1);
211
+ }
212
+ });
213
+
214
+ // Manual input
215
+ this[inputElementSymbol].addEventListener("input", () => {
216
+ if (!this.getOption("features.editable")) return;
217
+ // Only store temporarily, clamp on blur/enter – but update FormValue immediately
218
+ const raw = this[inputElementSymbol].value;
219
+ const n = normalizeNumber(raw, this.getOption("precision"));
220
+ this.setOption("value", n);
221
+ this.setFormValue(n);
222
+ fireChanged.call(this, "input");
223
+ });
224
+
225
+ this[inputElementSymbol].addEventListener("blur", () => {
226
+ if (!this.getOption("features.editable")) return;
227
+ const n = normalizeNumber(
228
+ this[inputElementSymbol].value,
229
+ this.getOption("precision"),
230
+ );
231
+ clampAndRender.call(this, n, { fire: true, kind: "blur" });
232
+ });
233
+ }
234
+
235
+ function applyEditableState() {
236
+ const editable = !!this.getOption("features.editable");
237
+ this[inputElementSymbol].toggleAttribute("readonly", !editable);
238
+ this[inputElementSymbol].toggleAttribute(
239
+ "disabled",
240
+ !!this.getOption("disabled"),
241
+ );
242
+ }
243
+
244
+ function clampAndRender(n, opts = {}) {
245
+ const min = Number.isFinite(this.getOption("min"))
246
+ ? Number(this.getOption("min"))
247
+ : Number.NEGATIVE_INFINITY;
248
+ const max = Number.isFinite(this.getOption("max"))
249
+ ? Number(this.getOption("max"))
250
+ : Number.POSITIVE_INFINITY;
251
+
252
+ let value = n;
253
+ if (this.getOption("features.enforceBounds")) {
254
+ value = Math.min(max, Math.max(min, toNumberOr(n, 0)));
255
+ }
256
+
257
+ // Precision
258
+ const p = this.getOption("precision");
259
+ if (Number.isInteger(p) && p >= 0) {
260
+ value = Number(toFixedSafe(value, p));
261
+ }
262
+
263
+ // Render into input
264
+ this[inputElementSymbol].value =
265
+ value === null || Number.isNaN(value) ? "" : String(value);
266
+
267
+ // Options + FormValue
268
+ this.setOption("value", value);
269
+ this.setFormValue(value);
270
+
271
+ if (opts.fire) fireChanged.call(this, opts.kind || "programmatic");
272
+ }
273
+
274
+ function fireChanged(kind) {
275
+ fireCustomEvent(this, "monster-quantity-change", {
276
+ element: this,
277
+ value: this.value,
278
+ kind, // 'increment' | 'decrement' | 'input' | 'blur' | 'programmatic'
279
+ });
280
+ }
281
+
282
+ function normalizeNumber(v, precision) {
283
+ if (v === null || v === undefined || v === "") return null;
284
+ let n = Number(v);
285
+ if (!Number.isFinite(n)) return null;
286
+ if (Number.isInteger(precision) && precision >= 0) {
287
+ n = Number(toFixedSafe(n, precision));
288
+ }
289
+ return n;
290
+ }
291
+
292
+ function toNumberOr(v, dflt) {
293
+ const n = Number(v);
294
+ return Number.isFinite(n) ? n : dflt;
295
+ }
296
+
297
+ function toFixedSafe(n, p) {
298
+ // Prevents 1.00000000000002 effects
299
+ return (Math.round(n * Math.pow(10, p)) / Math.pow(10, p)).toFixed(p);
300
+ }
301
+
302
+ function getTemplate() {
303
+ // language=HTML
304
+ return `
305
+ <div data-monster-role="control" part="control">
306
+ <monster-input-group part="input-group">
307
+ <button type="button"
308
+ part="decrement-button"
309
+ data-monster-attributes="class path:classes.button"
310
+ data-monster-role="decrement"
311
+ aria-label="decrement">
312
+ <svg viewBox="0 0 16 16" aria-hidden="true" fill="currentColor"
313
+ >\${minus}</svg>
314
+ </button>
315
+
316
+ <input data-monster-role="input"
317
+ part="input"
318
+ data-monster-attributes="
319
+ class path:classes.input,
320
+ placeholder path:placeholder,
321
+ inputmode path:inputmode"
322
+ autocomplete="off" />
323
+
324
+ <button type="button"
325
+ part="increment-button"
326
+ data-monster-attributes="class path:classes.button"
327
+ data-monster-role="increment"
328
+ aria-label="increment">
329
+ <svg viewBox="0 0 16 16" aria-hidden="true" fill="currentColor">\${plus}</svg>
330
+ </button>
331
+ </monster-input-group>
332
+ </div>
333
+ `;
334
+ }
335
+
336
+ registerCustomElement(Quantity);
@@ -0,0 +1,150 @@
1
+ /* monster-quantity – Theme-aware using central Monster tokens */
2
+
3
+ :host {
4
+ /* Size tokens */
5
+ --qty-height: 40px;
6
+ --qty-radius: 0;
7
+ --qty-gap: var(--monster-space-4);
8
+ --qty-pad-x: var(--monster-space-4);
9
+
10
+ /* Anbindung an eure zentralen Tokens */
11
+ --qty-bg: var(--monster-theme-control-bg-color);
12
+ --qty-fg: var(--monster-theme-control-color);
13
+ --qty-border-color: var(--monster-theme-control-border-color);
14
+ --qty-outline: var(--monster-outline-width) solid var(--monster-theme-control-border-color);
15
+
16
+ /* Buttons */
17
+ --qty-btn-bg: var(--monster-bg-color-primary-3);
18
+ --qty-btn-bg-hover: var(--monster-bg-color-primary-4);
19
+ --qty-btn-bg-active: var(--monster-bg-color-primary-2);
20
+ --qty-btn-fg: var(--monster-color-primary-3);
21
+
22
+ /* Input */
23
+ --qty-input-fg: var(--monster-color-primary-1);
24
+ --qty-input-bg: transparent;
25
+
26
+ --qty-shadow:none;
27
+ color: var(--qty-fg);
28
+ font-family: var(--monster-font-family);
29
+ }
30
+
31
+ /* Container */
32
+ [part="control"] {
33
+ display: inline-flex;
34
+ vertical-align: middle;
35
+ }
36
+
37
+ /* Pill-Group */
38
+ [part="input-group"] {
39
+ display: inline-grid;
40
+ grid-auto-flow: column;
41
+ align-items: center;
42
+ gap: var(--qty-gap);
43
+ height: var(--qty-height);
44
+ padding-inline: var(--qty-pad-x);
45
+ background: var(--qty-bg);
46
+ color: var(--qty-fg);
47
+ border: var(--monster-theme-control-border-width, 1px)
48
+ var(--monster-theme-control-border-style, solid)
49
+ var(--qty-border-color);
50
+ border-radius: var(--qty-radius);
51
+ box-shadow: var(--qty-shadow);
52
+ }
53
+
54
+ /* Buttons */
55
+ [part="decrement-button"],
56
+ [part="increment-button"] {
57
+ display: inline-grid;
58
+ place-items: center;
59
+ width: calc(var(--qty-height) - var(--monster-space-4));
60
+ height: calc(var(--qty-height) - var(--monster-space-4));
61
+ border: 0;
62
+ border-radius: var(--qty-radius);
63
+ background: var(--qty-btn-bg);
64
+ color: var(--qty-btn-fg);
65
+ cursor: pointer;
66
+ transition: transform .08s ease, background-color .15s ease;
67
+ }
68
+
69
+ [part="decrement-button"] {
70
+ transform: translateX(-7px);
71
+ }
72
+ [part="increment-button"] {
73
+ transform: translateX(7px);
74
+ }
75
+
76
+
77
+ [part="decrement-button"]:hover,
78
+ [part="increment-button"]:hover {
79
+ background: var(--qty-btn-bg-hover);
80
+ }
81
+
82
+ [part="decrement-button"]:active,
83
+ [part="increment-button"]:active {
84
+ background: var(--qty-btn-bg-active);
85
+ }
86
+
87
+ [part="decrement-button"]:active {
88
+ transform: scale(.95) translateX(-7px);
89
+ }
90
+ [part="increment-button"]:active {
91
+ transform: scale(.95) translateX(7px);
92
+ }
93
+
94
+ [part="decrement-button"] svg,
95
+ [part="increment-button"] svg {
96
+ width: 18px;
97
+ height: 18px;
98
+ display: block;
99
+ pointer-events: none;
100
+ }
101
+
102
+ /* Input */
103
+ [part="input"] {
104
+ min-width: 2.5ch;
105
+ max-width: 6.5ch;
106
+ text-align: center;
107
+ font: inherit;
108
+ color: var(--qty-input-fg);
109
+ background: var(--qty-input-bg);
110
+ border: 0;
111
+ outline: none;
112
+ padding: 0 .25rem;
113
+ appearance: textfield; /* Safari */
114
+ }
115
+
116
+ /* Native Number-Spinner entfernen (falls type=number verwendet wird) */
117
+ [part="input"]::-webkit-outer-spin-button,
118
+ [part="input"]::-webkit-inner-spin-button {
119
+ -webkit-appearance: none;
120
+ margin: 0;
121
+ }
122
+
123
+ /* Fokus */
124
+ :host(:focus-within) [part="input-group"] {
125
+ outline: var(--qty-outline);
126
+ outline-offset: 2px;
127
+ }
128
+
129
+ /* Disabled */
130
+ :host([disabled]) [part="decrement-button"],
131
+ :host([disabled]) [part="increment-button"] {
132
+ opacity: .5;
133
+ cursor: not-allowed;
134
+ }
135
+
136
+ :host([disabled]) [part="input"] {
137
+ opacity: .75;
138
+ cursor: not-allowed;
139
+ }
140
+
141
+ /* Größen-Varianten */
142
+ :host([data-size="compact"]) { --qty-height: 32px; }
143
+ :host([data-size="large"]) { --qty-height: 48px; }
144
+
145
+ /* Motion */
146
+ @media (prefers-reduced-motion: reduce) {
147
+ [part="decrement-button"],
148
+ [part="increment-button"] { transition: none; }
149
+ }
150
+
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Copyright © schukai GmbH and all contributing authors, 2025. All rights reserved.
3
+ * Node module: @schukai/monster
4
+ *
5
+ * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3).
6
+ * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html
7
+ *
8
+ * For those who do not wish to adhere to the AGPLv3, a commercial license is available.
9
+ * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms.
10
+ * For more information about purchasing a commercial license, please contact schukai GmbH.
11
+ */
12
+
13
+ import {addAttributeToken} from "../../../dom/attributes.mjs";
14
+ import {ATTRIBUTE_ERRORMESSAGE} from "../../../dom/constants.mjs";
15
+
16
+ export {QuantityStyleSheet}
17
+
18
+ /**
19
+ * @private
20
+ * @type {CSSStyleSheet}
21
+ */
22
+ const QuantityStyleSheet = new CSSStyleSheet();
23
+
24
+ try {
25
+ QuantityStyleSheet.insertRule(`
26
+ @layer quantity {
27
+ :host{--qty-height:40px;--qty-radius:0;--qty-gap:var(--monster-space-4);--qty-pad-x:var(--monster-space-4);--qty-bg:var(--monster-theme-control-bg-color);--qty-fg:var(--monster-theme-control-color);--qty-border-color:var(--monster-theme-control-border-color);--qty-outline:var(--monster-outline-width) solid var(--monster-theme-control-border-color);--qty-btn-bg:var(--monster-bg-color-primary-3);--qty-btn-bg-hover:var(--monster-bg-color-primary-4);--qty-btn-bg-active:var(--monster-bg-color-primary-2);--qty-btn-fg:var(--monster-color-primary-3);--qty-input-fg:var(--monster-color-primary-1);--qty-input-bg:transparent;--qty-shadow:none;color:var(--qty-fg);font-family:var(--monster-font-family)}[part=control]{display:inline-flex;vertical-align:middle}[part=input-group]{align-items:center;background:var(--qty-bg);border:var(--monster-theme-control-border-width,1px) var(--monster-theme-control-border-style,solid) var(--qty-border-color);border-radius:var(--qty-radius);box-shadow:var(--qty-shadow);color:var(--qty-fg);display:inline-grid;gap:var(--qty-gap);grid-auto-flow:column;height:var(--qty-height);padding-inline:var(--qty-pad-x)}[part=decrement-button],[part=increment-button]{background:var(--qty-btn-bg);border:0;border-radius:var(--qty-radius);color:var(--qty-btn-fg);cursor:pointer;display:inline-grid;height:calc(var(--qty-height) - var(--monster-space-4));place-items:center;transition:transform .08s ease,background-color .15s ease;width:calc(var(--qty-height) - var(--monster-space-4))}[part=decrement-button]{transform:translateX(-7px)}[part=increment-button]{transform:translateX(7px)}[part=decrement-button]:hover,[part=increment-button]:hover{background:var(--qty-btn-bg-hover)}[part=decrement-button]:active,[part=increment-button]:active{background:var(--qty-btn-bg-active)}[part=decrement-button]:active{transform:scale(.95) translateX(-7px)}[part=increment-button]:active{transform:scale(.95) translateX(7px)}[part=decrement-button] svg,[part=increment-button] svg{display:block;height:18px;pointer-events:none;width:18px}[part=input]{-webkit-appearance:textfield;-moz-appearance:textfield;appearance:textfield;background:var(--qty-input-bg);border:0;color:var(--qty-input-fg);font:inherit;max-width:6.5ch;min-width:2.5ch;outline:none;padding:0 .25rem;text-align:center}[part=input]::-webkit-inner-spin-button,[part=input]::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}:host(:focus-within) [part=input-group]{outline:var(--qty-outline);outline-offset:2px}:host([disabled]) [part=decrement-button],:host([disabled]) [part=increment-button]{cursor:not-allowed;opacity:.5}:host([disabled]) [part=input]{cursor:not-allowed;opacity:.75}:host([data-size=compact]){--qty-height:32px}:host([data-size=large]){--qty-height:48px}@media (prefers-reduced-motion:reduce){[part=decrement-button],[part=increment-button]{transition:none}}
28
+ }`, 0);
29
+ } catch (e) {
30
+ addAttributeToken(document.getRootNode().querySelector('html'), ATTRIBUTE_ERRORMESSAGE, e + "");
31
+ }