@schukai/monster 4.147.1 → 4.148.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/package.json CHANGED
@@ -1 +1 @@
1
- {"author":"Volker Schukai","dependencies":{"@floating-ui/dom":"^1.7.6"},"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.147.1"}
1
+ {"author":"Volker Schukai","dependencies":{"@floating-ui/dom":"^1.7.6"},"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.148.0"}
@@ -39,7 +39,7 @@ const controlElementSymbol = Symbol("controlElement");
39
39
  const itemsElementSymbol = Symbol("itemsElement");
40
40
 
41
41
  /**
42
- * A card-based single choice control.
42
+ * A card-based choice control.
43
43
  *
44
44
  * @fragments /fragments/components/form/choice-cards
45
45
  *
@@ -47,11 +47,13 @@ const itemsElementSymbol = Symbol("itemsElement");
47
47
  *
48
48
  * @since 4.137.0
49
49
  * @copyright Volker Schukai
50
- * @summary A visual radio-card picker for choosing one option from a compact set.
50
+ * @summary A visual card picker for choosing one or multiple options from a compact set.
51
51
  *
52
52
  * Event details:
53
- * - `monster-selected`, `monster-change` and `monster-changed` use
54
- * `{value: string|null, item: Object|null}`.
53
+ * - In single-select mode, `monster-selected`, `monster-change` and
54
+ * `monster-changed` use `{value: string|null, item: Object|null}`.
55
+ * - In multi-select mode, event details use
56
+ * `{value: Array<string>, item: Object|null, selected: boolean}`.
55
57
  * - The native `change` event is emitted before the custom Monster events.
56
58
  *
57
59
  * @fires monster-selected
@@ -70,7 +72,8 @@ class ChoiceCards extends CustomControl {
70
72
  * @property {string} items[].iconSlot Slot name for custom icon content.
71
73
  * @property {string} items[].contentSlot Slot name for custom card content. The shorter `slot` field is accepted as alias.
72
74
  * @property {boolean} items[].disabled Disables a single choice while keeping it visible.
73
- * @property {string|null} value Current selected value.
75
+ * @property {string|null|Array<string>} value Current selected value. Multi-select mode uses an array.
76
+ * @property {boolean} multiple Enables independent on/off selection for multiple cards.
74
77
  * @property {boolean} disabled Disabled state.
75
78
  * @property {Object} labels Accessible labels.
76
79
  * @property {string} labels.group Radio group label.
@@ -81,6 +84,7 @@ class ChoiceCards extends CustomControl {
81
84
  return Object.assign({}, super.defaults, {
82
85
  items: [],
83
86
  value: null,
87
+ multiple: false,
84
88
  disabled: false,
85
89
  eventProcessing: false,
86
90
  labels: {
@@ -136,21 +140,26 @@ class ChoiceCards extends CustomControl {
136
140
  }
137
141
 
138
142
  /**
139
- * @return {string}
143
+ * @return {string|null|Array<string>}
140
144
  */
141
145
  get value() {
142
- return this.getOption("value");
146
+ return normalizeControlValue(
147
+ this.getOption("value"),
148
+ isMultiple.call(this),
149
+ );
143
150
  }
144
151
 
145
152
  /**
146
- * @param {string|null|undefined} value
153
+ * @param {string|Array<string>|null|undefined} value
147
154
  */
148
155
  set value(value) {
149
- const normalized = normalizeValue(value);
150
- if (this.getOption("value") !== normalized) {
156
+ const multiple = isMultiple.call(this);
157
+ const normalized = normalizeControlValue(value, multiple);
158
+ const current = normalizeControlValue(this.getOption("value"), multiple);
159
+ if (!valuesEqual(current, normalized)) {
151
160
  this.setOption("value", normalized);
152
161
  }
153
- syncValueAttribute.call(this, normalized);
162
+ syncValueAttribute.call(this, normalized, multiple);
154
163
  render.call(this);
155
164
  syncFormValue.call(this);
156
165
  }
@@ -189,6 +198,10 @@ class ChoiceCards extends CustomControl {
189
198
  return this;
190
199
  }
191
200
 
201
+ if (isMultiple.call(this)) {
202
+ return toggleMultipleSelection.call(this, normalized, item || null);
203
+ }
204
+
192
205
  if (this.value === normalized) {
193
206
  return this;
194
207
  }
@@ -227,7 +240,13 @@ function initControlReferences() {
227
240
  */
228
241
  function initItems() {
229
242
  if (this.hasAttribute("value")) {
230
- this.setOption("value", normalizeValue(this.getAttribute("value")));
243
+ this.setOption(
244
+ "value",
245
+ normalizeControlValue(
246
+ this.getAttribute("value"),
247
+ isMultiple.call(this),
248
+ ),
249
+ );
231
250
  }
232
251
 
233
252
  const normalized = normalizeItems(this.getOption("items", []));
@@ -274,29 +293,32 @@ function initEventHandler() {
274
293
  return;
275
294
  }
276
295
 
296
+ const multiple = isMultiple.call(this);
297
+
277
298
  if (event.key === "Home") {
278
299
  event.preventDefault();
279
- focusAndSelect.call(this, buttons[0]);
300
+ focusButton.call(this, buttons[0], !multiple);
280
301
  return;
281
302
  }
282
303
 
283
304
  if (event.key === "End") {
284
305
  event.preventDefault();
285
- focusAndSelect.call(this, buttons[buttons.length - 1]);
306
+ focusButton.call(this, buttons[buttons.length - 1], !multiple);
286
307
  return;
287
308
  }
288
309
 
289
310
  if (["ArrowRight", "ArrowDown"].includes(event.key)) {
290
311
  event.preventDefault();
291
- focusAndSelect.call(this, buttons[(index + 1) % buttons.length]);
312
+ focusButton.call(this, buttons[(index + 1) % buttons.length], !multiple);
292
313
  return;
293
314
  }
294
315
 
295
316
  if (["ArrowLeft", "ArrowUp"].includes(event.key)) {
296
317
  event.preventDefault();
297
- focusAndSelect.call(
318
+ focusButton.call(
298
319
  this,
299
320
  buttons[(index - 1 + buttons.length) % buttons.length],
321
+ !multiple,
300
322
  );
301
323
  }
302
324
  });
@@ -305,11 +327,14 @@ function initEventHandler() {
305
327
  /**
306
328
  * @private
307
329
  * @param {HTMLButtonElement} button
330
+ * @param {boolean} select
308
331
  * @return {void}
309
332
  */
310
- function focusAndSelect(button) {
333
+ function focusButton(button, select) {
311
334
  button.focus();
312
- this.select(button.getAttribute("data-choice-value"));
335
+ if (select === true) {
336
+ this.select(button.getAttribute("data-choice-value"));
337
+ }
313
338
  }
314
339
 
315
340
  /**
@@ -349,10 +374,14 @@ function render() {
349
374
  }
350
375
 
351
376
  const items = normalizeItems(this.getOption("items", []));
352
- const value = normalizeValue(this.getOption("value"));
377
+ const multiple = isMultiple.call(this);
378
+ const value = normalizeControlValue(this.getOption("value"), multiple);
379
+ const selectedValues = multiple ? value : [];
353
380
  const disabled =
354
381
  this.getOption("disabled") === true || this.hasAttribute("disabled");
355
- const hasSelectedItem = items.some((item) => item.value === value);
382
+ const hasSelectedItem = items.some((item) =>
383
+ multiple ? selectedValues.includes(item.value) : item.value === value,
384
+ );
356
385
  const firstEnabledIndex = items.findIndex((item) => item.disabled !== true);
357
386
 
358
387
  this[itemsElementSymbol].replaceChildren();
@@ -360,17 +389,24 @@ function render() {
360
389
  "aria-label",
361
390
  this.getOption("labels.group", "Choices"),
362
391
  );
392
+ this[controlElementSymbol]?.setAttribute(
393
+ "role",
394
+ multiple ? "group" : "radiogroup",
395
+ );
363
396
 
364
397
  for (const [index, item] of items.entries()) {
365
- const selected = value !== null && item.value === value;
366
- const tabbable =
367
- selected || (hasSelectedItem === false && index === firstEnabledIndex);
398
+ const selected = multiple
399
+ ? selectedValues.includes(item.value)
400
+ : value !== null && item.value === value;
401
+ const tabbable = multiple
402
+ ? item.disabled !== true
403
+ : selected || (hasSelectedItem === false && index === firstEnabledIndex);
368
404
  const button = document.createElement("button");
369
405
  button.type = "button";
370
406
  button.setAttribute("data-monster-role", "choice");
371
407
  button.setAttribute("data-choice-value", item.value);
372
408
  button.setAttribute("part", "choice");
373
- button.setAttribute("role", "radio");
409
+ button.setAttribute("role", multiple ? "checkbox" : "radio");
374
410
  button.setAttribute("aria-label", item.label);
375
411
  button.setAttribute("aria-checked", selected ? "true" : "false");
376
412
  button.tabIndex = tabbable ? 0 : -1;
@@ -430,6 +466,14 @@ function findItem(value) {
430
466
  );
431
467
  }
432
468
 
469
+ /**
470
+ * @private
471
+ * @return {boolean}
472
+ */
473
+ function isMultiple() {
474
+ return this.getOption("multiple") === true;
475
+ }
476
+
433
477
  /**
434
478
  * @private
435
479
  * @param {*} value
@@ -442,6 +486,61 @@ function normalizeValue(value) {
442
486
  return String(value);
443
487
  }
444
488
 
489
+ /**
490
+ * @private
491
+ * @param {*} value
492
+ * @param {boolean} multiple
493
+ * @return {string|null|Array<string>}
494
+ */
495
+ function normalizeControlValue(value, multiple) {
496
+ if (multiple === true) {
497
+ return normalizeValues(value);
498
+ }
499
+ return normalizeValue(value);
500
+ }
501
+
502
+ /**
503
+ * @private
504
+ * @param {*} value
505
+ * @return {Array<string>}
506
+ */
507
+ function normalizeValues(value) {
508
+ if (value === undefined || value === null || value === "") {
509
+ return [];
510
+ }
511
+
512
+ const raw = isArray(value)
513
+ ? value
514
+ : isString(value)
515
+ ? value.split("::")
516
+ : [value];
517
+ const normalized = raw
518
+ .map((entry) => normalizeValue(entry))
519
+ .filter((entry) => entry !== null);
520
+
521
+ return Array.from(new Set(normalized));
522
+ }
523
+
524
+ /**
525
+ * @private
526
+ * @param {string|null|Array<string>} current
527
+ * @param {string|null|Array<string>} next
528
+ * @return {boolean}
529
+ */
530
+ function valuesEqual(current, next) {
531
+ if (isArray(current) || isArray(next)) {
532
+ if (!isArray(current) || !isArray(next)) {
533
+ return false;
534
+ }
535
+ if (current.length !== next.length) {
536
+ return false;
537
+ }
538
+ return current.every((value, index) => value === next[index]);
539
+ }
540
+
541
+ return current === next;
542
+ }
543
+
445
544
  /**
446
545
  * @private
447
546
  * @param {*} items
@@ -493,28 +592,94 @@ function normalizeItems(items) {
493
592
  */
494
593
  function syncFormValue() {
495
594
  if (typeof this.setFormValue === "function") {
595
+ if (isMultiple.call(this)) {
596
+ const values = normalizeValues(this.value);
597
+ const serialized = serializeValues(values);
598
+ this.setFormValue(serialized, serialized);
599
+ return;
600
+ }
601
+
496
602
  this.setFormValue(this.value ?? "");
497
603
  }
498
604
  }
499
605
 
500
606
  /**
501
607
  * @private
502
- * @param {string|null} value
608
+ * @param {string|null|Array<string>} value
609
+ * @param {boolean} multiple
503
610
  * @return {void}
504
611
  */
505
- function syncValueAttribute(value) {
506
- if (value === null) {
612
+ function syncValueAttribute(value, multiple) {
613
+ const attributeValue = multiple ? serializeValues(value) : value;
614
+ if (attributeValue === null || attributeValue === "") {
507
615
  if (this.hasAttribute("value")) {
508
616
  this.removeAttribute("value");
509
617
  }
510
618
  return;
511
619
  }
512
620
 
513
- if (this.getAttribute("value") !== value) {
514
- this.setAttribute("value", value);
621
+ if (this.getAttribute("value") !== attributeValue) {
622
+ this.setAttribute("value", attributeValue);
515
623
  }
516
624
  }
517
625
 
626
+ /**
627
+ * @private
628
+ * @param {Array<string>|string|null} values
629
+ * @return {string}
630
+ */
631
+ function serializeValues(values) {
632
+ return normalizeValues(values).join("::");
633
+ }
634
+
635
+ /**
636
+ * @private
637
+ * @param {string|null} value
638
+ * @param {Object|null} item
639
+ * @return {ChoiceCards}
640
+ */
641
+ function toggleMultipleSelection(value, item) {
642
+ const current = normalizeValues(this.value);
643
+ if (value === null) {
644
+ if (current.length === 0) {
645
+ return this;
646
+ }
647
+ this.value = [];
648
+ fireSelectionEvents.call(this, {
649
+ value: this.value,
650
+ item: null,
651
+ selected: false,
652
+ });
653
+ return this;
654
+ }
655
+
656
+ const selected = current.includes(value);
657
+ const next = selected
658
+ ? current.filter((entry) => entry !== value)
659
+ : current.concat(value);
660
+
661
+ this.value = next;
662
+ fireSelectionEvents.call(this, {
663
+ value: this.value,
664
+ item,
665
+ selected: !selected,
666
+ });
667
+
668
+ return this;
669
+ }
670
+
671
+ /**
672
+ * @private
673
+ * @param {Object} detail
674
+ * @return {void}
675
+ */
676
+ function fireSelectionEvents(detail) {
677
+ fireEvent(this, "change");
678
+ fireCustomEvent(this, "monster-selected", detail);
679
+ fireCustomEvent(this, "monster-change", detail);
680
+ fireCustomEvent(this, "monster-changed", detail);
681
+ }
682
+
518
683
  /**
519
684
  * @private
520
685
  * @return {string}
@@ -132,6 +132,86 @@ describe("ChoiceCards", function () {
132
132
  cards.shadowRoot.querySelector('[data-choice-value="api"]').click();
133
133
  });
134
134
 
135
+ it("toggles multiple cards when the multiple option is enabled", function () {
136
+ const cards = document.createElement("monster-choice-cards");
137
+ cards.setOption("multiple", true);
138
+ cards.setItems([
139
+ { value: "assets", label: "Project assets" },
140
+ { value: "api", label: "API collection" },
141
+ { value: "manual", label: "Manual" },
142
+ ]);
143
+
144
+ const changes = [];
145
+ cards.addEventListener("monster-change", (event) => {
146
+ changes.push(event.detail);
147
+ });
148
+
149
+ document.getElementById("test").appendChild(cards);
150
+
151
+ expect(
152
+ cards.shadowRoot
153
+ .querySelector('[data-monster-role="control"]')
154
+ .getAttribute("role"),
155
+ ).is.equal("group");
156
+ expect(
157
+ cards.shadowRoot
158
+ .querySelector('[data-choice-value="assets"]')
159
+ .getAttribute("role"),
160
+ ).is.equal("checkbox");
161
+
162
+ cards.shadowRoot.querySelector('[data-choice-value="assets"]').click();
163
+ cards.shadowRoot.querySelector('[data-choice-value="api"]').click();
164
+ cards.shadowRoot.querySelector('[data-choice-value="assets"]').click();
165
+
166
+ expect(cards.value).deep.equal(["api"]);
167
+ expect(
168
+ cards.shadowRoot
169
+ .querySelector('[data-choice-value="assets"]')
170
+ .getAttribute("aria-checked"),
171
+ ).is.equal("false");
172
+ expect(
173
+ cards.shadowRoot
174
+ .querySelector('[data-choice-value="api"]')
175
+ .getAttribute("aria-checked"),
176
+ ).is.equal("true");
177
+ expect(changes.length).is.equal(3);
178
+ expect(changes[0].value).deep.equal(["assets"]);
179
+ expect(changes[0].selected).is.equal(true);
180
+ expect(changes[2].value).deep.equal(["api"]);
181
+ expect(changes[2].selected).is.equal(false);
182
+ });
183
+
184
+ it("reads multiple values from the value attribute", function () {
185
+ document.getElementById("test").innerHTML = `
186
+ <monster-choice-cards
187
+ id="multi-attribute"
188
+ value="assets::api"
189
+ data-monster-option-multiple="true"
190
+ data-monster-options='{
191
+ "items": [
192
+ { "value": "assets", "label": "Assets" },
193
+ { "value": "api", "label": "API" },
194
+ { "value": "manual", "label": "Manual" }
195
+ ]
196
+ }'
197
+ ></monster-choice-cards>
198
+ `;
199
+
200
+ const cards = document.getElementById("multi-attribute");
201
+
202
+ expect(cards.value).deep.equal(["assets", "api"]);
203
+ expect(
204
+ cards.shadowRoot
205
+ .querySelector('[data-choice-value="assets"]')
206
+ .getAttribute("aria-checked"),
207
+ ).is.equal("true");
208
+ expect(
209
+ cards.shadowRoot
210
+ .querySelector('[data-choice-value="manual"]')
211
+ .getAttribute("aria-checked"),
212
+ ).is.equal("false");
213
+ });
214
+
135
215
  it("reads from and writes back to a form datasource", async function () {
136
216
  this.timeout(6000);
137
217
 
@@ -177,6 +257,80 @@ describe("ChoiceCards", function () {
177
257
  await waitForCondition(() => datasource.data?.[0]?.source === "api");
178
258
  });
179
259
 
260
+ it("writes multiple values back to a form datasource", async function () {
261
+ this.timeout(6000);
262
+
263
+ document.getElementById("test").innerHTML = `
264
+ <monster-datasource-dom id="multi-choice-ds">
265
+ <script type="application/json">
266
+ [
267
+ {
268
+ "sources": []
269
+ }
270
+ ]
271
+ </script>
272
+ </monster-datasource-dom>
273
+ <monster-form
274
+ id="multi-choice-form"
275
+ data-monster-option-datasource-selector="#multi-choice-ds"
276
+ data-monster-option-mapping-data=""
277
+ data-monster-option-mapping-index="0"
278
+ >
279
+ <monster-choice-cards
280
+ id="multi-choice-source"
281
+ data-monster-attributes="value path:data.sources"
282
+ data-monster-bind="path:data.sources"
283
+ data-monster-option-multiple="true"
284
+ data-monster-options='{
285
+ "items": [
286
+ { "value": "assets", "label": "Assets" },
287
+ { "value": "api", "label": "API" },
288
+ { "value": "manual", "label": "Manual" }
289
+ ]
290
+ }'
291
+ ></monster-choice-cards>
292
+ </monster-form>
293
+ `;
294
+
295
+ const datasource = document.getElementById("multi-choice-ds");
296
+ const cards = document.getElementById("multi-choice-source");
297
+
298
+ await waitForCondition(() => Array.isArray(datasource.data?.[0]?.sources));
299
+ await new Promise((resolve) => setTimeout(resolve, 500));
300
+
301
+ cards.select("assets");
302
+ await waitForCondition(
303
+ () => JSON.stringify(datasource.data?.[0]?.sources) === '["assets"]',
304
+ );
305
+
306
+ cards.select("api");
307
+
308
+ await waitForCondition(
309
+ () => JSON.stringify(datasource.data?.[0]?.sources) === '["assets","api"]',
310
+ );
311
+ });
312
+
313
+ it("submits multiple values through form data", function () {
314
+ const form = document.createElement("form");
315
+ const cards = document.createElement("monster-choice-cards");
316
+ cards.setAttribute("name", "sources");
317
+ cards.setOption("multiple", true);
318
+ cards.setItems([
319
+ { value: "assets", label: "Project assets" },
320
+ { value: "api", label: "API collection" },
321
+ { value: "manual", label: "Manual" },
322
+ ]);
323
+
324
+ form.appendChild(cards);
325
+ document.getElementById("test").appendChild(form);
326
+
327
+ cards.select("assets");
328
+ cards.select("api");
329
+
330
+ const formData = new window.FormData(form);
331
+ expect(formData.getAll("sources")).deep.equal(["assets::api"]);
332
+ });
333
+
180
334
  it("does not select disabled items", function () {
181
335
  const cards = document.createElement("monster-choice-cards");
182
336
  cards.setItems([
@@ -189,4 +343,24 @@ describe("ChoiceCards", function () {
189
343
 
190
344
  expect(cards.value).is.equal(null);
191
345
  });
346
+
347
+ it("does not toggle disabled items in multiple mode", function () {
348
+ const cards = document.createElement("monster-choice-cards");
349
+ cards.setOption("multiple", true);
350
+ cards.setItems([
351
+ { value: "assets", label: "Project assets" },
352
+ { value: "api", label: "API collection", disabled: true },
353
+ ]);
354
+
355
+ document.getElementById("test").appendChild(cards);
356
+ cards.select("assets");
357
+ cards.select("api");
358
+
359
+ expect(cards.value).deep.equal(["assets"]);
360
+ expect(
361
+ cards.shadowRoot
362
+ .querySelector('[data-choice-value="api"]')
363
+ .getAttribute("aria-checked"),
364
+ ).is.equal("false");
365
+ });
192
366
  });