stimeo-ui 0.4.0 → 0.6.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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +184 -0
  3. data/dist/controllers/aspect_ratio_controller.js +19 -11
  4. data/dist/controllers/avatar_controller.js +195 -40
  5. data/dist/controllers/breadcrumb_controller.js +5 -1
  6. data/dist/controllers/carousel_controller.js +90 -10
  7. data/dist/controllers/checkbox_controller.js +136 -25
  8. data/dist/controllers/clipboard_controller.js +8 -3
  9. data/dist/controllers/collapsible_controller.js +4 -1
  10. data/dist/controllers/color_picker_controller.js +41 -11
  11. data/dist/controllers/context_menu_controller.js +2 -2
  12. data/dist/controllers/countdown_controller.js +5 -1
  13. data/dist/controllers/date_range_picker_controller.js +162 -31
  14. data/dist/controllers/direct_upload_controller.js +3 -3
  15. data/dist/controllers/empty_state_controller.js +107 -16
  16. data/dist/controllers/file_dropzone_controller.js +26 -3
  17. data/dist/controllers/flash_controller.js +161 -21
  18. data/dist/controllers/form_validation_controller.js +8 -2
  19. data/dist/controllers/frame_loading_controller.js +94 -22
  20. data/dist/controllers/highlight_controller.js +38 -1
  21. data/dist/controllers/idle_controller.js +39 -6
  22. data/dist/controllers/local_time_controller.js +2 -0
  23. data/dist/controllers/masonry_controller.js +1 -1
  24. data/dist/controllers/menubar_controller.js +5 -3
  25. data/dist/controllers/meter_controller.js +3 -1
  26. data/dist/controllers/multi_select_controller.js +460 -151
  27. data/dist/controllers/network_status_controller.js +1 -3
  28. data/dist/controllers/number_input_controller.js +455 -64
  29. data/dist/controllers/overflow_menu_controller.js +5 -1
  30. data/dist/controllers/pagination_controller.js +38 -1
  31. data/dist/controllers/password_strength_controller.js +21 -3
  32. data/dist/controllers/persist_controller.js +24 -5
  33. data/dist/controllers/pointer_drag_controller.js +10 -0
  34. data/dist/controllers/portal_controller.js +10 -0
  35. data/dist/controllers/progress_controller.js +7 -3
  36. data/dist/controllers/radio_group_controller.js +540 -56
  37. data/dist/controllers/range_slider_controller.js +385 -94
  38. data/dist/controllers/rating_controller.js +274 -89
  39. data/dist/controllers/relative_time_controller.js +2 -0
  40. data/dist/controllers/resizable_controller.js +33 -0
  41. data/dist/controllers/roving_controller.js +60 -5
  42. data/dist/controllers/scroll_area_controller.js +155 -23
  43. data/dist/controllers/scroll_visibility_controller.js +33 -0
  44. data/dist/controllers/separator_controller.js +13 -17
  45. data/dist/controllers/skeleton_controller.js +71 -3
  46. data/dist/controllers/slider_controller.js +325 -48
  47. data/dist/controllers/spinner_controller.js +18 -3
  48. data/dist/controllers/step_indicator_controller.js +3 -1
  49. data/dist/controllers/stepper_controller.js +2 -0
  50. data/dist/controllers/switch_controller.js +162 -18
  51. data/dist/controllers/tags_input_controller.js +356 -120
  52. data/dist/controllers/textarea_autosize_controller.js +1 -1
  53. data/dist/controllers/time_picker_controller.js +296 -104
  54. data/dist/controllers/toggle_group_controller.js +378 -55
  55. data/dist/controllers/toolbar_controller.js +5 -3
  56. data/dist/controllers/tree_view_controller.js +24 -4
  57. data/dist/index.js +3811 -1048
  58. data/lib/stimeo/ui/version.rb +1 -1
  59. metadata +2 -2
@@ -2,6 +2,23 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/multi_select_controller.ts
4
4
 
5
+ // src/utils/announce.ts
6
+ function announce(message, options = {}) {
7
+ const text = message.trim();
8
+ if (text.length === 0) return;
9
+ window.dispatchEvent(
10
+ new CustomEvent("stimeo--announcer:announce", {
11
+ detail: { message: text, assertive: options.assertive === true }
12
+ })
13
+ );
14
+ }
15
+ function fillTemplate(template, values) {
16
+ return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
17
+ const replacement = values[name];
18
+ return replacement === void 0 ? match : String(replacement);
19
+ });
20
+ }
21
+
5
22
  // src/utils/aria_ids.ts
6
23
  var counter = 0;
7
24
  function uniqueId(prefix = "stimeo") {
@@ -35,6 +52,177 @@ function isReservedArrowChord(event, allow = []) {
35
52
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
36
53
  }
37
54
 
55
+ // src/utils/roving_tabindex.ts
56
+ var RovingTabindex = class {
57
+ /** Returns the current ordered item elements; called on every operation. */
58
+ #getItems;
59
+ /**
60
+ * @param getItems - Returns the current ordered item elements. Called on every
61
+ * operation so the live target list is always used.
62
+ */
63
+ constructor(getItems) {
64
+ this.#getItems = getItems;
65
+ }
66
+ /** Index of the currently tabbable item (`tabindex="0"`), or `-1` if none. */
67
+ get activeIndex() {
68
+ return this.#getItems().findIndex((item) => item.tabIndex === 0);
69
+ }
70
+ /**
71
+ * Makes exactly the item at `index` tabbable (`tabindex="0"`) and removes every
72
+ * other item from the Tab sequence (`tabindex="-1"`). An out-of-range `index`
73
+ * (e.g. `-1`) leaves all items at `-1`, which a controller can use to express
74
+ * "nothing is currently tabbable".
75
+ *
76
+ * @param index - Position of the item to make tabbable.
77
+ * @param options - Pass `{ focus: true }` to also move DOM focus to that item,
78
+ * and `items` to reuse an event-scoped collection snapshot.
79
+ */
80
+ setActive(index, options = {}) {
81
+ const { focus = false } = options;
82
+ const items = options.items ?? this.#getItems();
83
+ items.forEach((item, i) => {
84
+ item.tabIndex = i === index ? 0 : -1;
85
+ });
86
+ if (focus) items[index]?.focus();
87
+ }
88
+ };
89
+
90
+ // src/utils/chip_row.ts
91
+ var ChipRow = class {
92
+ #directionElement;
93
+ #getItems;
94
+ #getButton;
95
+ #onRemove;
96
+ #focusAfterEnd;
97
+ #roving = new RovingTabindex(() => this.buttons);
98
+ #container = null;
99
+ constructor(options) {
100
+ this.#directionElement = options.directionElement;
101
+ this.#getItems = options.getItems;
102
+ this.#getButton = options.getButton ?? ((item) => item.querySelector("button"));
103
+ this.#onRemove = options.onRemove;
104
+ this.#focusAfterEnd = options.focusAfterEnd;
105
+ }
106
+ /**
107
+ * Binds delegation to `container`; any row currently bound is released first.
108
+ * Idempotent for the same node, so target callbacks may call it freely.
109
+ */
110
+ connect(container) {
111
+ if (this.#container === container) return;
112
+ this.disconnect();
113
+ this.#container = container;
114
+ container.addEventListener("click", this.#onClick);
115
+ container.addEventListener("keydown", this.#onKeydown);
116
+ }
117
+ /**
118
+ * Releases the current row. When `container` is supplied, a stale disconnect
119
+ * callback cannot tear listeners off a newer replacement target.
120
+ */
121
+ disconnect(container) {
122
+ const current = this.#container;
123
+ if (!current || container !== void 0 && current !== container) return;
124
+ current.removeEventListener("click", this.#onClick);
125
+ current.removeEventListener("keydown", this.#onKeydown);
126
+ this.#container = null;
127
+ }
128
+ /** Current remove buttons, at most one consumer-resolved button per chip item. */
129
+ get buttons() {
130
+ return this.#entries.map(({ button }) => button);
131
+ }
132
+ /** Number of currently navigable remove buttons. */
133
+ get length() {
134
+ return this.buttons.length;
135
+ }
136
+ /** Declared-item index of the last navigable chip, or `-1` for an empty row. */
137
+ get lastIndex() {
138
+ return this.#entries.at(-1)?.itemIndex ?? -1;
139
+ }
140
+ /** Keeps exactly one button tabbable, preferring the first authored Tab stop. */
141
+ ensureTabStop() {
142
+ const buttons = this.#entries.map(({ button }) => button);
143
+ const active = buttons.findIndex((button) => button.tabIndex === 0);
144
+ const count = buttons.filter((button) => button.tabIndex === 0).length;
145
+ if (buttons.length > 0 && count !== 1) {
146
+ this.#roving.setActive(active === -1 ? 0 : active, { items: buttons });
147
+ }
148
+ }
149
+ /** Focuses the last chip, returning false for an empty row. */
150
+ focusLast() {
151
+ const buttons = this.#entries.map(({ button }) => button);
152
+ const last = buttons.length - 1;
153
+ if (last < 0) return false;
154
+ this.#roving.setActive(last, { focus: true, items: buttons });
155
+ return true;
156
+ }
157
+ /**
158
+ * Focuses the chip that followed a removed index, or the new last chip.
159
+ * Returns false when removal emptied the row so the consumer can rescue focus.
160
+ */
161
+ focusAfterRemoval(index) {
162
+ const entries = this.#entries;
163
+ if (entries.length === 0) return false;
164
+ const following = entries.findIndex((entry) => entry.itemIndex >= index);
165
+ this.#roving.setActive(following === -1 ? entries.length - 1 : following, {
166
+ focus: true,
167
+ items: entries.map(({ button }) => button)
168
+ });
169
+ return true;
170
+ }
171
+ /** Current remove-button entries, retaining each button's declared item index. */
172
+ get #entries() {
173
+ return this.#getItems().flatMap((item, itemIndex) => {
174
+ const button = this.#getButton(item);
175
+ return button ? [{ button, itemIndex }] : [];
176
+ });
177
+ }
178
+ /** Resolves a delegated event to the remove button owned by a declared chip. */
179
+ #entry(event) {
180
+ const target = event.target;
181
+ if (!(target instanceof Element)) return null;
182
+ const button = target.closest("button");
183
+ const entries = this.#entries;
184
+ const buttonIndex = entries.findIndex((entry2) => entry2.button === button);
185
+ if (buttonIndex === -1) return null;
186
+ const entry = entries[buttonIndex];
187
+ return { ...entry, buttonIndex, buttons: entries.map(({ button: button2 }) => button2) };
188
+ }
189
+ /** Delegates removal clicks without waiting for Stimulus to wire a new chip. */
190
+ #onClick = (event) => {
191
+ const entry = this.#entry(event);
192
+ if (!entry) return;
193
+ this.#onRemove(entry.itemIndex);
194
+ };
195
+ /** Applies the shared logical-arrow and removal policy within the chip row. */
196
+ #onKeydown = (event) => {
197
+ if (event.defaultPrevented || isReservedArrowChord(event)) return;
198
+ const entry = this.#entry(event);
199
+ if (!entry) return;
200
+ const buttons = entry.buttons;
201
+ const index = entry.buttonIndex;
202
+ switch (logicalArrowKey(event.key, this.#directionElement)) {
203
+ case "ArrowLeft":
204
+ if (index > 0) {
205
+ event.preventDefault();
206
+ this.#roving.setActive(index - 1, { focus: true, items: buttons });
207
+ }
208
+ break;
209
+ case "ArrowRight":
210
+ event.preventDefault();
211
+ if (index < buttons.length - 1) {
212
+ this.#roving.setActive(index + 1, { focus: true, items: buttons });
213
+ } else {
214
+ this.#focusAfterEnd();
215
+ }
216
+ break;
217
+ case "Delete":
218
+ case "Backspace":
219
+ event.preventDefault();
220
+ this.#onRemove(entry.itemIndex);
221
+ break;
222
+ }
223
+ };
224
+ };
225
+
38
226
  // src/utils/composition_tracker.ts
39
227
  var CompositionTracker = class {
40
228
  #observedTargets = /* @__PURE__ */ new Set();
@@ -127,36 +315,32 @@ function scrollOptionIntoView(list, option) {
127
315
  }
128
316
  }
129
317
 
130
- // src/utils/roving_tabindex.ts
131
- var RovingTabindex = class {
132
- /** Returns the current ordered item elements; called on every operation. */
133
- #getItems;
134
- /**
135
- * @param getItems - Returns the current ordered item elements. Called on every
136
- * operation so the live target list is always used.
137
- */
138
- constructor(getItems) {
139
- this.#getItems = getItems;
318
+ // src/utils/before_cache_reset.ts
319
+ var BeforeCacheReset = class _BeforeCacheReset {
320
+ /** Every subscribed instance, iterated by the one shared document listener. */
321
+ static #subscribers = /* @__PURE__ */ new Set();
322
+ /** The shared listener; installed while at least one instance is subscribed. */
323
+ static #onBeforeCache = () => {
324
+ for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
325
+ };
326
+ #rewind;
327
+ /** @param rewind - the pass that returns this controller's state to its initial form. */
328
+ constructor(rewind) {
329
+ this.#rewind = rewind;
140
330
  }
141
- /** Index of the currently tabbable item (`tabindex="0"`), or `-1` if none. */
142
- get activeIndex() {
143
- return this.#getItems().findIndex((item) => item.tabIndex === 0);
331
+ /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
332
+ activate() {
333
+ const first = _BeforeCacheReset.#subscribers.size === 0;
334
+ _BeforeCacheReset.#subscribers.add(this);
335
+ if (first) {
336
+ document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
337
+ }
144
338
  }
145
- /**
146
- * Makes exactly the item at `index` tabbable (`tabindex="0"`) and removes every
147
- * other item from the Tab sequence (`tabindex="-1"`). An out-of-range `index`
148
- * (e.g. `-1`) leaves all items at `-1`, which a controller can use to express
149
- * "nothing is currently tabbable".
150
- *
151
- * @param index - Position of the item to make tabbable.
152
- * @param options - Pass `{ focus: true }` to also move DOM focus to that item.
153
- */
154
- setActive(index, { focus = false } = {}) {
155
- const items = this.#getItems();
156
- items.forEach((item, i) => {
157
- item.tabIndex = i === index ? 0 : -1;
158
- });
159
- if (focus) items[index]?.focus();
339
+ /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
340
+ deactivate() {
341
+ _BeforeCacheReset.#subscribers.delete(this);
342
+ if (_BeforeCacheReset.#subscribers.size > 0) return;
343
+ document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
160
344
  }
161
345
  };
162
346
 
@@ -164,6 +348,8 @@ var RovingTabindex = class {
164
348
  var TabindexLoan = class {
165
349
  #value;
166
350
  #lent = /* @__PURE__ */ new Set();
351
+ /** Returns live loans before Turbo can copy them into its page snapshot. */
352
+ #beforeCache = new BeforeCacheReset(() => this.returnAll());
167
353
  /**
168
354
  * @param value - the `tabindex` to lend. `"-1"` (the default) is
169
355
  * programmatically focusable but not a Tab stop; `"0"` is a real Tab stop,
@@ -177,6 +363,7 @@ var TabindexLoan = class {
177
363
  if (element.hasAttribute("tabindex")) return;
178
364
  element.setAttribute("tabindex", this.#value);
179
365
  this.#lent.add(element);
366
+ this.#beforeCache.activate();
180
367
  }
181
368
  /** Takes back every loan whose value is still the one that was lent. */
182
369
  returnAll() {
@@ -184,6 +371,7 @@ var TabindexLoan = class {
184
371
  if (element.getAttribute("tabindex") === this.#value) element.removeAttribute("tabindex");
185
372
  }
186
373
  this.#lent.clear();
374
+ this.#beforeCache.deactivate();
187
375
  }
188
376
  };
189
377
 
@@ -196,21 +384,26 @@ var MultiSelectController = class extends Controller {
196
384
  "tags",
197
385
  "tag",
198
386
  "tagTemplate",
199
- "status",
387
+ "label",
388
+ "remove",
200
389
  "fields"
201
390
  ];
202
391
  static values = {
203
392
  max: { type: Number, default: 0 },
204
393
  name: { type: String, default: "options[]" },
205
- form: { type: String, default: "" }
394
+ form: { type: String, default: "" },
395
+ announceText: { type: String, default: "" },
396
+ announceRemovedText: { type: String, default: "" }
206
397
  };
207
398
  static actions = ["close", "filter", "onKeydown", "open", "toggleOption"];
208
- static events = ["change", "filter"];
399
+ static events = ["change", "filter", "reconcile"];
209
400
  /** Stable id of the active option; the current target is resolved from the DOM. */
210
401
  #activeOptionId = null;
211
402
  /** Whether the root borrowed a tab stop to catch focus, so teardown can undo it. */
212
403
  #tabindex = new TabindexLoan();
213
- /** Prevents initial/teardown target callbacks from mutating authored DOM. */
404
+ /** Last reconciled selection, used to distinguish state changes from derived-DOM repair. */
405
+ #selectionValues = [];
406
+ /** Whether initial normalization finished, so a fields callback cannot mirror stale state. */
214
407
  #connected = false;
215
408
  /** Collapses one batch of target callbacks into a single final-DOM reconciliation. */
216
409
  #reconcile = new MicrotaskCoalescer(() => this.#reconcileOptions());
@@ -229,17 +422,26 @@ var MultiSelectController = class extends Controller {
229
422
  this.filter();
230
423
  }
231
424
  });
232
- #roving = new RovingTabindex(() => this.#removeButtons);
425
+ /** Whether this connection already reported its unusable chip template. */
426
+ #warnedTemplate = false;
427
+ /** Shared delegated interaction for the replaceable row of removable chips. */
428
+ #chipRow = new ChipRow({
429
+ directionElement: this.element,
430
+ getItems: () => this.tagTargets,
431
+ getButton: (tag) => tag.querySelector('button[data-stimeo--multi-select-target~="remove"]'),
432
+ onRemove: (index) => this.#removeTagAt(index),
433
+ focusAfterEnd: () => this.#focusInput()
434
+ });
233
435
  /** Starts closed, syncs chips for any pre-selected options, and listens out. */
234
436
  connect() {
235
- if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
236
- this.#normalizeSelection();
437
+ this.#warnedTemplate = false;
438
+ this.#normalizeSelection([]);
237
439
  this.close();
238
440
  if (this.hasTagsTarget) {
239
- this.tagsTarget.addEventListener("keydown", this.#onTagKeydown);
240
- this.tagsTarget.addEventListener("click", this.#onTagClick);
441
+ this.#chipRow.connect(this.tagsTarget);
241
442
  this.#rebuildTags();
242
443
  }
444
+ this.#selectionValues = this.#values;
243
445
  this.#syncFields();
244
446
  document.addEventListener("click", this.#onOutsideClick, true);
245
447
  this.#connected = true;
@@ -251,10 +453,16 @@ var MultiSelectController = class extends Controller {
251
453
  * cleared before deriving afresh to avoid duplicates.
252
454
  */
253
455
  #rebuildTags() {
254
- if (!this.hasTagsTarget) return;
456
+ if (!this.hasTagsTarget || !this.hasTagTemplateTarget) return;
457
+ const fragments = [];
458
+ for (const option of this.#selectedOptions) {
459
+ const fragment = this.#buildTag(option);
460
+ if (!fragment) return;
461
+ fragments.push(fragment);
462
+ }
255
463
  for (const tag of this.tagTargets) tag.remove();
256
- for (const option of this.#selectedOptions) this.#appendTag(option);
257
- if (this.#removeButtons.length > 0) this.#roving.setActive(0);
464
+ this.tagsTarget.append(...fragments);
465
+ this.#chipRow.ensureTabStop();
258
466
  }
259
467
  /** Tears down document and chip listeners on disconnect (Turbo included). */
260
468
  disconnect() {
@@ -262,10 +470,7 @@ var MultiSelectController = class extends Controller {
262
470
  this.#reconcile.cancel();
263
471
  this.#composition.disconnect();
264
472
  this.#ignorePostCompositionInput = false;
265
- if (this.hasTagsTarget) {
266
- this.tagsTarget.removeEventListener("keydown", this.#onTagKeydown);
267
- this.tagsTarget.removeEventListener("click", this.#onTagClick);
268
- }
473
+ this.#chipRow.disconnect();
269
474
  document.removeEventListener("click", this.#onOutsideClick, true);
270
475
  this.#releaseTabindex();
271
476
  }
@@ -275,10 +480,35 @@ var MultiSelectController = class extends Controller {
275
480
  }
276
481
  /** Cleans a removed target and reconciles active state against the surviving DOM. */
277
482
  optionTargetDisconnected(option) {
278
- if (!this.#connected) return;
279
483
  option.removeAttribute("data-active");
280
484
  this.#scheduleOptionReconcile();
281
485
  }
486
+ /** Rebinds delegated chip interaction when Turbo replaces the tags container. */
487
+ tagsTargetConnected(tags) {
488
+ this.#chipRow.connect(tags);
489
+ this.#scheduleOptionReconcile();
490
+ }
491
+ /** Releases only the row that actually disconnected, never a newer replacement. */
492
+ tagsTargetDisconnected(tags) {
493
+ this.#chipRow.disconnect(tags);
494
+ this.#scheduleOptionReconcile();
495
+ }
496
+ /** Seeds a fields target inserted after connect from the current selection. */
497
+ fieldsTargetConnected() {
498
+ if (this.#connected) this.#syncFields();
499
+ }
500
+ /** Reconciles a runtime max change, including dropping any newly invalid overflow. */
501
+ maxValueChanged() {
502
+ this.#scheduleOptionReconcile();
503
+ }
504
+ /** Rebuilds submitted fields when their public name changes at runtime. */
505
+ nameValueChanged() {
506
+ this.#scheduleOptionReconcile();
507
+ }
508
+ /** Rebuilds submitted fields when their associated form changes at runtime. */
509
+ formValueChanged() {
510
+ this.#scheduleOptionReconcile();
511
+ }
282
512
  /** Schedules one reconciliation after all callbacks in the mutation batch. */
283
513
  #scheduleOptionReconcile() {
284
514
  this.#reconcile.schedule();
@@ -301,7 +531,17 @@ var MultiSelectController = class extends Controller {
301
531
  const next = this.#isClosed ? null : active && !active.hidden ? active : visible[0] ?? null;
302
532
  this.#setActive(next);
303
533
  this.#reflectEmpty();
304
- this.#normalizeSelection();
534
+ const previous = this.#selectionValues;
535
+ const previousLabels = new Map(
536
+ this.tagTargets.map((tag) => [
537
+ tag.dataset.value ?? "",
538
+ (tag.querySelector('[data-stimeo--multi-select-target~="label"]')?.textContent ?? "").trim()
539
+ ])
540
+ );
541
+ const priority = [
542
+ .../* @__PURE__ */ new Set([...this.tagTargets.map((tag) => tag.dataset.value ?? ""), ...previous])
543
+ ];
544
+ this.#normalizeSelection(priority);
305
545
  const selected = this.#selectedOptions;
306
546
  const nextValues = selected.map((option) => this.#optionValue(option)).sort();
307
547
  const tagValues = this.tagTargets.map((tag) => tag.dataset.value ?? "").sort();
@@ -309,19 +549,52 @@ var MultiSelectController = class extends Controller {
309
549
  if (unchanged) this.#refreshTagLabels(selected);
310
550
  else this.#rebuildTags();
311
551
  this.#syncFields();
552
+ const values = this.#values;
553
+ const changed = !this.#sameSelection(previous, values);
554
+ this.#selectionValues = values;
555
+ if (!changed) return;
556
+ const options = new Map(selected.map((option) => [this.#optionValue(option), option]));
557
+ for (const value of previous) {
558
+ if (!values.includes(value)) {
559
+ this.#announceTransition(false, previousLabels.get(value) || value, value, values.length);
560
+ }
561
+ }
562
+ for (const value of values) {
563
+ if (previous.includes(value)) continue;
564
+ const option = options.get(value);
565
+ this.#announceTransition(true, this.#optionLabel(option), value, values.length);
566
+ }
567
+ this.dispatch("reconcile", { detail: { values } });
312
568
  }
313
569
  /**
314
- * Gives every option an explicit `aria-selected`, without changing which ones
315
- * the author chose. An absent value means "not selectable" in ARIA, so a
316
- * forgotten attribute hides a selectable option. Several `true` is the normal
317
- * case here — the list is `aria-multiselectable` — so nothing is dropped.
570
+ * Gives every option an explicit `aria-selected` and enforces the current cap.
571
+ * Priority preserves existing chip order at runtime; a fresh connection passes
572
+ * no priority, so deterministic option DOM order chooses the initial survivors.
318
573
  */
319
- #normalizeSelection() {
320
- for (const option of this.optionTargets) {
321
- if (option.getAttribute("aria-selected") !== "true") {
322
- option.setAttribute("aria-selected", "false");
574
+ #normalizeSelection(priority) {
575
+ const selected = this.optionTargets.filter(
576
+ (option) => option.getAttribute("aria-selected") === "true"
577
+ );
578
+ const limit = this.#selectionLimit;
579
+ const kept = /* @__PURE__ */ new Set();
580
+ if (limit === 0) {
581
+ for (const option of selected) kept.add(option);
582
+ } else {
583
+ for (const value of priority) {
584
+ if (kept.size >= limit) break;
585
+ const option = selected.find(
586
+ (candidate) => !kept.has(candidate) && this.#optionValue(candidate) === value
587
+ );
588
+ if (option) kept.add(option);
589
+ }
590
+ for (const option of selected) {
591
+ if (kept.size >= limit) break;
592
+ kept.add(option);
323
593
  }
324
594
  }
595
+ for (const option of this.optionTargets) {
596
+ option.setAttribute("aria-selected", String(kept.has(option)));
597
+ }
325
598
  }
326
599
  /**
327
600
  * Tracks an input added initially or after connect, and makes it describe the
@@ -344,6 +617,7 @@ var MultiSelectController = class extends Controller {
344
617
  }
345
618
  /** Filters confirmed input text, opens, and re-seeds the active option. */
346
619
  filter(event) {
620
+ if (!this.hasInputTarget) return;
347
621
  if (event && this.#ignorePostCompositionInput) {
348
622
  this.#ignorePostCompositionInput = false;
349
623
  return;
@@ -368,10 +642,14 @@ var MultiSelectController = class extends Controller {
368
642
  * opening is skipped entirely, where {@link close} still closes.
369
643
  */
370
644
  open() {
371
- if (!this.hasListTarget || !this.hasInputTarget) return;
645
+ if (!this.hasListTarget || !this.hasInputTarget) {
646
+ this.#reflectEmpty();
647
+ return;
648
+ }
372
649
  this.listTarget.hidden = false;
373
650
  this.inputTarget.setAttribute("aria-expanded", "true");
374
651
  if (!this.#activeOption) this.#setActive(this.#visibleOptions[0] ?? null);
652
+ this.#reflectEmpty();
375
653
  }
376
654
  /**
377
655
  * Closes the list and clears the active option.
@@ -385,16 +663,16 @@ var MultiSelectController = class extends Controller {
385
663
  * active option, so only the `aria-expanded` write is guarded.
386
664
  */
387
665
  close() {
388
- if (!this.hasListTarget) return;
389
- this.listTarget.hidden = true;
666
+ if (this.hasListTarget) this.listTarget.hidden = true;
390
667
  this.#setActive(null);
391
- if (!this.hasInputTarget) return;
392
- this.inputTarget.setAttribute("aria-expanded", "false");
668
+ if (this.hasInputTarget) this.inputTarget.setAttribute("aria-expanded", "false");
669
+ this.#reflectEmpty();
393
670
  }
394
671
  /** Routes input keyboard interaction per the multi-select combobox model. */
395
672
  onKeydown(event) {
396
673
  if (event.defaultPrevented) return;
397
674
  if (isReservedArrowChord(event)) return;
675
+ if (!this.hasInputTarget) return;
398
676
  if (this.#composition.isComposing(event)) return;
399
677
  this.#reconcileActiveForInteraction();
400
678
  switch (logicalArrowKey(event.key, this.element)) {
@@ -437,17 +715,17 @@ var MultiSelectController = class extends Controller {
437
715
  break;
438
716
  case "Backspace":
439
717
  if (this.inputTarget.value === "") {
440
- const buttons = this.#removeButtons;
441
- if (buttons.length > 0) {
718
+ const last = this.#chipRow.lastIndex;
719
+ if (last >= 0) {
442
720
  event.preventDefault();
443
- this.#removeTagAt(buttons.length - 1);
721
+ this.#removeTagAt(last, "input");
444
722
  }
445
723
  }
446
724
  break;
447
725
  case "ArrowLeft":
448
- if (this.inputTarget.value === "" && this.#removeButtons.length > 0) {
726
+ if (this.inputTarget.value === "" && this.#chipRow.length > 0) {
449
727
  event.preventDefault();
450
- this.#roving.setActive(this.#removeButtons.length - 1, { focus: true });
728
+ this.#chipRow.focusLast();
451
729
  }
452
730
  break;
453
731
  case "Tab":
@@ -485,8 +763,8 @@ var MultiSelectController = class extends Controller {
485
763
  * Unlike the other {@link #focusInput} callers, the element that held focus has
486
764
  * just left the DOM, so "leave it alone" is not an option — the browser already
487
765
  * dropped it to `<body>`. The root borrows a `tabindex="-1"` just-in-time (not a
488
- * Tab stop, handed back on teardown). Focus that landed on a real element is
489
- * left alone, so a chip removed out of band never steals it.
766
+ * Tab stop, handed back before Turbo caches or on teardown). Focus that landed
767
+ * on a real element is left alone, so a chip removed out of band never steals it.
490
768
  */
491
769
  #focusAfterLastTag() {
492
770
  if (this.hasInputTarget) {
@@ -509,43 +787,40 @@ var MultiSelectController = class extends Controller {
509
787
  #releaseTabindex() {
510
788
  this.#tabindex.returnAll();
511
789
  }
512
- /**
513
- * Removes the chip whose remove button was clicked, deselecting its option.
514
- * Delegated on the tags container (like `#onTagKeydown`) rather than bound
515
- * per chip via `data-action`, so it works the instant a chip is appended without
516
- * waiting on Stimulus to wire a freshly created element.
517
- */
518
- #onTagClick = (event) => {
519
- const button = event.target.closest("button");
520
- if (!button || !this.tagsTarget.contains(button)) return;
521
- const index = this.#removeButtons.indexOf(button);
522
- if (index !== -1) this.#removeTagAt(index);
523
- };
524
790
  /** Moves the active option by `delta` among visible options, wrapping. */
525
791
  #moveActive(delta) {
526
792
  const visible = this.#visibleOptions;
527
- if (visible.length === 0) return;
528
- const current = this.#activeOption ? visible.indexOf(this.#activeOption) : -1;
793
+ const active = this.#activeOption;
794
+ const current = visible.indexOf(active);
529
795
  const candidate = current === -1 ? delta > 0 ? 0 : visible.length - 1 : current + delta;
530
796
  const next = (candidate + visible.length) % visible.length;
531
797
  this.#setActive(visible[next] ?? null);
532
798
  }
533
- /** Selects/deselects `option`, honoring `max`, and syncs chip + live region. */
799
+ /** Selects/deselects `option`, honoring `max`, and syncs chip + announcement. */
534
800
  #toggleSelection(option) {
535
801
  const selected = option.getAttribute("aria-selected") === "true";
536
- if (!selected && this.maxValue > 0 && this.#selectedOptions.length >= this.maxValue) {
802
+ const limit = this.#selectionLimit;
803
+ if (!selected && limit > 0 && this.#selectedOptions.length >= limit) {
537
804
  return;
538
805
  }
539
- option.setAttribute("aria-selected", String(!selected));
540
806
  if (selected) {
807
+ option.setAttribute("aria-selected", "false");
541
808
  this.#removeTagFor(option);
542
809
  } else {
543
- this.#appendTag(option);
810
+ if (this.hasTagTemplateTarget && !this.#appendTag(option)) return;
811
+ option.setAttribute("aria-selected", "true");
544
812
  }
545
- this.#announce(this.#optionLabel(option));
546
813
  this.#refreshRoving();
547
814
  this.#syncFields();
548
- this.dispatch("change", { detail: { values: this.#values } });
815
+ const values = this.#values;
816
+ this.#selectionValues = values;
817
+ this.#announceTransition(
818
+ !selected,
819
+ this.#optionLabel(option),
820
+ this.#optionValue(option),
821
+ values.length
822
+ );
823
+ this.dispatch("change", { detail: { values } });
549
824
  }
550
825
  /**
551
826
  * Re-reads each chip's label from its option, in place.
@@ -560,31 +835,81 @@ var MultiSelectController = class extends Controller {
560
835
  const options = new Map(selected.map((option) => [this.#optionValue(option), option]));
561
836
  for (const tag of this.tagTargets) {
562
837
  const option = options.get(tag.dataset.value ?? "");
563
- if (!option) continue;
564
838
  const text = this.#optionLabel(option);
565
- const label = tag.querySelector('[data-multi-select-slot="label"]');
566
- if (label && label.textContent !== text) label.textContent = text;
567
- const button = tag.querySelector("button");
568
- const name = `Remove ${text}`;
569
- if (button && button.getAttribute("aria-label") !== name) {
570
- button.setAttribute("aria-label", name);
571
- }
839
+ const label = tag.querySelector('[data-stimeo--multi-select-target~="label"]');
840
+ const button = tag.querySelector(
841
+ 'button[data-stimeo--multi-select-target~="remove"]'
842
+ );
843
+ const name = this.#removeName(text, this.#optionValue(option));
844
+ if (!label || !button || !name) continue;
845
+ if (label.textContent !== text) label.textContent = text;
846
+ if (button.getAttribute("aria-label") !== name) button.setAttribute("aria-label", name);
572
847
  }
573
848
  }
574
849
  /** Builds one chip from the template for `option`. */
575
850
  #appendTag(option) {
576
- if (!this.hasTagTemplateTarget || !this.hasTagsTarget) return;
851
+ if (!this.hasTagsTarget) {
852
+ this.#warnTemplate('a "tags" target to append the chip to');
853
+ return false;
854
+ }
855
+ const fragment = this.#buildTag(option);
856
+ if (!fragment) return false;
857
+ this.tagsTarget.appendChild(fragment);
858
+ return true;
859
+ }
860
+ /**
861
+ * Builds one fully named chip without mutating the live tag row, or `null`
862
+ * when the authored template cannot produce one. Both callers establish the
863
+ * template first: a field authored without `tagTemplate` renders no chips at
864
+ * all — a supported configuration — and never reaches here.
865
+ */
866
+ #buildTag(option) {
577
867
  const fragment = this.tagTemplateTarget.content.cloneNode(true);
578
- const tag = fragment.querySelector('[data-stimeo--multi-select-target="tag"]');
579
- const label = fragment.querySelector('[data-multi-select-slot="label"]');
580
- const button = fragment.querySelector("button");
581
- if (!tag || !button) return;
868
+ const tag = fragment.querySelector('[data-stimeo--multi-select-target~="tag"]');
869
+ const label = fragment.querySelector(
870
+ '[data-stimeo--multi-select-target~="label"]'
871
+ );
872
+ const button = fragment.querySelector(
873
+ 'button[data-stimeo--multi-select-target~="remove"]'
874
+ );
582
875
  const text = this.#optionLabel(option);
583
- tag.dataset.value = this.#optionValue(option);
584
- if (label) label.textContent = text;
585
- button.setAttribute("aria-label", `Remove ${text}`);
876
+ const value = this.#optionValue(option);
877
+ const removeName = button?.getAttribute("aria-label")?.trim() ?? "";
878
+ if (!tag) return this.#warnTemplate('a "tag" target');
879
+ if (!label) return this.#warnTemplate('a "label" target');
880
+ if (!button) return this.#warnTemplate('a "remove" target <button>');
881
+ if (removeName === "") {
882
+ return this.#warnTemplate('a non-empty aria-label on its "remove" target');
883
+ }
884
+ tag.dataset.value = value;
885
+ label.textContent = text;
886
+ button.setAttribute("aria-label", fillTemplate(removeName, { label: text, value }));
586
887
  button.tabIndex = -1;
587
- this.tagsTarget.appendChild(fragment);
888
+ return fragment;
889
+ }
890
+ /**
891
+ * Reports an unusable chip template to the author, once per connection.
892
+ *
893
+ * The selection itself stays untouched — no `aria-selected`, chip, hidden
894
+ * field, announcement, or event moves. Without this line the only symptom is
895
+ * a listbox whose options refuse to select, and the two causes the Inspector
896
+ * cannot see statically (a name that renders empty from a missing
897
+ * translation, a server-rendered template) would have no diagnostic anywhere.
898
+ */
899
+ #warnTemplate(missing) {
900
+ if (!this.#warnedTemplate) {
901
+ this.#warnedTemplate = true;
902
+ console.warn(
903
+ `Stimeo UI: "${this.identifier}" changed no selection because its chip template lacks ${missing}.`
904
+ );
905
+ }
906
+ return null;
907
+ }
908
+ /** Expands the current template's localized remove-button name. */
909
+ #removeName(label, value) {
910
+ if (!this.hasTagTemplateTarget) return null;
911
+ const template = this.tagTemplateTarget.content.querySelector('button[data-stimeo--multi-select-target~="remove"]')?.getAttribute("aria-label")?.trim();
912
+ return template ? fillTemplate(template, { label, value }) : null;
588
913
  }
589
914
  /** Removes the chip mirroring `option`, if present. */
590
915
  #removeTagFor(option) {
@@ -593,52 +918,30 @@ var MultiSelectController = class extends Controller {
593
918
  tag?.remove();
594
919
  }
595
920
  /** Removes chip `index` and deselects its option, re-homing focus. */
596
- #removeTagAt(index) {
921
+ #removeTagAt(index, focus = "neighbor") {
597
922
  const tag = this.tagTargets[index];
598
923
  if (!tag) return;
599
924
  const value = tag.dataset.value ?? "";
600
925
  const option = this.optionTargets.find((candidate) => this.#optionValue(candidate) === value);
601
926
  if (option) option.setAttribute("aria-selected", "false");
602
927
  tag.remove();
603
- this.#announce(option ? this.#optionLabel(option) : value);
604
928
  this.#refreshRoving();
605
929
  this.#syncFields();
606
- this.dispatch("change", { detail: { values: this.#values } });
607
- const remaining = this.#removeButtons;
608
- if (remaining.length === 0) {
609
- this.#focusAfterLastTag();
610
- } else {
611
- this.#roving.setActive(Math.min(index, remaining.length - 1), { focus: true });
930
+ const values = this.#values;
931
+ this.#selectionValues = values;
932
+ this.#announceTransition(
933
+ false,
934
+ option ? this.#optionLabel(option) : value,
935
+ value,
936
+ values.length
937
+ );
938
+ this.dispatch("change", { detail: { values } });
939
+ if (focus === "input") {
940
+ this.#focusInput();
941
+ return;
612
942
  }
943
+ if (!this.#chipRow.focusAfterRemoval(index)) this.#focusAfterLastTag();
613
944
  }
614
- /** Arrow navigation and deletion within the chip list (delegated). */
615
- #onTagKeydown = (event) => {
616
- if (event.defaultPrevented) return;
617
- if (isReservedArrowChord(event)) return;
618
- const button = event.target.closest("button");
619
- if (!button) return;
620
- const buttons = this.#removeButtons;
621
- const index = buttons.indexOf(button);
622
- if (index === -1) return;
623
- switch (logicalArrowKey(event.key, this.element)) {
624
- case "ArrowLeft":
625
- if (index > 0) {
626
- event.preventDefault();
627
- this.#roving.setActive(index - 1, { focus: true });
628
- }
629
- break;
630
- case "ArrowRight":
631
- event.preventDefault();
632
- if (index < buttons.length - 1) this.#roving.setActive(index + 1, { focus: true });
633
- else this.#focusInput();
634
- break;
635
- case "Delete":
636
- case "Backspace":
637
- event.preventDefault();
638
- this.#removeTagAt(index);
639
- break;
640
- }
641
- };
642
945
  /**
643
946
  * Marks `option` active via `data-active` and the input's
644
947
  * `aria-activedescendant` (the attribute is removed, not emptied, when null).
@@ -669,7 +972,7 @@ var MultiSelectController = class extends Controller {
669
972
  const resolved = this.#activeOption;
670
973
  const active = resolved && !resolved.hidden ? resolved : null;
671
974
  const marked = this.optionTargets.filter((candidate) => candidate.hasAttribute("data-active"));
672
- const idref = this.hasInputTarget ? this.inputTarget.getAttribute("aria-activedescendant") : null;
975
+ const idref = this.inputTarget.getAttribute("aria-activedescendant");
673
976
  if (!active || marked.length !== 1 || marked[0] !== active || idref !== activeId) {
674
977
  this.#setActive(active);
675
978
  }
@@ -703,12 +1006,19 @@ var MultiSelectController = class extends Controller {
703
1006
  }
704
1007
  /** Keeps exactly one chip remove button tabbable after the set changes. */
705
1008
  #refreshRoving() {
706
- if (this.#removeButtons.length > 0 && this.#roving.activeIndex === -1)
707
- this.#roving.setActive(0);
1009
+ this.#chipRow.ensureTabStop();
708
1010
  }
709
- /** Mirrors the changed option label into the live region. */
710
- #announce(text) {
711
- if (this.hasStatusTarget) this.statusTarget.textContent = text;
1011
+ /** Sends one localized selection transition through the page's shared announcer. */
1012
+ #announceTransition(selected, label, value, count) {
1013
+ const template = selected ? this.announceTextValue : this.announceRemovedTextValue;
1014
+ announce(fillTemplate(template, { label, value, count }));
1015
+ }
1016
+ /** Whether two arrays represent the same selection set, independent of DOM order. */
1017
+ #sameSelection(left, right) {
1018
+ if (left.length !== right.length) return false;
1019
+ const a = [...left].sort();
1020
+ const b = [...right].sort();
1021
+ return a.every((value, index) => value === b[index]);
712
1022
  }
713
1023
  /** Closes the list on a click outside the controller element. */
714
1024
  #onOutsideClick = (event) => {
@@ -728,9 +1038,7 @@ var MultiSelectController = class extends Controller {
728
1038
  }
729
1039
  /** Current active target resolved by stable id, never a detached node reference. */
730
1040
  get #activeOption() {
731
- const activeId = this.#activeOptionId;
732
- if (activeId === null) return null;
733
- return this.optionTargets.find((option) => option.id === activeId) ?? null;
1041
+ return this.optionTargets.find((option) => option.id === this.#activeOptionId) ?? null;
734
1042
  }
735
1043
  /** Options currently selected. */
736
1044
  get #selectedOptions() {
@@ -740,9 +1048,10 @@ var MultiSelectController = class extends Controller {
740
1048
  get #values() {
741
1049
  return this.#selectedOptions.map((option) => this.#optionValue(option));
742
1050
  }
743
- /** The chip remove buttons in order (the roving navigation set). */
744
- get #removeButtons() {
745
- return this.hasTagsTarget ? Array.from(this.tagsTarget.querySelectorAll("button")) : [];
1051
+ /** Normalized cardinality cap: zero is unlimited and positive fractions round down. */
1052
+ get #selectionLimit() {
1053
+ if (!Number.isFinite(this.maxValue) || this.maxValue <= 0) return 0;
1054
+ return Math.max(1, Math.floor(this.maxValue));
746
1055
  }
747
1056
  /** Whether the list is currently hidden. */
748
1057
  get #isClosed() {