stimeo-ui 0.2.1 → 0.3.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +104 -0
  3. data/dist/controllers/accordion_controller.js +10 -0
  4. data/dist/controllers/breadcrumb_controller.js +225 -13
  5. data/dist/controllers/calendar_controller.js +89 -22
  6. data/dist/controllers/carousel_controller.js +47 -6
  7. data/dist/controllers/collapsible_controller.js +2 -2
  8. data/dist/controllers/color_picker_controller.js +46 -7
  9. data/dist/controllers/combobox_controller.js +162 -23
  10. data/dist/controllers/command_palette_controller.js +194 -17
  11. data/dist/controllers/context_menu_controller.js +32 -10
  12. data/dist/controllers/data_grid_controller.js +82 -4
  13. data/dist/controllers/date_range_picker_controller.js +27 -3
  14. data/dist/controllers/editable_controller.js +1 -0
  15. data/dist/controllers/form_validation_controller.js +1 -1
  16. data/dist/controllers/intersection_controller.js +36 -11
  17. data/dist/controllers/lazy_frame_controller.js +31 -10
  18. data/dist/controllers/listbox_controller.js +257 -53
  19. data/dist/controllers/local_time_controller.js +2 -2
  20. data/dist/controllers/menu_controller.js +104 -17
  21. data/dist/controllers/menubar_controller.js +415 -63
  22. data/dist/controllers/multi_select_controller.js +312 -29
  23. data/dist/controllers/navigation_menu_controller.js +154 -27
  24. data/dist/controllers/number_input_controller.js +7 -0
  25. data/dist/controllers/otp_controller.js +18 -1
  26. data/dist/controllers/overflow_indicator_controller.js +81 -13
  27. data/dist/controllers/overflow_menu_controller.js +381 -57
  28. data/dist/controllers/pagination_controller.js +163 -32
  29. data/dist/controllers/persist_controller.js +6 -6
  30. data/dist/controllers/pointer_drag_controller.js +9 -1
  31. data/dist/controllers/popover_controller.js +2 -2
  32. data/dist/controllers/radio_group_controller.js +22 -3
  33. data/dist/controllers/range_slider_controller.js +32 -6
  34. data/dist/controllers/rating_controller.js +16 -2
  35. data/dist/controllers/read_more_controller.js +63 -19
  36. data/dist/controllers/resizable_controller.js +65 -1
  37. data/dist/controllers/roving_controller.js +17 -2
  38. data/dist/controllers/scroll_area_controller.js +86 -12
  39. data/dist/controllers/scroll_restore_controller.js +1 -1
  40. data/dist/controllers/scroll_visibility_controller.js +33 -3
  41. data/dist/controllers/scrollspy_controller.js +346 -73
  42. data/dist/controllers/separator_controller.js +9 -0
  43. data/dist/controllers/skeleton_controller.js +1 -1
  44. data/dist/controllers/slider_controller.js +32 -6
  45. data/dist/controllers/sortable_controller.js +34 -3
  46. data/dist/controllers/spinner_controller.js +1 -1
  47. data/dist/controllers/stick_to_bottom_controller.js +2 -1
  48. data/dist/controllers/sticky_observer_controller.js +32 -11
  49. data/dist/controllers/switch_controller.js +1 -0
  50. data/dist/controllers/tabs_controller.js +26 -3
  51. data/dist/controllers/tags_input_controller.js +22 -2
  52. data/dist/controllers/theme_controller.js +22 -3
  53. data/dist/controllers/time_picker_controller.js +20 -1
  54. data/dist/controllers/toast_controller.js +4 -5
  55. data/dist/controllers/toggle_group_controller.js +23 -2
  56. data/dist/controllers/toolbar_controller.js +230 -31
  57. data/dist/controllers/tree_view_controller.js +467 -51
  58. data/dist/index.js +3514 -689
  59. data/lib/stimeo/ui/version.rb +2 -3
  60. metadata +2 -2
@@ -19,6 +19,22 @@ function ensureId(element, prefix = "stimeo") {
19
19
  return id;
20
20
  }
21
21
 
22
+ // src/utils/logical_scroll.ts
23
+ function isRtl(element) {
24
+ return window.getComputedStyle(element).direction === "rtl";
25
+ }
26
+
27
+ // src/utils/arrow_step.ts
28
+ function logicalArrowKey(key, element) {
29
+ if (key !== "ArrowRight" && key !== "ArrowLeft") return key;
30
+ if (!isRtl(element)) return key;
31
+ return key === "ArrowRight" ? "ArrowLeft" : "ArrowRight";
32
+ }
33
+ function isReservedArrowChord(event, allow = []) {
34
+ if (!event.key.startsWith("Arrow")) return false;
35
+ return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
36
+ }
37
+
22
38
  // src/utils/composition_tracker.ts
23
39
  var CompositionTracker = class {
24
40
  #observedTargets = /* @__PURE__ */ new Set();
@@ -66,6 +82,39 @@ var CompositionTracker = class {
66
82
  };
67
83
  };
68
84
 
85
+ // src/utils/microtask_coalescer.ts
86
+ var MicrotaskCoalescer = class {
87
+ #run;
88
+ #queued = false;
89
+ #active = false;
90
+ #generation = 0;
91
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
92
+ constructor(run) {
93
+ this.#run = run;
94
+ }
95
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
96
+ activate() {
97
+ this.#active = true;
98
+ }
99
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
100
+ cancel() {
101
+ this.#active = false;
102
+ this.#queued = false;
103
+ this.#generation += 1;
104
+ }
105
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
106
+ schedule() {
107
+ if (!this.#active || this.#queued) return;
108
+ this.#queued = true;
109
+ const generation = this.#generation;
110
+ queueMicrotask(() => {
111
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
112
+ this.#queued = false;
113
+ this.#run();
114
+ });
115
+ }
116
+ };
117
+
69
118
  // src/utils/option_scroll.ts
70
119
  function scrollOptionIntoView(list, option) {
71
120
  if (list.scrollHeight <= list.clientHeight) return;
@@ -111,6 +160,33 @@ var RovingTabindex = class {
111
160
  }
112
161
  };
113
162
 
163
+ // src/utils/tabindex_loan.ts
164
+ var TabindexLoan = class {
165
+ #value;
166
+ #lent = /* @__PURE__ */ new Set();
167
+ /**
168
+ * @param value - the `tabindex` to lend. `"-1"` (the default) is
169
+ * programmatically focusable but not a Tab stop; `"0"` is a real Tab stop,
170
+ * which a scroll region with no focusable content of its own needs.
171
+ */
172
+ constructor(value = "-1") {
173
+ this.#value = value;
174
+ }
175
+ /** Lends `element` the value; no-ops when it already carries a `tabindex`. */
176
+ lend(element) {
177
+ if (element.hasAttribute("tabindex")) return;
178
+ element.setAttribute("tabindex", this.#value);
179
+ this.#lent.add(element);
180
+ }
181
+ /** Takes back every loan whose value is still the one that was lent. */
182
+ returnAll() {
183
+ for (const element of this.#lent) {
184
+ if (element.getAttribute("tabindex") === this.#value) element.removeAttribute("tabindex");
185
+ }
186
+ this.#lent.clear();
187
+ }
188
+ };
189
+
114
190
  // src/controllers/multi_select_controller.ts
115
191
  var MultiSelectController = class extends Controller {
116
192
  static targets = [
@@ -130,8 +206,14 @@ var MultiSelectController = class extends Controller {
130
206
  };
131
207
  static actions = ["close", "filter", "onKeydown", "open", "toggleOption"];
132
208
  static events = ["change", "filter"];
133
- /** The active option (tracked via `aria-activedescendant`), or null. */
134
- #activeOption = null;
209
+ /** Stable id of the active option; the current target is resolved from the DOM. */
210
+ #activeOptionId = null;
211
+ /** Whether the root borrowed a tab stop to catch focus, so teardown can undo it. */
212
+ #tabindex = new TabindexLoan();
213
+ /** Prevents initial/teardown target callbacks from mutating authored DOM. */
214
+ #connected = false;
215
+ /** Collapses one batch of target callbacks into a single final-DOM reconciliation. */
216
+ #reconcile = new MicrotaskCoalescer(() => this.#reconcileOptions());
135
217
  /** Absorbs the browser's redundant final input after compositionend. */
136
218
  #ignorePostCompositionInput = false;
137
219
  /** Owns IME lifecycle state; confirmed text emits one filter result. */
@@ -151,30 +233,109 @@ var MultiSelectController = class extends Controller {
151
233
  /** Starts closed, syncs chips for any pre-selected options, and listens out. */
152
234
  connect() {
153
235
  if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
236
+ this.#normalizeSelection();
154
237
  this.close();
155
238
  if (this.hasTagsTarget) {
156
239
  this.tagsTarget.addEventListener("keydown", this.#onTagKeydown);
157
240
  this.tagsTarget.addEventListener("click", this.#onTagClick);
158
- for (const tag of this.tagTargets) tag.remove();
159
- for (const option of this.#selectedOptions) this.#appendTag(option);
160
- if (this.#removeButtons.length > 0) this.#roving.setActive(0);
241
+ this.#rebuildTags();
161
242
  }
162
243
  this.#syncFields();
163
- document.addEventListener("click", this.#onOutsideClick);
244
+ document.addEventListener("click", this.#onOutsideClick, true);
245
+ this.#connected = true;
246
+ this.#reconcile.activate();
247
+ }
248
+ /**
249
+ * Derives the chips from the selected options, idempotently: a Turbo Drive cache
250
+ * restore or morph can re-connect with chips already in the DOM, so they are
251
+ * cleared before deriving afresh to avoid duplicates.
252
+ */
253
+ #rebuildTags() {
254
+ if (!this.hasTagsTarget) return;
255
+ 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);
164
258
  }
165
259
  /** Tears down document and chip listeners on disconnect (Turbo included). */
166
260
  disconnect() {
261
+ this.#connected = false;
262
+ this.#reconcile.cancel();
167
263
  this.#composition.disconnect();
168
264
  this.#ignorePostCompositionInput = false;
169
265
  if (this.hasTagsTarget) {
170
266
  this.tagsTarget.removeEventListener("keydown", this.#onTagKeydown);
171
267
  this.tagsTarget.removeEventListener("click", this.#onTagClick);
172
268
  }
173
- document.removeEventListener("click", this.#onOutsideClick);
269
+ document.removeEventListener("click", this.#onOutsideClick, true);
270
+ this.#releaseTabindex();
271
+ }
272
+ /** Reconciles active state after an option target is added at runtime. */
273
+ optionTargetConnected() {
274
+ this.#scheduleOptionReconcile();
174
275
  }
175
- /** Tracks an input added initially or after connect. */
276
+ /** Cleans a removed target and reconciles active state against the surviving DOM. */
277
+ optionTargetDisconnected(option) {
278
+ if (!this.#connected) return;
279
+ option.removeAttribute("data-active");
280
+ this.#scheduleOptionReconcile();
281
+ }
282
+ /** Schedules one reconciliation after all callbacks in the mutation batch. */
283
+ #scheduleOptionReconcile() {
284
+ this.#reconcile.schedule();
285
+ }
286
+ /**
287
+ * Keeps a surviving/same-id active target, otherwise falls back to the first
288
+ * visible one — and brings the derived state back in line with the new option set.
289
+ *
290
+ * The baseline pass fills in any missing `aria-selected`, and the chips and
291
+ * hidden fields are re-derived from it, because the options are the truth source
292
+ * for the selection. The chips are rebuilt **only when the selected value set
293
+ * actually moved**: the rebuild removes and recreates every chip, so running it
294
+ * for an unrelated option would drop focus from a chip's remove button to
295
+ * `<body>`, losing the keyboard user's place for something that did not concern
296
+ * them.
297
+ */
298
+ #reconcileOptions() {
299
+ const visible = this.#visibleOptions;
300
+ const active = this.#activeOption;
301
+ const next = this.#isClosed ? null : active && !active.hidden ? active : visible[0] ?? null;
302
+ this.#setActive(next);
303
+ this.#reflectEmpty();
304
+ this.#normalizeSelection();
305
+ const selected = this.#selectedOptions;
306
+ const nextValues = selected.map((option) => this.#optionValue(option)).sort();
307
+ const tagValues = this.tagTargets.map((tag) => tag.dataset.value ?? "").sort();
308
+ const unchanged = nextValues.length === tagValues.length && nextValues.every((value, index) => value === tagValues[index]);
309
+ if (unchanged) this.#refreshTagLabels(selected);
310
+ else this.#rebuildTags();
311
+ this.#syncFields();
312
+ }
313
+ /**
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.
318
+ */
319
+ #normalizeSelection() {
320
+ for (const option of this.optionTargets) {
321
+ if (option.getAttribute("aria-selected") !== "true") {
322
+ option.setAttribute("aria-selected", "false");
323
+ }
324
+ }
325
+ }
326
+ /**
327
+ * Tracks an input added initially or after connect, and makes it describe the
328
+ * widget that is actually on screen: a swapped-in input arrives with the
329
+ * authored ARIA of a fresh node while this controller still holds the popup
330
+ * state, and the open path cannot repair that (it seeds an active option only
331
+ * when there is none), so a live list would go unannounced.
332
+ */
176
333
  inputTargetConnected(input) {
177
334
  this.#composition.observe(input);
335
+ input.setAttribute("aria-expanded", String(!this.#isClosed));
336
+ const active = this.#activeOption;
337
+ if (active) input.setAttribute("aria-activedescendant", ensureId(active, "stimeo-ms-opt"));
338
+ else input.removeAttribute("aria-activedescendant");
178
339
  }
179
340
  /** Removes composition listeners when the active input is replaced or removed. */
180
341
  inputTargetDisconnected(input) {
@@ -195,28 +356,48 @@ var MultiSelectController = class extends Controller {
195
356
  }
196
357
  this.open();
197
358
  const visible = this.#visibleOptions;
198
- this.element.toggleAttribute("data-stimeo--multi-select-empty", visible.length === 0);
359
+ this.#reflectEmpty();
199
360
  this.#setActive(visible[0] ?? null);
200
361
  this.dispatch("filter", { detail: { query } });
201
362
  }
202
- /** Opens the list and activates the first visible option when none is active. */
363
+ /**
364
+ * Opens the list and activates the first visible option when none is active.
365
+ *
366
+ * Needs the input, which owns `aria-expanded` and `aria-activedescendant`: a
367
+ * list shown without one is a popup no assistive technology is told about. So
368
+ * opening is skipped entirely, where {@link close} still closes.
369
+ */
203
370
  open() {
204
- if (!this.hasListTarget) return;
371
+ if (!this.hasListTarget || !this.hasInputTarget) return;
205
372
  this.listTarget.hidden = false;
206
373
  this.inputTarget.setAttribute("aria-expanded", "true");
207
374
  if (!this.#activeOption) this.#setActive(this.#visibleOptions[0] ?? null);
208
375
  }
209
- /** Closes the list and clears the active option. */
376
+ /**
377
+ * Closes the list and clears the active option.
378
+ *
379
+ * Survives a missing input in both directions. `connect()` calls this second,
380
+ * so dereferencing the input here would throw before the chips, the roving
381
+ * seed, the chip listeners, the hidden fields and the outside-click listener —
382
+ * and Stimulus keeps the controller alive after that throw, so none of them
383
+ * would ever run and the selection would silently stop submitting. An input
384
+ * removed while the list is open must still let it come down *and* forget its
385
+ * active option, so only the `aria-expanded` write is guarded.
386
+ */
210
387
  close() {
211
388
  if (!this.hasListTarget) return;
212
389
  this.listTarget.hidden = true;
213
- this.inputTarget.setAttribute("aria-expanded", "false");
214
390
  this.#setActive(null);
391
+ if (!this.hasInputTarget) return;
392
+ this.inputTarget.setAttribute("aria-expanded", "false");
215
393
  }
216
394
  /** Routes input keyboard interaction per the multi-select combobox model. */
217
395
  onKeydown(event) {
396
+ if (event.defaultPrevented) return;
397
+ if (isReservedArrowChord(event)) return;
218
398
  if (this.#composition.isComposing(event)) return;
219
- switch (event.key) {
399
+ this.#reconcileActiveForInteraction();
400
+ switch (logicalArrowKey(event.key, this.element)) {
220
401
  case "ArrowDown":
221
402
  event.preventDefault();
222
403
  if (this.#isClosed) this.open();
@@ -241,14 +422,16 @@ var MultiSelectController = class extends Controller {
241
422
  }
242
423
  break;
243
424
  }
244
- case "Enter":
245
- if (!this.#isClosed && this.#activeOption) {
425
+ case "Enter": {
426
+ const active = this.#activeOption;
427
+ if (!this.#isClosed && active) {
246
428
  event.preventDefault();
247
- this.#toggleSelection(this.#activeOption);
429
+ this.#toggleSelection(active);
248
430
  }
249
431
  break;
432
+ }
250
433
  case "Escape":
251
- if (event.defaultPrevented || this.#isClosed) break;
434
+ if (this.#isClosed) break;
252
435
  event.preventDefault();
253
436
  this.close();
254
437
  break;
@@ -281,9 +464,50 @@ var MultiSelectController = class extends Controller {
281
464
  */
282
465
  toggleOption(event) {
283
466
  const option = event.currentTarget.closest('[role="option"]');
284
- if (!option) return;
467
+ if (!option || !this.optionTargets.includes(option)) return;
285
468
  this.#toggleSelection(option);
286
- this.inputTarget.focus();
469
+ this.#focusInput();
470
+ }
471
+ /**
472
+ * Re-homes focus to the input, or leaves it where it is when there is none.
473
+ *
474
+ * All three callers run *after* an option or a chip already took focus, and all
475
+ * three are reachable without an input — options and chips carry their own
476
+ * `data-action`. Throwing here would leave the chip removed but focus stranded
477
+ * on a detached button.
478
+ */
479
+ #focusInput() {
480
+ if (this.hasInputTarget) this.inputTarget.focus();
481
+ }
482
+ /**
483
+ * Re-homes focus after the last chip was removed: to the input, else the root.
484
+ *
485
+ * Unlike the other {@link #focusInput} callers, the element that held focus has
486
+ * just left the DOM, so "leave it alone" is not an option — the browser already
487
+ * 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.
490
+ */
491
+ #focusAfterLastTag() {
492
+ if (this.hasInputTarget) {
493
+ this.inputTarget.focus();
494
+ return;
495
+ }
496
+ const doc = this.element.ownerDocument;
497
+ const active = doc.activeElement;
498
+ if (active && active !== doc.body && active !== doc.documentElement && active.isConnected) {
499
+ return;
500
+ }
501
+ this.#tabindex.lend(this.element);
502
+ this.element.focus();
503
+ }
504
+ /**
505
+ * Returns the borrowed tab stop. Owning the borrow is not enough — the value
506
+ * has to still be the one this instance wrote, since a consumer that changed it
507
+ * afterwards owns it now.
508
+ */
509
+ #releaseTabindex() {
510
+ this.#tabindex.returnAll();
287
511
  }
288
512
  /**
289
513
  * Removes the chip whose remove button was clicked, deselecting its option.
@@ -302,7 +526,8 @@ var MultiSelectController = class extends Controller {
302
526
  const visible = this.#visibleOptions;
303
527
  if (visible.length === 0) return;
304
528
  const current = this.#activeOption ? visible.indexOf(this.#activeOption) : -1;
305
- const next = (current + delta + visible.length) % visible.length;
529
+ const candidate = current === -1 ? delta > 0 ? 0 : visible.length - 1 : current + delta;
530
+ const next = (candidate + visible.length) % visible.length;
306
531
  this.#setActive(visible[next] ?? null);
307
532
  }
308
533
  /** Selects/deselects `option`, honoring `max`, and syncs chip + live region. */
@@ -322,6 +547,30 @@ var MultiSelectController = class extends Controller {
322
547
  this.#syncFields();
323
548
  this.dispatch("change", { detail: { values: this.#values } });
324
549
  }
550
+ /**
551
+ * Re-reads each chip's label from its option, in place.
552
+ *
553
+ * The value order alone does not say the chips are still correct: a server can
554
+ * re-render the same candidate with a new label ("Apple" → "Green Apple"), and
555
+ * the chip text and its `Remove {label}` name are both derived from the option.
556
+ * Updating them here keeps the rebuild — which would drop focus — for the case
557
+ * that actually needs it, a changed selection.
558
+ */
559
+ #refreshTagLabels(selected) {
560
+ const options = new Map(selected.map((option) => [this.#optionValue(option), option]));
561
+ for (const tag of this.tagTargets) {
562
+ const option = options.get(tag.dataset.value ?? "");
563
+ if (!option) continue;
564
+ 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
+ }
572
+ }
573
+ }
325
574
  /** Builds one chip from the template for `option`. */
326
575
  #appendTag(option) {
327
576
  if (!this.hasTagTemplateTarget || !this.hasTagsTarget) return;
@@ -357,19 +606,21 @@ var MultiSelectController = class extends Controller {
357
606
  this.dispatch("change", { detail: { values: this.#values } });
358
607
  const remaining = this.#removeButtons;
359
608
  if (remaining.length === 0) {
360
- this.inputTarget.focus();
609
+ this.#focusAfterLastTag();
361
610
  } else {
362
611
  this.#roving.setActive(Math.min(index, remaining.length - 1), { focus: true });
363
612
  }
364
613
  }
365
614
  /** Arrow navigation and deletion within the chip list (delegated). */
366
615
  #onTagKeydown = (event) => {
616
+ if (event.defaultPrevented) return;
617
+ if (isReservedArrowChord(event)) return;
367
618
  const button = event.target.closest("button");
368
619
  if (!button) return;
369
620
  const buttons = this.#removeButtons;
370
621
  const index = buttons.indexOf(button);
371
622
  if (index === -1) return;
372
- switch (event.key) {
623
+ switch (logicalArrowKey(event.key, this.element)) {
373
624
  case "ArrowLeft":
374
625
  if (index > 0) {
375
626
  event.preventDefault();
@@ -379,7 +630,7 @@ var MultiSelectController = class extends Controller {
379
630
  case "ArrowRight":
380
631
  event.preventDefault();
381
632
  if (index < buttons.length - 1) this.#roving.setActive(index + 1, { focus: true });
382
- else this.inputTarget.focus();
633
+ else this.#focusInput();
383
634
  break;
384
635
  case "Delete":
385
636
  case "Backspace":
@@ -391,23 +642,49 @@ var MultiSelectController = class extends Controller {
391
642
  /**
392
643
  * Marks `option` active via `data-active` and the input's
393
644
  * `aria-activedescendant` (the attribute is removed, not emptied, when null).
645
+ *
646
+ * The state half runs even with no input, so a `close()` that cannot touch ARIA
647
+ * still clears it: {@link open} seeds an active option only when there is none,
648
+ * so a stale one makes the next open skip the seeding and a replacement input
649
+ * gets no `aria-activedescendant` at all.
394
650
  */
395
651
  #setActive(option) {
396
- this.#activeOption = option;
652
+ const activeId = option ? ensureId(option, "stimeo-ms-opt") : null;
653
+ this.#activeOptionId = activeId;
397
654
  for (const candidate of this.optionTargets) {
398
655
  candidate.toggleAttribute("data-active", candidate === option);
399
656
  }
400
- if (option) {
401
- this.inputTarget.setAttribute("aria-activedescendant", ensureId(option, "stimeo-ms-opt"));
402
- if (this.hasListTarget) scrollOptionIntoView(this.listTarget, option);
657
+ if (option && this.hasListTarget) scrollOptionIntoView(this.listTarget, option);
658
+ if (!this.hasInputTarget) return;
659
+ if (activeId !== null) {
660
+ this.inputTarget.setAttribute("aria-activedescendant", activeId);
403
661
  } else {
404
662
  this.inputTarget.removeAttribute("aria-activedescendant");
405
663
  }
406
664
  }
665
+ /** Repairs only active identity before a key; the fallback waits for the target callback. */
666
+ #reconcileActiveForInteraction() {
667
+ const activeId = this.#activeOptionId;
668
+ if (activeId === null) return;
669
+ const resolved = this.#activeOption;
670
+ const active = resolved && !resolved.hidden ? resolved : null;
671
+ const marked = this.optionTargets.filter((candidate) => candidate.hasAttribute("data-active"));
672
+ const idref = this.hasInputTarget ? this.inputTarget.getAttribute("aria-activedescendant") : null;
673
+ if (!active || marked.length !== 1 || marked[0] !== active || idref !== activeId) {
674
+ this.#setActive(active);
675
+ }
676
+ }
677
+ /** Reflects whether the open list currently has no visible option targets. */
678
+ #reflectEmpty() {
679
+ this.element.toggleAttribute(
680
+ "data-stimeo--multi-select-empty",
681
+ !this.#isClosed && this.#visibleOptions.length === 0
682
+ );
683
+ }
407
684
  /**
408
685
  * Mirrors the selected values into named hidden inputs under the `fields`
409
686
  * target so the selection submits with a normal form (parity with tags-input).
410
- * No-ops without a `fields` target, keeping the control back-compat. When the
687
+ * No-ops without a `fields` target. When the
411
688
  * `form` value is set, each input gets a matching `form` attribute so the picker
412
689
  * can submit with a `<form>` it lives outside of.
413
690
  */
@@ -449,6 +726,12 @@ var MultiSelectController = class extends Controller {
449
726
  get #visibleOptions() {
450
727
  return this.optionTargets.filter((option) => !option.hidden);
451
728
  }
729
+ /** Current active target resolved by stable id, never a detached node reference. */
730
+ get #activeOption() {
731
+ const activeId = this.#activeOptionId;
732
+ if (activeId === null) return null;
733
+ return this.optionTargets.find((option) => option.id === activeId) ?? null;
734
+ }
452
735
  /** Options currently selected. */
453
736
  get #selectedOptions() {
454
737
  return this.optionTargets.filter((option) => option.getAttribute("aria-selected") === "true");