@schukai/monster 4.80.0 → 4.82.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
 
4
4
 
5
+ ## [4.82.0] - 2026-01-07
6
+
7
+ ### Add Features
8
+
9
+ - Update Select component to handle null values gracefully
10
+
11
+
12
+
13
+ ## [4.81.0] - 2026-01-07
14
+
15
+ ### Add Features
16
+
17
+ - Add new State Machine implementation and Control Flow documentation
18
+ ### Changes
19
+
20
+ - update doc; close isse [#365](https://gitlab.schukai.com/oss/libraries/javascript/monster/issues/365)
21
+
22
+
23
+
5
24
  ## [4.80.0] - 2026-01-07
6
25
 
7
26
  ### Add Features
package/package.json CHANGED
@@ -1 +1 @@
1
- {"author":"Volker Schukai","dependencies":{"@floating-ui/dom":"^1.7.4","@popperjs/core":"^2.11.8"},"description":"Monster is a simple library for creating fast, robust and lightweight websites.","homepage":"https://monsterjs.org/","keywords":["framework","web","dom","css","sass","mobile-first","app","front-end","templates","schukai","core","shopcloud","alvine","monster","buildmap","stack","observer","observable","uuid","node","nodelist","css-in-js","logger","log","theme"],"license":"AGPL 3.0","main":"source/monster.mjs","module":"source/monster.mjs","name":"@schukai/monster","repository":{"type":"git","url":"https://gitlab.schukai.com/oss/libraries/javascript/monster.git"},"type":"module","version":"4.80.0"}
1
+ {"author":"Volker Schukai","dependencies":{"@floating-ui/dom":"^1.7.4","@popperjs/core":"^2.11.8"},"description":"Monster is a simple library for creating fast, robust and lightweight websites.","homepage":"https://monsterjs.org/","keywords":["framework","web","dom","css","sass","mobile-first","app","front-end","templates","schukai","core","shopcloud","alvine","monster","buildmap","stack","observer","observable","uuid","node","nodelist","css-in-js","logger","log","theme"],"license":"AGPL 3.0","main":"source/monster.mjs","module":"source/monster.mjs","name":"@schukai/monster","repository":{"type":"git","url":"https://gitlab.schukai.com/oss/libraries/javascript/monster.git"},"type":"module","version":"4.82.0"}
@@ -408,7 +408,7 @@ class Select extends CustomControl {
408
408
  * e.value=1
409
409
  * ```
410
410
  *
411
- * @property {string|array} value
411
+ * @property {string|array|null} value
412
412
  * @throws {Error} unsupported type
413
413
  * @fires monster-selected this event is fired when the selection is set
414
414
  */
@@ -3140,6 +3140,8 @@ function checkOptionState() {
3140
3140
  function convertValueToSelection(value) {
3141
3141
  const selection = [];
3142
3142
 
3143
+ value = isValueIsEmptyThenGetNormalize.call(this, value);
3144
+
3143
3145
  if (isString(value)) {
3144
3146
  value = value
3145
3147
  .split(",")
@@ -30,7 +30,7 @@ import {
30
30
  ATTRIBUTE_ROLE,
31
31
  } from "../../dom/constants.mjs";
32
32
  import { getWindow } from "../../dom/util.mjs";
33
- import { fireEvent } from "../../dom/events.mjs";
33
+ import { fireCustomEvent, fireEvent } from "../../dom/events.mjs";
34
34
  import { addErrorAttribute } from "../../dom/error.mjs";
35
35
 
36
36
  export { ToggleSwitch };
@@ -224,6 +224,8 @@ class ToggleSwitch extends CustomControl {
224
224
  toggleOn() {
225
225
  this.setOption("value", this.getOption("values.on"));
226
226
  fireEvent(this, "change");
227
+ fireCustomEvent(this, "monster-change", { value: this.value });
228
+ fireCustomEvent(this, "monster-changed", { value: this.value });
227
229
  return this;
228
230
  }
229
231
 
@@ -240,6 +242,8 @@ class ToggleSwitch extends CustomControl {
240
242
  toggleOff() {
241
243
  this.setOption("value", this.getOption("values.off"));
242
244
  fireEvent(this, "change");
245
+ fireCustomEvent(this, "monster-change", { value: this.value });
246
+ fireCustomEvent(this, "monster-changed", { value: this.value });
243
247
  return this;
244
248
  }
245
249
 
@@ -0,0 +1,415 @@
1
+ /**
2
+ * Copyright © Volker Schukai 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 Volker Schukai.
11
+ *
12
+ * SPDX-License-Identifier: AGPL-3.0
13
+ */
14
+
15
+ import { Pathfinder } from "../data/pathfinder.mjs";
16
+ import { ProxyObserver } from "../types/proxyobserver.mjs";
17
+ import { Observer } from "../types/observer.mjs";
18
+ import { isArray, isFunction, isObject, isString } from "../types/is.mjs";
19
+ import { getDocument } from "./util.mjs";
20
+
21
+ export { ControlFlow };
22
+
23
+ /**
24
+ * Lightweight control flow for event-driven control dependencies.
25
+ * Config is defined in JS and supports callbacks per event.
26
+ */
27
+ class ControlFlow {
28
+ constructor({
29
+ stateObserver,
30
+ state = {},
31
+ controls = {},
32
+ datasources = {},
33
+ rules = [],
34
+ } = {}) {
35
+ this[internalSymbol] = {
36
+ stateObserver:
37
+ stateObserver instanceof ProxyObserver
38
+ ? stateObserver
39
+ : new ProxyObserver(state),
40
+ controls: resolveControls(controls),
41
+ datasources: resolveDatasources(datasources),
42
+ rules: isArray(rules) ? rules : [],
43
+ listeners: [],
44
+ };
45
+
46
+ this.state = this[internalSymbol].stateObserver.getSubject();
47
+
48
+ this[internalSymbol].stateObserver.attachObserver(
49
+ new Observer(() => {
50
+ this.emit({
51
+ source: "state",
52
+ id: "root",
53
+ event: "changed",
54
+ value: this.state,
55
+ });
56
+ }),
57
+ );
58
+
59
+ bindControlEvents.call(this);
60
+ bindDatasourceEvents.call(this);
61
+ }
62
+
63
+ /**
64
+ * Create an API helper for rule handlers.
65
+ * @return {object}
66
+ */
67
+ createApi() {
68
+ return createApi(this);
69
+ }
70
+
71
+ /**
72
+ * Add a rule at runtime.
73
+ * @param {object} rule
74
+ */
75
+ addRule(rule) {
76
+ if (isObject(rule)) {
77
+ this[internalSymbol].rules.push(rule);
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Remove all listeners.
83
+ */
84
+ destroy() {
85
+ for (const entry of this[internalSymbol].listeners) {
86
+ entry.element.removeEventListener(entry.type, entry.handler);
87
+ }
88
+ this[internalSymbol].listeners = [];
89
+ }
90
+
91
+ /**
92
+ * Emit a synthetic event into the rule system.
93
+ * @param {object} ctx
94
+ */
95
+ emit(ctx) {
96
+ const context = normalizeContext(ctx, this);
97
+ for (const rule of this[internalSymbol].rules) {
98
+ if (!matchRule(rule, context)) {
99
+ continue;
100
+ }
101
+ const run = rule?.run;
102
+ if (isFunction(run)) {
103
+ run(context, createApi(this));
104
+ }
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Rule helper: control events.
110
+ * @param {string} id
111
+ * @param {string|string[]} events
112
+ * @param {function} run
113
+ * @return {object}
114
+ */
115
+ static onControl(id, events, run) {
116
+ return {
117
+ on: (ctx) =>
118
+ ctx.source === "control" &&
119
+ ctx.id === id &&
120
+ matchEvent(ctx.event, events),
121
+ run,
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Rule helper: datasource events.
127
+ * @param {string} id
128
+ * @param {string|string[]} events
129
+ * @param {function} run
130
+ * @return {object}
131
+ */
132
+ static onDatasource(id, events, run) {
133
+ return {
134
+ on: (ctx) =>
135
+ ctx.source === "datasource" &&
136
+ ctx.id === id &&
137
+ matchEvent(ctx.event, events),
138
+ run,
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Rule helper: state events.
144
+ * @param {string|string[]} events
145
+ * @param {function} run
146
+ * @return {object}
147
+ */
148
+ static onState(events, run) {
149
+ return {
150
+ on: (ctx) =>
151
+ ctx.source === "state" && matchEvent(ctx.event, events),
152
+ run,
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Rule helper: custom app events.
158
+ * @param {string|string[]} events
159
+ * @param {function} run
160
+ * @return {object}
161
+ */
162
+ static onApp(events, run) {
163
+ return {
164
+ on: (ctx) => ctx.source === "app" && matchEvent(ctx.event, events),
165
+ run,
166
+ };
167
+ }
168
+ }
169
+
170
+ /**
171
+ * @private
172
+ */
173
+ const internalSymbol = Symbol("controlFlowInternal");
174
+
175
+ /**
176
+ * @private
177
+ */
178
+ function resolveControls(controls) {
179
+ const resolved = {};
180
+ for (const [id, def] of Object.entries(controls || {})) {
181
+ const element = resolveElement(def?.element || def);
182
+ if (!element) continue;
183
+ resolved[id] = {
184
+ element,
185
+ events: def?.events || ["monster-changed", "change"],
186
+ };
187
+ }
188
+ return resolved;
189
+ }
190
+
191
+ /**
192
+ * @private
193
+ */
194
+ function resolveDatasources(datasources) {
195
+ const resolved = {};
196
+ for (const [id, def] of Object.entries(datasources || {})) {
197
+ const element = resolveElement(def?.element || def);
198
+ if (!element) continue;
199
+ resolved[id] = {
200
+ element,
201
+ events: def?.events || [
202
+ "monster-datasource-fetched",
203
+ "monster-datasource-error",
204
+ ],
205
+ };
206
+ }
207
+ return resolved;
208
+ }
209
+
210
+ /**
211
+ * @private
212
+ */
213
+ function resolveElement(elementOrSelector) {
214
+ if (elementOrSelector instanceof HTMLElement) {
215
+ return elementOrSelector;
216
+ }
217
+ if (isString(elementOrSelector)) {
218
+ return getDocument().querySelector(elementOrSelector);
219
+ }
220
+ return null;
221
+ }
222
+
223
+ /**
224
+ * @private
225
+ */
226
+ function bindControlEvents() {
227
+ for (const [id, control] of Object.entries(this[internalSymbol].controls)) {
228
+ for (const type of control.events) {
229
+ const handler = (event) => {
230
+ this.emit({
231
+ source: "control",
232
+ id,
233
+ event: type,
234
+ value: readControlValue(control.element),
235
+ rawEvent: event,
236
+ });
237
+ };
238
+ control.element.addEventListener(type, handler);
239
+ this[internalSymbol].listeners.push({
240
+ element: control.element,
241
+ type,
242
+ handler,
243
+ });
244
+ }
245
+ }
246
+ }
247
+
248
+ /**
249
+ * @private
250
+ */
251
+ function bindDatasourceEvents() {
252
+ for (const [id, datasource] of Object.entries(
253
+ this[internalSymbol].datasources,
254
+ )) {
255
+ for (const type of datasource.events) {
256
+ const handler = (event) => {
257
+ this.emit({
258
+ source: "datasource",
259
+ id,
260
+ event: type,
261
+ value: datasource.element?.data,
262
+ rawEvent: event,
263
+ });
264
+ };
265
+ datasource.element.addEventListener(type, handler);
266
+ this[internalSymbol].listeners.push({
267
+ element: datasource.element,
268
+ type,
269
+ handler,
270
+ });
271
+ }
272
+ }
273
+ }
274
+
275
+ /**
276
+ * @private
277
+ */
278
+ function readControlValue(element) {
279
+ if (element && "value" in element) {
280
+ return element.value;
281
+ }
282
+ if (isFunction(element?.getOption)) {
283
+ return element.getOption("value");
284
+ }
285
+ return undefined;
286
+ }
287
+
288
+ /**
289
+ * @private
290
+ */
291
+ function matchRule(rule, ctx) {
292
+ if (!rule) return false;
293
+ if (isFunction(rule.on)) {
294
+ return !!rule.on(ctx);
295
+ }
296
+ if (isString(rule.on)) {
297
+ return rule.on === ctx.key || rule.on === ctx.topic;
298
+ }
299
+ if (isArray(rule.on)) {
300
+ return rule.on.includes(ctx.key) || rule.on.includes(ctx.topic);
301
+ }
302
+ return false;
303
+ }
304
+
305
+ /**
306
+ * @private
307
+ */
308
+ function normalizeContext(ctx, flow) {
309
+ const context = Object.assign({}, ctx || {});
310
+ context.key = `${context.source}:${context.id}:${context.event}`;
311
+ context.topic = `${context.source}:${context.id}`;
312
+ context.state = flow.state;
313
+ context.controls = flow[internalSymbol].controls;
314
+ context.datasources = flow[internalSymbol].datasources;
315
+ return context;
316
+ }
317
+
318
+ /**
319
+ * @private
320
+ */
321
+ function matchEvent(event, events) {
322
+ if (!events) return true;
323
+ if (isString(events)) {
324
+ return event === events;
325
+ }
326
+ if (isArray(events)) {
327
+ return events.includes(event);
328
+ }
329
+ return false;
330
+ }
331
+
332
+ /**
333
+ * @private
334
+ */
335
+ function createApi(flow) {
336
+ return {
337
+ state: flow.state,
338
+ getState(path, fallback) {
339
+ try {
340
+ return new Pathfinder(flow.state).getVia(path);
341
+ } catch (e) {
342
+ return fallback;
343
+ }
344
+ },
345
+ setState(path, value) {
346
+ new Pathfinder(flow.state).setVia(path, value);
347
+ },
348
+ getControl(id) {
349
+ return flow[internalSymbol].controls?.[id]?.element;
350
+ },
351
+ setControlOption(id, path, value) {
352
+ const control = flow[internalSymbol].controls?.[id]?.element;
353
+ if (isFunction(control?.setOption)) {
354
+ control.setOption(path, value);
355
+ }
356
+ },
357
+ setControlOptions(id, options) {
358
+ const control = flow[internalSymbol].controls?.[id]?.element;
359
+ if (isFunction(control?.setOptions)) {
360
+ control.setOptions(options);
361
+ } else if (isFunction(control?.setOption)) {
362
+ control.setOption("options", options);
363
+ }
364
+ },
365
+ setControlValue(id, value) {
366
+ const control = flow[internalSymbol].controls?.[id]?.element;
367
+ if (control && "value" in control) {
368
+ control.value = value;
369
+ } else if (isFunction(control?.setOption)) {
370
+ control.setOption("value", value);
371
+ }
372
+ },
373
+ clearControl(id) {
374
+ const control = flow[internalSymbol].controls?.[id]?.element;
375
+ if (control && "value" in control) {
376
+ control.value = "";
377
+ } else if (isFunction(control?.setOption)) {
378
+ control.setOption("value", "");
379
+ }
380
+ },
381
+ disableControl(id, disabled) {
382
+ const control = flow[internalSymbol].controls?.[id]?.element;
383
+ if (isFunction(control?.setOption)) {
384
+ control.setOption("disabled", !!disabled);
385
+ }
386
+ },
387
+ fetchDatasource(id, { url, transform } = {}) {
388
+ const datasource = flow[internalSymbol].datasources?.[id]?.element;
389
+ if (!datasource) {
390
+ return Promise.reject(new Error("datasource not found"));
391
+ }
392
+ return new Promise((resolve, reject) => {
393
+ const onFetched = () => resolve(datasource.data);
394
+ const onError = (event) => {
395
+ reject(event?.detail?.error || new Error("Datasource error"));
396
+ };
397
+ datasource.addEventListener("monster-datasource-fetched", onFetched, {
398
+ once: true,
399
+ });
400
+ datasource.addEventListener("monster-datasource-error", onError, {
401
+ once: true,
402
+ });
403
+ if (isFunction(transform)) {
404
+ datasource.setOption("read.responseCallback", (payload) => {
405
+ datasource.data = transform(payload);
406
+ });
407
+ }
408
+ if (url) {
409
+ datasource.setOption("read.url", url);
410
+ }
411
+ datasource.read();
412
+ });
413
+ },
414
+ };
415
+ }
@@ -161,6 +161,7 @@ export * from "./constraints/andoperator.mjs";
161
161
  export * from "./constraints/isarray.mjs";
162
162
  export * from "./constraints/abstract.mjs";
163
163
  export * from "./constraints/valid.mjs";
164
+ export * from "./dom/controlflow.mjs";
164
165
  export * from "./dom/dimension.mjs";
165
166
  export * from "./dom/error.mjs";
166
167
  export * from "./dom/resource/link/stylesheet.mjs";
@@ -281,6 +281,34 @@ describe('Select', function () {
281
281
  }, 350);
282
282
  });
283
283
 
284
+ it('should treat null value as empty selection', function (done) {
285
+ this.timeout(2000);
286
+
287
+ let mocks = document.getElementById('mocks');
288
+ const select = document.createElement('monster-select');
289
+ select.setOption('options', [{label: 'One', value: '1'}]);
290
+ mocks.appendChild(select);
291
+
292
+ setTimeout(() => {
293
+ select.value = null;
294
+
295
+ setTimeout(() => {
296
+ try {
297
+ const selection = select.getOption('selection');
298
+ const error = select.getAttribute('data-monster-error') ?? '';
299
+ expect(Array.isArray(selection)).to.equal(true);
300
+ expect(selection.length).to.equal(0);
301
+ expect(select.value).to.equal('');
302
+ expect(error).to.not.contain('unsupported type');
303
+ } catch (e) {
304
+ return done(e);
305
+ }
306
+
307
+ done();
308
+ }, 50);
309
+ }, 50);
310
+ });
311
+
284
312
  });
285
313
 
286
314