stimeo-ui 0.2.1 → 0.4.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +160 -0
  3. data/dist/controllers/accordion_controller.js +10 -0
  4. data/dist/controllers/alert_dialog_controller.js +32 -5
  5. data/dist/controllers/announcer_controller.js +255 -20
  6. data/dist/controllers/breadcrumb_controller.js +225 -13
  7. data/dist/controllers/calendar_controller.js +89 -22
  8. data/dist/controllers/carousel_controller.js +47 -6
  9. data/dist/controllers/collapsible_controller.js +2 -2
  10. data/dist/controllers/color_picker_controller.js +92 -7
  11. data/dist/controllers/combobox_controller.js +162 -23
  12. data/dist/controllers/command_palette_controller.js +226 -22
  13. data/dist/controllers/confirm_controller.js +32 -5
  14. data/dist/controllers/context_menu_controller.js +32 -10
  15. data/dist/controllers/countdown_controller.js +112 -12
  16. data/dist/controllers/data_grid_controller.js +82 -4
  17. data/dist/controllers/date_range_picker_controller.js +77 -3
  18. data/dist/controllers/dialog_controller.js +32 -5
  19. data/dist/controllers/drawer_controller.js +32 -5
  20. data/dist/controllers/editable_controller.js +1 -0
  21. data/dist/controllers/empty_state_controller.js +24 -10
  22. data/dist/controllers/focus_controller.js +32 -5
  23. data/dist/controllers/form_validation_controller.js +1 -1
  24. data/dist/controllers/frame_loading_controller.js +177 -15
  25. data/dist/controllers/intersection_controller.js +36 -11
  26. data/dist/controllers/lazy_frame_controller.js +31 -10
  27. data/dist/controllers/listbox_controller.js +257 -53
  28. data/dist/controllers/local_time_controller.js +102 -8
  29. data/dist/controllers/menu_controller.js +104 -17
  30. data/dist/controllers/menubar_controller.js +415 -63
  31. data/dist/controllers/meter_controller.js +145 -26
  32. data/dist/controllers/multi_select_controller.js +312 -29
  33. data/dist/controllers/navigation_menu_controller.js +154 -27
  34. data/dist/controllers/network_status_controller.js +28 -8
  35. data/dist/controllers/number_input_controller.js +7 -0
  36. data/dist/controllers/otp_controller.js +18 -1
  37. data/dist/controllers/overflow_indicator_controller.js +81 -13
  38. data/dist/controllers/overflow_menu_controller.js +408 -57
  39. data/dist/controllers/pagination_controller.js +163 -32
  40. data/dist/controllers/persist_controller.js +6 -6
  41. data/dist/controllers/pointer_drag_controller.js +9 -1
  42. data/dist/controllers/popover_controller.js +2 -2
  43. data/dist/controllers/progress_controller.js +116 -9
  44. data/dist/controllers/radio_group_controller.js +22 -3
  45. data/dist/controllers/range_slider_controller.js +100 -9
  46. data/dist/controllers/rating_controller.js +69 -2
  47. data/dist/controllers/read_more_controller.js +63 -19
  48. data/dist/controllers/relative_time_controller.js +133 -12
  49. data/dist/controllers/resizable_controller.js +65 -1
  50. data/dist/controllers/roving_controller.js +17 -2
  51. data/dist/controllers/scroll_area_controller.js +86 -12
  52. data/dist/controllers/scroll_restore_controller.js +1 -1
  53. data/dist/controllers/scroll_visibility_controller.js +33 -3
  54. data/dist/controllers/scrollspy_controller.js +346 -73
  55. data/dist/controllers/separator_controller.js +9 -0
  56. data/dist/controllers/sidebar_controller.js +37 -8
  57. data/dist/controllers/skeleton_controller.js +73 -20
  58. data/dist/controllers/slider_controller.js +49 -8
  59. data/dist/controllers/sortable_controller.js +34 -3
  60. data/dist/controllers/spinner_controller.js +228 -27
  61. data/dist/controllers/step_indicator_controller.js +82 -5
  62. data/dist/controllers/stick_to_bottom_controller.js +61 -10
  63. data/dist/controllers/sticky_observer_controller.js +32 -11
  64. data/dist/controllers/switch_controller.js +1 -0
  65. data/dist/controllers/tabs_controller.js +26 -3
  66. data/dist/controllers/tags_input_controller.js +22 -2
  67. data/dist/controllers/theme_controller.js +22 -3
  68. data/dist/controllers/time_picker_controller.js +20 -1
  69. data/dist/controllers/toast_controller.js +4 -5
  70. data/dist/controllers/toggle_group_controller.js +23 -2
  71. data/dist/controllers/toolbar_controller.js +230 -31
  72. data/dist/controllers/tree_view_controller.js +467 -51
  73. data/dist/index.js +4507 -907
  74. data/lib/stimeo/ui/version.rb +2 -3
  75. metadata +2 -2
@@ -2,6 +2,24 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/command_palette_controller.ts
4
4
 
5
+ // src/utils/active_option.ts
6
+ function syncActiveOption(options, active) {
7
+ let written = 0;
8
+ for (const option of options) {
9
+ const next = option === active ? "true" : "false";
10
+ if (option.getAttribute("aria-selected") === next) continue;
11
+ option.setAttribute("aria-selected", next);
12
+ written += 1;
13
+ }
14
+ return written;
15
+ }
16
+
17
+ // src/utils/arrow_step.ts
18
+ function isReservedArrowChord(event, allow = []) {
19
+ if (!event.key.startsWith("Arrow")) return false;
20
+ return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
21
+ }
22
+
5
23
  // src/utils/composition_tracker.ts
6
24
  var CompositionTracker = class {
7
25
  #observedTargets = /* @__PURE__ */ new Set();
@@ -49,6 +67,35 @@ var CompositionTracker = class {
49
67
  };
50
68
  };
51
69
 
70
+ // src/utils/before_cache_reset.ts
71
+ var BeforeCacheReset = class _BeforeCacheReset {
72
+ /** Every subscribed instance, iterated by the one shared document listener. */
73
+ static #subscribers = /* @__PURE__ */ new Set();
74
+ /** The shared listener; installed while at least one instance is subscribed. */
75
+ static #onBeforeCache = () => {
76
+ for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
77
+ };
78
+ #rewind;
79
+ /** @param rewind - the pass that returns this controller's state to its initial form. */
80
+ constructor(rewind) {
81
+ this.#rewind = rewind;
82
+ }
83
+ /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
84
+ activate() {
85
+ const first = _BeforeCacheReset.#subscribers.size === 0;
86
+ _BeforeCacheReset.#subscribers.add(this);
87
+ if (first) {
88
+ document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
89
+ }
90
+ }
91
+ /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
92
+ deactivate() {
93
+ _BeforeCacheReset.#subscribers.delete(this);
94
+ if (_BeforeCacheReset.#subscribers.size > 0) return;
95
+ document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
96
+ }
97
+ };
98
+
52
99
  // src/utils/escape_layer.ts
53
100
  var EscapeLayer = class _EscapeLayer {
54
101
  static #registries = /* @__PURE__ */ new WeakMap();
@@ -182,7 +229,7 @@ var FocusTrap = class {
182
229
  }
183
230
  if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
184
231
  document.addEventListener("keydown", this.#onKeydown);
185
- document.addEventListener("turbo:before-cache", this.#onBeforeCache);
232
+ this.#beforeCache.activate();
186
233
  const onEscape = this.#options.onEscape;
187
234
  if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
188
235
  if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
@@ -199,7 +246,7 @@ var FocusTrap = class {
199
246
  this.#activeState = false;
200
247
  this.#escapeLayer.deactivate();
201
248
  document.removeEventListener("keydown", this.#onKeydown);
202
- document.removeEventListener("turbo:before-cache", this.#onBeforeCache);
249
+ this.#beforeCache.deactivate();
203
250
  if (this.#scrollLocked) {
204
251
  document.body.style.overflow = this.#previousBodyOverflow;
205
252
  this.#scrollLocked = false;
@@ -223,9 +270,7 @@ var FocusTrap = class {
223
270
  * untouched (restore-open designs reopen against a clean baseline), and focus
224
271
  * is left alone mid-navigation. The listener lives only while active.
225
272
  */
226
- #onBeforeCache = () => {
227
- this.deactivate({ restoreFocus: false });
228
- };
273
+ #beforeCache = new BeforeCacheReset(() => this.deactivate({ restoreFocus: false }));
229
274
  /**
230
275
  * Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
231
276
  * shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
@@ -304,6 +349,39 @@ var FocusTrap = class {
304
349
  }
305
350
  };
306
351
 
352
+ // src/utils/microtask_coalescer.ts
353
+ var MicrotaskCoalescer = class {
354
+ #run;
355
+ #queued = false;
356
+ #active = false;
357
+ #generation = 0;
358
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
359
+ constructor(run) {
360
+ this.#run = run;
361
+ }
362
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
363
+ activate() {
364
+ this.#active = true;
365
+ }
366
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
367
+ cancel() {
368
+ this.#active = false;
369
+ this.#queued = false;
370
+ this.#generation += 1;
371
+ }
372
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
373
+ schedule() {
374
+ if (!this.#active || this.#queued) return;
375
+ this.#queued = true;
376
+ const generation = this.#generation;
377
+ queueMicrotask(() => {
378
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
379
+ this.#queued = false;
380
+ this.#run();
381
+ });
382
+ }
383
+ };
384
+
307
385
  // src/controllers/command_palette_controller.ts
308
386
  var CommandPaletteController = class _CommandPaletteController extends Controller {
309
387
  static #ORIGINAL_ARIA_DISABLED = "data-command-palette-original-aria-disabled";
@@ -323,8 +401,13 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
323
401
  "toggle"
324
402
  ];
325
403
  static events = ["select"];
326
- /** The index of the currently active option within the visible subset. */
327
- #activeIndex = -1;
404
+ /** Stable ID of the active option; the live target is resolved before every use. */
405
+ #activeId = null;
406
+ /** Visible/selectable target ID order captured for removal fallback. */
407
+ #activeOrder = [];
408
+ #connected = false;
409
+ /** Collapses one mutation batch of target callbacks into a single pass. */
410
+ #reconcile = new MicrotaskCoalescer(() => this.#reconcileActive());
328
411
  /** Owns IME lifecycle state; confirmed text re-filters an open palette once. */
329
412
  #composition = new CompositionTracker({
330
413
  onEnd: () => {
@@ -358,9 +441,13 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
358
441
  const shouldOpen = this.#isOpen || this.openValue;
359
442
  this.#resetToClosedState();
360
443
  if (shouldOpen) this.open();
444
+ this.#connected = true;
445
+ this.#reconcile.activate();
361
446
  }
362
447
  /** Tears down the global hotkey listener and reverts the modal side effects. */
363
448
  disconnect() {
449
+ this.#connected = false;
450
+ this.#reconcile.cancel();
364
451
  document.removeEventListener("keydown", this.#onGlobalKeydown);
365
452
  this.#composition.disconnect();
366
453
  this.#trap.deactivate({ restoreFocus: false });
@@ -369,10 +456,20 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
369
456
  /** Initializes semantics for an option added before or after the controller connects. */
370
457
  optionTargetConnected(option) {
371
458
  this.#syncOptionSemanticsFor(option);
459
+ option.setAttribute("aria-selected", "false");
460
+ option.removeAttribute("data-active");
461
+ if (this.#connected) this.#queueOptionReconciliation();
462
+ }
463
+ /** Clears active residue on the old node and reconciles the surviving targets. */
464
+ optionTargetDisconnected(option) {
465
+ option.setAttribute("aria-selected", "false");
466
+ option.removeAttribute("data-active");
467
+ if (this.#connected) this.#queueOptionReconciliation();
372
468
  }
373
469
  /** Tracks an input added initially or after connect without extra consumer actions. */
374
470
  inputTargetConnected(input) {
375
471
  this.#composition.observe(input);
472
+ if (this.#connected) this.#queueOptionReconciliation();
376
473
  }
377
474
  /** Removes controller-owned listeners when the input target is replaced or removed. */
378
475
  inputTargetDisconnected(input) {
@@ -412,7 +509,7 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
412
509
  const matches = searchText.includes(query);
413
510
  if (matches) {
414
511
  option.removeAttribute("hidden");
415
- if (!this.#isDisabled(option)) hasSelectableMatch = true;
512
+ if (this.#isNavigable(option)) hasSelectableMatch = true;
416
513
  } else {
417
514
  option.setAttribute("hidden", "true");
418
515
  }
@@ -427,8 +524,9 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
427
524
  const target = event.currentTarget;
428
525
  if (!target) return;
429
526
  const option = target.closest("[role='option']");
430
- if (option) this.#syncOptionSemanticsFor(option);
431
- if (option && !this.#isDisabled(option)) {
527
+ if (!option || !this.optionTargets.includes(option)) return;
528
+ this.#syncOptionSemanticsFor(option);
529
+ if (!option.hasAttribute("hidden") && !this.#isDisabled(option)) {
432
530
  this.#confirmSelection(option);
433
531
  }
434
532
  }
@@ -443,7 +541,10 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
443
541
  * focus — not only the input.
444
542
  */
445
543
  onKeydown(event) {
544
+ if (event.defaultPrevented) return;
545
+ if (isReservedArrowChord(event)) return;
446
546
  if (this.#composition.isComposing(event) || !this.#isOpen) return;
547
+ this.#reconcileActive();
447
548
  switch (event.key) {
448
549
  case "ArrowDown":
449
550
  event.preventDefault();
@@ -463,7 +564,9 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
463
564
  break;
464
565
  case "Enter": {
465
566
  event.preventDefault();
466
- const activeOption = this.#visibleOptions[this.#activeIndex];
567
+ const visible = this.#visibleOptions;
568
+ const activeIndex = this.#findActiveIndex(visible);
569
+ const activeOption = activeIndex < 0 ? void 0 : visible[activeIndex];
467
570
  if (activeOption) this.#confirmSelection(activeOption);
468
571
  break;
469
572
  }
@@ -472,7 +575,7 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
472
575
  #navigate(direction) {
473
576
  const visible = this.#visibleOptions;
474
577
  if (visible.length === 0) return;
475
- let newIndex = this.#activeIndex + direction;
578
+ let newIndex = this.#findActiveIndex(visible) + direction;
476
579
  if (newIndex >= visible.length) newIndex = 0;
477
580
  if (newIndex < 0) newIndex = visible.length - 1;
478
581
  this.#setActiveIndex(newIndex);
@@ -481,16 +584,16 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
481
584
  this.#syncOptionSemantics();
482
585
  const visible = this.#visibleOptions;
483
586
  const activeOption = visible[index] ?? null;
484
- this.#activeIndex = activeOption ? index : -1;
587
+ syncActiveOption(this.optionTargets, activeOption);
485
588
  for (const option of this.optionTargets) {
486
- option.setAttribute("aria-selected", "false");
487
- option.removeAttribute("data-active");
589
+ if (option === activeOption) option.setAttribute("data-active", "true");
590
+ else option.removeAttribute("data-active");
488
591
  }
489
592
  if (activeOption) {
490
- activeOption.setAttribute("aria-selected", "true");
491
- activeOption.setAttribute("data-active", "true");
492
593
  activeOption.scrollIntoView({ block: "nearest" });
493
594
  }
595
+ this.#activeId = activeOption?.id || null;
596
+ this.#activeOrder = activeOption ? visible.map((option) => option.id).filter(Boolean) : [];
494
597
  if (this.hasInputTarget) {
495
598
  if (activeOption?.id) {
496
599
  this.inputTarget.setAttribute("aria-activedescendant", activeOption.id);
@@ -499,7 +602,75 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
499
602
  }
500
603
  }
501
604
  }
605
+ /** Re-resolves active identity and component-specific fallback against live targets. */
606
+ #reconcileActive() {
607
+ if (!this.#isOpen) {
608
+ this.#setActiveIndex(-1);
609
+ this.#reflectEmptyState();
610
+ return;
611
+ }
612
+ const visible = this.#visibleOptions;
613
+ const currentIndex = this.#findActiveIndex(visible);
614
+ if (currentIndex >= 0) {
615
+ const active = visible[currentIndex] ?? null;
616
+ const options = this.optionTargets;
617
+ const marked = options.filter((option) => option.hasAttribute("data-active"));
618
+ const selected = options.filter((option) => option.getAttribute("aria-selected") === "true");
619
+ const stateMatches = this.#activeId === (active?.id || null) && marked.length === 1 && marked[0] === active && selected.length === 1 && selected[0] === active && (!this.hasInputTarget || this.inputTarget.getAttribute("aria-activedescendant") === (active?.id || null));
620
+ if (stateMatches) {
621
+ this.#activeOrder = visible.map((option) => option.id).filter(Boolean);
622
+ } else {
623
+ this.#setActiveIndex(currentIndex);
624
+ }
625
+ this.#reflectEmptyState();
626
+ return;
627
+ }
628
+ const activeId = (this.hasInputTarget ? this.inputTarget.getAttribute("aria-activedescendant") : null) ?? this.#activeId;
629
+ this.#setActiveIndex(activeId ? this.#findFallbackIndex(visible, activeId) : -1);
630
+ this.#reflectEmptyState();
631
+ }
632
+ /** Resolves stable ID first, then active markers for the existing ID-less case. */
633
+ #findActiveIndex(options) {
634
+ const activeId = (this.hasInputTarget ? this.inputTarget.getAttribute("aria-activedescendant") : null) ?? this.#activeId;
635
+ if (activeId) return options.findIndex((option) => option.id === activeId);
636
+ return options.findIndex(
637
+ (option) => option.hasAttribute("data-active") || option.getAttribute("aria-selected") === "true"
638
+ );
639
+ }
640
+ /** Chooses a surviving former successor, then predecessor, from selectable targets only. */
641
+ #findFallbackIndex(options, activeId) {
642
+ const oldIndex = this.#activeOrder.indexOf(activeId);
643
+ if (oldIndex < 0) return -1;
644
+ const indexesById = new Map(options.map((option, index) => [option.id, index]));
645
+ for (let index = oldIndex + 1; index < this.#activeOrder.length; index += 1) {
646
+ const fallback = indexesById.get(this.#activeOrder[index] ?? "");
647
+ if (fallback !== void 0) return fallback;
648
+ }
649
+ for (let index = oldIndex - 1; index >= 0; index -= 1) {
650
+ const fallback = indexesById.get(this.#activeOrder[index] ?? "");
651
+ if (fallback !== void 0) return fallback;
652
+ }
653
+ return -1;
654
+ }
655
+ /** Reflects whether any visible, navigable command remains. */
656
+ #reflectEmptyState() {
657
+ if (this.hasEmptyTarget) this.emptyTarget.hidden = this.#visibleOptions.length > 0;
658
+ }
659
+ /** Coalesces all target callbacks from one MutationObserver batch. */
660
+ #queueOptionReconciliation() {
661
+ this.#reconcile.schedule();
662
+ }
663
+ /**
664
+ * Runs `option`: dispatches `select` and closes.
665
+ *
666
+ * **Activation is refused here, not at the call sites.** Every path that runs a
667
+ * command funnels through this method, so one check covers keyboard, pointer,
668
+ * and anything added later. Per-call-site guards would leave `aria-disabled`
669
+ * options — which stay within the keyboard's reach — runnable from whichever
670
+ * path forgets to consult the predicate.
671
+ */
502
672
  #confirmSelection(option) {
673
+ if (this.#isDisabled(option)) return;
503
674
  const value = option.dataset.value || option.textContent || "";
504
675
  this.dispatch("select", { detail: { value, option } });
505
676
  this.close();
@@ -510,14 +681,33 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
510
681
  option.removeAttribute("hidden");
511
682
  }
512
683
  const hasSelectableOption = this.#visibleOptions.length > 0;
513
- if (this.hasEmptyTarget) this.emptyTarget.hidden = hasSelectableOption;
684
+ this.#reflectEmptyState();
514
685
  this.#setActiveIndex(hasSelectableOption ? 0 : -1);
515
686
  }
516
687
  get #visibleOptions() {
517
688
  return this.optionTargets.filter(
518
- (option) => !option.hasAttribute("hidden") && !this.#isDisabled(option)
689
+ (option) => !option.hasAttribute("hidden") && this.#isNavigable(option)
519
690
  );
520
691
  }
692
+ /**
693
+ * Whether virtual focus may land on `option`.
694
+ *
695
+ * Only `data-disabled` disqualifies. That attribute is this controller's own —
696
+ * authors reach for it to mark a row that is not a destination at all, such as
697
+ * a group heading — so it can mean "skip entirely" without contradicting ARIA.
698
+ *
699
+ * **`aria-disabled` does not disqualify.** It marks a command that must stay
700
+ * *discoverable*: `aria-activedescendant` names it, so a reader announces it as
701
+ * unavailable rather than hiding its existence. Virtual focus does
702
+ * not change that — pointing `aria-activedescendant` at a disabled option is
703
+ * ordinary ARIA. Keeping the two attributes distinct is what leaves both
704
+ * meanings expressible; collapsing them would take "show it, say it is
705
+ * unavailable, do not run it" away from consumers.
706
+ */
707
+ #isNavigable(option) {
708
+ return option.dataset.disabled !== "true";
709
+ }
710
+ /** Whether activating `option` may run. Suppressed for either disabled marker. */
521
711
  #isDisabled(option) {
522
712
  return option.dataset.disabled === "true" || option.getAttribute("aria-disabled") === "true";
523
713
  }
@@ -525,9 +715,18 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
525
715
  #syncOptionSemantics() {
526
716
  for (const option of this.optionTargets) this.#syncOptionSemanticsFor(option);
527
717
  }
528
- /** Reflects `data-disabled` to ARIA without losing a pre-existing authored value. */
718
+ /**
719
+ * Reflects `data-disabled` to ARIA without losing a pre-existing authored value,
720
+ * and puts `aria-selected` back to its baseline.
721
+ *
722
+ * Here `aria-selected` marks the *active* option, not a committed choice — the
723
+ * palette runs a command and keeps nothing selected — so an authored value is
724
+ * meaningless and is overwritten rather than preserved. Every caller either
725
+ * establishes the active option straight after (`filter`, `#setActiveIndex`) or
726
+ * is handling an option that cannot be the active one yet
727
+ * (`optionTargetConnected`).
728
+ */
529
729
  #syncOptionSemanticsFor(option) {
530
- if (!option.hasAttribute("aria-selected")) option.setAttribute("aria-selected", "false");
531
730
  const originalAttribute = _CommandPaletteController.#ORIGINAL_ARIA_DISABLED;
532
731
  const originalValue = option.getAttribute(originalAttribute);
533
732
  if (option.dataset.disabled === "true") {
@@ -581,8 +780,13 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
581
780
  }
582
781
  /** Resets transient open state so reconnect starts from a predictable closed snapshot. */
583
782
  #resetToClosedState() {
584
- this.#activeIndex = -1;
783
+ this.#activeId = null;
784
+ this.#activeOrder = [];
585
785
  this.openValue = false;
786
+ for (const option of this.optionTargets) {
787
+ option.setAttribute("aria-selected", "false");
788
+ option.removeAttribute("data-active");
789
+ }
586
790
  if (this.hasDialogTarget) {
587
791
  this.dialogTarget.hidden = true;
588
792
  }
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/confirm_controller.ts
4
4
 
5
+ // src/utils/before_cache_reset.ts
6
+ var BeforeCacheReset = class _BeforeCacheReset {
7
+ /** Every subscribed instance, iterated by the one shared document listener. */
8
+ static #subscribers = /* @__PURE__ */ new Set();
9
+ /** The shared listener; installed while at least one instance is subscribed. */
10
+ static #onBeforeCache = () => {
11
+ for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
12
+ };
13
+ #rewind;
14
+ /** @param rewind - the pass that returns this controller's state to its initial form. */
15
+ constructor(rewind) {
16
+ this.#rewind = rewind;
17
+ }
18
+ /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
19
+ activate() {
20
+ const first = _BeforeCacheReset.#subscribers.size === 0;
21
+ _BeforeCacheReset.#subscribers.add(this);
22
+ if (first) {
23
+ document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
24
+ }
25
+ }
26
+ /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
27
+ deactivate() {
28
+ _BeforeCacheReset.#subscribers.delete(this);
29
+ if (_BeforeCacheReset.#subscribers.size > 0) return;
30
+ document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
31
+ }
32
+ };
33
+
5
34
  // src/utils/escape_layer.ts
6
35
  var EscapeLayer = class _EscapeLayer {
7
36
  static #registries = /* @__PURE__ */ new WeakMap();
@@ -135,7 +164,7 @@ var FocusTrap = class {
135
164
  }
136
165
  if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
137
166
  document.addEventListener("keydown", this.#onKeydown);
138
- document.addEventListener("turbo:before-cache", this.#onBeforeCache);
167
+ this.#beforeCache.activate();
139
168
  const onEscape = this.#options.onEscape;
140
169
  if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
141
170
  if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
@@ -152,7 +181,7 @@ var FocusTrap = class {
152
181
  this.#activeState = false;
153
182
  this.#escapeLayer.deactivate();
154
183
  document.removeEventListener("keydown", this.#onKeydown);
155
- document.removeEventListener("turbo:before-cache", this.#onBeforeCache);
184
+ this.#beforeCache.deactivate();
156
185
  if (this.#scrollLocked) {
157
186
  document.body.style.overflow = this.#previousBodyOverflow;
158
187
  this.#scrollLocked = false;
@@ -176,9 +205,7 @@ var FocusTrap = class {
176
205
  * untouched (restore-open designs reopen against a clean baseline), and focus
177
206
  * is left alone mid-navigation. The listener lives only while active.
178
207
  */
179
- #onBeforeCache = () => {
180
- this.deactivate({ restoreFocus: false });
181
- };
208
+ #beforeCache = new BeforeCacheReset(() => this.deactivate({ restoreFocus: false }));
182
209
  /**
183
210
  * Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
184
211
  * shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
@@ -2,6 +2,12 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/context_menu_controller.ts
4
4
 
5
+ // src/utils/arrow_step.ts
6
+ function isReservedArrowChord(event, allow = []) {
7
+ if (!event.key.startsWith("Arrow")) return false;
8
+ return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
9
+ }
10
+
5
11
  // src/utils/escape_layer.ts
6
12
  function claimsWhileFocusWithin(element) {
7
13
  return () => {
@@ -156,16 +162,16 @@ var ContextMenuController = class extends Controller {
156
162
  connect() {
157
163
  this.#closeMenu();
158
164
  this.element.addEventListener("click", this.#onItemClickCapture, true);
159
- document.addEventListener("click", this.#onOutsidePointer);
160
- document.addEventListener("contextmenu", this.#onOutsidePointer);
165
+ document.addEventListener("click", this.#onOutsidePointer, true);
166
+ document.addEventListener("contextmenu", this.#onOutsidePointer, true);
161
167
  }
162
168
  /** Releases the listeners, stack membership, and pending Tab-close task. */
163
169
  disconnect() {
164
170
  this.#timers.clearAll();
165
171
  this.#escapeLayer.deactivate();
166
172
  this.element.removeEventListener("click", this.#onItemClickCapture, true);
167
- document.removeEventListener("click", this.#onOutsidePointer);
168
- document.removeEventListener("contextmenu", this.#onOutsidePointer);
173
+ document.removeEventListener("click", this.#onOutsidePointer, true);
174
+ document.removeEventListener("contextmenu", this.#onOutsidePointer, true);
169
175
  }
170
176
  /**
171
177
  * Opens the menu from a `contextmenu` event: suppresses the native menu and
@@ -177,6 +183,7 @@ var ContextMenuController = class extends Controller {
177
183
  }
178
184
  /** Keyboard entry on the region: `Shift+F10` / `ContextMenu` open at center. */
179
185
  onRegionKeydown(event) {
186
+ if (event.defaultPrevented) return;
180
187
  const isContextKey = event.key === "ContextMenu" || event.key === "F10" && event.shiftKey;
181
188
  if (!isContextKey) return;
182
189
  event.preventDefault();
@@ -185,6 +192,8 @@ var ContextMenuController = class extends Controller {
185
192
  }
186
193
  /** Roving focus and closing keys inside the menu. */
187
194
  onItemKeydown(event) {
195
+ if (event.defaultPrevented) return;
196
+ if (isReservedArrowChord(event)) return;
188
197
  const items = this.#navigableItems;
189
198
  const currentIndex = items.indexOf(event.currentTarget);
190
199
  switch (event.key) {
@@ -247,7 +256,13 @@ var ContextMenuController = class extends Controller {
247
256
  this.#closeMenu();
248
257
  if (this.hasRegionTarget) this.regionTarget.focus();
249
258
  }
250
- /** Closes when a click or context-menu invocation lands outside this instance. */
259
+ /**
260
+ * Closes when a click or context-menu invocation lands outside this instance.
261
+ * Both subscriptions observe in the capture phase, so the target is judged
262
+ * against the tree the user actually pressed even when a consumer handler
263
+ * detaches it, and application code that stops bubbling cannot leave the menu
264
+ * open.
265
+ */
251
266
  #onOutsidePointer = (event) => {
252
267
  if (this.#isOpen && !this.element.contains(event.target)) this.#closeMenu();
253
268
  };
@@ -265,18 +280,25 @@ var ContextMenuController = class extends Controller {
265
280
  event.preventDefault();
266
281
  event.stopImmediatePropagation();
267
282
  };
268
- /** Menu items eligible for roving focus (excludes disabled / hidden). */
283
+ /** Menu items eligible for roving focus (excludes hidden / natively disabled). */
269
284
  get #navigableItems() {
270
285
  return this.itemTargets.filter((item) => this.#isNavigable(item));
271
286
  }
272
287
  /**
273
- * An item can take roving focus unless it is `hidden`, `aria-disabled="true"`,
274
- * or a natively `disabled` form control. CSS-only visibility is not detectable
275
- * here and is the consumer's responsibility.
288
+ * An item can take roving focus unless it is `hidden` or a natively `disabled`
289
+ * form control. CSS-only visibility is not detectable here and is the
290
+ * consumer's responsibility.
291
+ *
292
+ * **`aria-disabled="true"` stays reachable.** APG separates the two attributes
293
+ * by intent: `disabled` is for controls whose existence can be inferred from a
294
+ * neighbour (a greyed Next next to a Prev), while `aria-disabled` marks a
295
+ * control that must stay *discoverable* — and it names menu items as the
296
+ * example. Skipping it would hide the command's existence from a keyboard user
297
+ * entirely. Activation is suppressed separately, so the item announces itself
298
+ * and does nothing.
276
299
  */
277
300
  #isNavigable(item) {
278
301
  if (item.hasAttribute("hidden")) return false;
279
- if (item.getAttribute("aria-disabled") === "true") return false;
280
302
  return !item.disabled;
281
303
  }
282
304
  /** Whether the menu is currently visible. */