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
@@ -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();
@@ -304,6 +322,39 @@ var FocusTrap = class {
304
322
  }
305
323
  };
306
324
 
325
+ // src/utils/microtask_coalescer.ts
326
+ var MicrotaskCoalescer = class {
327
+ #run;
328
+ #queued = false;
329
+ #active = false;
330
+ #generation = 0;
331
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
332
+ constructor(run) {
333
+ this.#run = run;
334
+ }
335
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
336
+ activate() {
337
+ this.#active = true;
338
+ }
339
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
340
+ cancel() {
341
+ this.#active = false;
342
+ this.#queued = false;
343
+ this.#generation += 1;
344
+ }
345
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
346
+ schedule() {
347
+ if (!this.#active || this.#queued) return;
348
+ this.#queued = true;
349
+ const generation = this.#generation;
350
+ queueMicrotask(() => {
351
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
352
+ this.#queued = false;
353
+ this.#run();
354
+ });
355
+ }
356
+ };
357
+
307
358
  // src/controllers/command_palette_controller.ts
308
359
  var CommandPaletteController = class _CommandPaletteController extends Controller {
309
360
  static #ORIGINAL_ARIA_DISABLED = "data-command-palette-original-aria-disabled";
@@ -323,8 +374,13 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
323
374
  "toggle"
324
375
  ];
325
376
  static events = ["select"];
326
- /** The index of the currently active option within the visible subset. */
327
- #activeIndex = -1;
377
+ /** Stable ID of the active option; the live target is resolved before every use. */
378
+ #activeId = null;
379
+ /** Visible/selectable target ID order captured for removal fallback. */
380
+ #activeOrder = [];
381
+ #connected = false;
382
+ /** Collapses one mutation batch of target callbacks into a single pass. */
383
+ #reconcile = new MicrotaskCoalescer(() => this.#reconcileActive());
328
384
  /** Owns IME lifecycle state; confirmed text re-filters an open palette once. */
329
385
  #composition = new CompositionTracker({
330
386
  onEnd: () => {
@@ -358,9 +414,13 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
358
414
  const shouldOpen = this.#isOpen || this.openValue;
359
415
  this.#resetToClosedState();
360
416
  if (shouldOpen) this.open();
417
+ this.#connected = true;
418
+ this.#reconcile.activate();
361
419
  }
362
420
  /** Tears down the global hotkey listener and reverts the modal side effects. */
363
421
  disconnect() {
422
+ this.#connected = false;
423
+ this.#reconcile.cancel();
364
424
  document.removeEventListener("keydown", this.#onGlobalKeydown);
365
425
  this.#composition.disconnect();
366
426
  this.#trap.deactivate({ restoreFocus: false });
@@ -369,10 +429,20 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
369
429
  /** Initializes semantics for an option added before or after the controller connects. */
370
430
  optionTargetConnected(option) {
371
431
  this.#syncOptionSemanticsFor(option);
432
+ option.setAttribute("aria-selected", "false");
433
+ option.removeAttribute("data-active");
434
+ if (this.#connected) this.#queueOptionReconciliation();
435
+ }
436
+ /** Clears active residue on the old node and reconciles the surviving targets. */
437
+ optionTargetDisconnected(option) {
438
+ option.setAttribute("aria-selected", "false");
439
+ option.removeAttribute("data-active");
440
+ if (this.#connected) this.#queueOptionReconciliation();
372
441
  }
373
442
  /** Tracks an input added initially or after connect without extra consumer actions. */
374
443
  inputTargetConnected(input) {
375
444
  this.#composition.observe(input);
445
+ if (this.#connected) this.#queueOptionReconciliation();
376
446
  }
377
447
  /** Removes controller-owned listeners when the input target is replaced or removed. */
378
448
  inputTargetDisconnected(input) {
@@ -412,7 +482,7 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
412
482
  const matches = searchText.includes(query);
413
483
  if (matches) {
414
484
  option.removeAttribute("hidden");
415
- if (!this.#isDisabled(option)) hasSelectableMatch = true;
485
+ if (this.#isNavigable(option)) hasSelectableMatch = true;
416
486
  } else {
417
487
  option.setAttribute("hidden", "true");
418
488
  }
@@ -427,8 +497,9 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
427
497
  const target = event.currentTarget;
428
498
  if (!target) return;
429
499
  const option = target.closest("[role='option']");
430
- if (option) this.#syncOptionSemanticsFor(option);
431
- if (option && !this.#isDisabled(option)) {
500
+ if (!option || !this.optionTargets.includes(option)) return;
501
+ this.#syncOptionSemanticsFor(option);
502
+ if (!option.hasAttribute("hidden") && !this.#isDisabled(option)) {
432
503
  this.#confirmSelection(option);
433
504
  }
434
505
  }
@@ -443,7 +514,10 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
443
514
  * focus — not only the input.
444
515
  */
445
516
  onKeydown(event) {
517
+ if (event.defaultPrevented) return;
518
+ if (isReservedArrowChord(event)) return;
446
519
  if (this.#composition.isComposing(event) || !this.#isOpen) return;
520
+ this.#reconcileActive();
447
521
  switch (event.key) {
448
522
  case "ArrowDown":
449
523
  event.preventDefault();
@@ -463,7 +537,9 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
463
537
  break;
464
538
  case "Enter": {
465
539
  event.preventDefault();
466
- const activeOption = this.#visibleOptions[this.#activeIndex];
540
+ const visible = this.#visibleOptions;
541
+ const activeIndex = this.#findActiveIndex(visible);
542
+ const activeOption = activeIndex < 0 ? void 0 : visible[activeIndex];
467
543
  if (activeOption) this.#confirmSelection(activeOption);
468
544
  break;
469
545
  }
@@ -472,7 +548,7 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
472
548
  #navigate(direction) {
473
549
  const visible = this.#visibleOptions;
474
550
  if (visible.length === 0) return;
475
- let newIndex = this.#activeIndex + direction;
551
+ let newIndex = this.#findActiveIndex(visible) + direction;
476
552
  if (newIndex >= visible.length) newIndex = 0;
477
553
  if (newIndex < 0) newIndex = visible.length - 1;
478
554
  this.#setActiveIndex(newIndex);
@@ -481,16 +557,16 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
481
557
  this.#syncOptionSemantics();
482
558
  const visible = this.#visibleOptions;
483
559
  const activeOption = visible[index] ?? null;
484
- this.#activeIndex = activeOption ? index : -1;
560
+ syncActiveOption(this.optionTargets, activeOption);
485
561
  for (const option of this.optionTargets) {
486
- option.setAttribute("aria-selected", "false");
487
- option.removeAttribute("data-active");
562
+ if (option === activeOption) option.setAttribute("data-active", "true");
563
+ else option.removeAttribute("data-active");
488
564
  }
489
565
  if (activeOption) {
490
- activeOption.setAttribute("aria-selected", "true");
491
- activeOption.setAttribute("data-active", "true");
492
566
  activeOption.scrollIntoView({ block: "nearest" });
493
567
  }
568
+ this.#activeId = activeOption?.id || null;
569
+ this.#activeOrder = activeOption ? visible.map((option) => option.id).filter(Boolean) : [];
494
570
  if (this.hasInputTarget) {
495
571
  if (activeOption?.id) {
496
572
  this.inputTarget.setAttribute("aria-activedescendant", activeOption.id);
@@ -499,7 +575,75 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
499
575
  }
500
576
  }
501
577
  }
578
+ /** Re-resolves active identity and component-specific fallback against live targets. */
579
+ #reconcileActive() {
580
+ if (!this.#isOpen) {
581
+ this.#setActiveIndex(-1);
582
+ this.#reflectEmptyState();
583
+ return;
584
+ }
585
+ const visible = this.#visibleOptions;
586
+ const currentIndex = this.#findActiveIndex(visible);
587
+ if (currentIndex >= 0) {
588
+ const active = visible[currentIndex] ?? null;
589
+ const options = this.optionTargets;
590
+ const marked = options.filter((option) => option.hasAttribute("data-active"));
591
+ const selected = options.filter((option) => option.getAttribute("aria-selected") === "true");
592
+ 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));
593
+ if (stateMatches) {
594
+ this.#activeOrder = visible.map((option) => option.id).filter(Boolean);
595
+ } else {
596
+ this.#setActiveIndex(currentIndex);
597
+ }
598
+ this.#reflectEmptyState();
599
+ return;
600
+ }
601
+ const activeId = (this.hasInputTarget ? this.inputTarget.getAttribute("aria-activedescendant") : null) ?? this.#activeId;
602
+ this.#setActiveIndex(activeId ? this.#findFallbackIndex(visible, activeId) : -1);
603
+ this.#reflectEmptyState();
604
+ }
605
+ /** Resolves stable ID first, then active markers for the existing ID-less case. */
606
+ #findActiveIndex(options) {
607
+ const activeId = (this.hasInputTarget ? this.inputTarget.getAttribute("aria-activedescendant") : null) ?? this.#activeId;
608
+ if (activeId) return options.findIndex((option) => option.id === activeId);
609
+ return options.findIndex(
610
+ (option) => option.hasAttribute("data-active") || option.getAttribute("aria-selected") === "true"
611
+ );
612
+ }
613
+ /** Chooses a surviving former successor, then predecessor, from selectable targets only. */
614
+ #findFallbackIndex(options, activeId) {
615
+ const oldIndex = this.#activeOrder.indexOf(activeId);
616
+ if (oldIndex < 0) return -1;
617
+ const indexesById = new Map(options.map((option, index) => [option.id, index]));
618
+ for (let index = oldIndex + 1; index < this.#activeOrder.length; index += 1) {
619
+ const fallback = indexesById.get(this.#activeOrder[index] ?? "");
620
+ if (fallback !== void 0) return fallback;
621
+ }
622
+ for (let index = oldIndex - 1; index >= 0; index -= 1) {
623
+ const fallback = indexesById.get(this.#activeOrder[index] ?? "");
624
+ if (fallback !== void 0) return fallback;
625
+ }
626
+ return -1;
627
+ }
628
+ /** Reflects whether any visible, navigable command remains. */
629
+ #reflectEmptyState() {
630
+ if (this.hasEmptyTarget) this.emptyTarget.hidden = this.#visibleOptions.length > 0;
631
+ }
632
+ /** Coalesces all target callbacks from one MutationObserver batch. */
633
+ #queueOptionReconciliation() {
634
+ this.#reconcile.schedule();
635
+ }
636
+ /**
637
+ * Runs `option`: dispatches `select` and closes.
638
+ *
639
+ * **Activation is refused here, not at the call sites.** Every path that runs a
640
+ * command funnels through this method, so one check covers keyboard, pointer,
641
+ * and anything added later. Per-call-site guards would leave `aria-disabled`
642
+ * options — which stay within the keyboard's reach — runnable from whichever
643
+ * path forgets to consult the predicate.
644
+ */
502
645
  #confirmSelection(option) {
646
+ if (this.#isDisabled(option)) return;
503
647
  const value = option.dataset.value || option.textContent || "";
504
648
  this.dispatch("select", { detail: { value, option } });
505
649
  this.close();
@@ -510,14 +654,33 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
510
654
  option.removeAttribute("hidden");
511
655
  }
512
656
  const hasSelectableOption = this.#visibleOptions.length > 0;
513
- if (this.hasEmptyTarget) this.emptyTarget.hidden = hasSelectableOption;
657
+ this.#reflectEmptyState();
514
658
  this.#setActiveIndex(hasSelectableOption ? 0 : -1);
515
659
  }
516
660
  get #visibleOptions() {
517
661
  return this.optionTargets.filter(
518
- (option) => !option.hasAttribute("hidden") && !this.#isDisabled(option)
662
+ (option) => !option.hasAttribute("hidden") && this.#isNavigable(option)
519
663
  );
520
664
  }
665
+ /**
666
+ * Whether virtual focus may land on `option`.
667
+ *
668
+ * Only `data-disabled` disqualifies. That attribute is this controller's own —
669
+ * authors reach for it to mark a row that is not a destination at all, such as
670
+ * a group heading — so it can mean "skip entirely" without contradicting ARIA.
671
+ *
672
+ * **`aria-disabled` does not disqualify.** It marks a command that must stay
673
+ * *discoverable*: `aria-activedescendant` names it, so a reader announces it as
674
+ * unavailable rather than hiding its existence. Virtual focus does
675
+ * not change that — pointing `aria-activedescendant` at a disabled option is
676
+ * ordinary ARIA. Keeping the two attributes distinct is what leaves both
677
+ * meanings expressible; collapsing them would take "show it, say it is
678
+ * unavailable, do not run it" away from consumers.
679
+ */
680
+ #isNavigable(option) {
681
+ return option.dataset.disabled !== "true";
682
+ }
683
+ /** Whether activating `option` may run. Suppressed for either disabled marker. */
521
684
  #isDisabled(option) {
522
685
  return option.dataset.disabled === "true" || option.getAttribute("aria-disabled") === "true";
523
686
  }
@@ -525,9 +688,18 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
525
688
  #syncOptionSemantics() {
526
689
  for (const option of this.optionTargets) this.#syncOptionSemanticsFor(option);
527
690
  }
528
- /** Reflects `data-disabled` to ARIA without losing a pre-existing authored value. */
691
+ /**
692
+ * Reflects `data-disabled` to ARIA without losing a pre-existing authored value,
693
+ * and puts `aria-selected` back to its baseline.
694
+ *
695
+ * Here `aria-selected` marks the *active* option, not a committed choice — the
696
+ * palette runs a command and keeps nothing selected — so an authored value is
697
+ * meaningless and is overwritten rather than preserved. Every caller either
698
+ * establishes the active option straight after (`filter`, `#setActiveIndex`) or
699
+ * is handling an option that cannot be the active one yet
700
+ * (`optionTargetConnected`).
701
+ */
529
702
  #syncOptionSemanticsFor(option) {
530
- if (!option.hasAttribute("aria-selected")) option.setAttribute("aria-selected", "false");
531
703
  const originalAttribute = _CommandPaletteController.#ORIGINAL_ARIA_DISABLED;
532
704
  const originalValue = option.getAttribute(originalAttribute);
533
705
  if (option.dataset.disabled === "true") {
@@ -581,8 +753,13 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
581
753
  }
582
754
  /** Resets transient open state so reconnect starts from a predictable closed snapshot. */
583
755
  #resetToClosedState() {
584
- this.#activeIndex = -1;
756
+ this.#activeId = null;
757
+ this.#activeOrder = [];
585
758
  this.openValue = false;
759
+ for (const option of this.optionTargets) {
760
+ option.setAttribute("aria-selected", "false");
761
+ option.removeAttribute("data-active");
762
+ }
586
763
  if (this.hasDialogTarget) {
587
764
  this.dialogTarget.hidden = true;
588
765
  }
@@ -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. */
@@ -1,5 +1,23 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
+ // src/controllers/data_grid_controller.ts
4
+
5
+ // src/utils/logical_scroll.ts
6
+ function isRtl(element) {
7
+ return window.getComputedStyle(element).direction === "rtl";
8
+ }
9
+
10
+ // src/utils/arrow_step.ts
11
+ function logicalArrowKey(key, element) {
12
+ if (key !== "ArrowRight" && key !== "ArrowLeft") return key;
13
+ if (!isRtl(element)) return key;
14
+ return key === "ArrowRight" ? "ArrowLeft" : "ArrowRight";
15
+ }
16
+ function isReservedArrowChord(event, allow = []) {
17
+ if (!event.key.startsWith("Arrow")) return false;
18
+ return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
19
+ }
20
+
3
21
  // src/controllers/data_grid_controller.ts
4
22
  var SORT_CYCLE = ["none", "ascending", "descending"];
5
23
  function nextSortDirection(current) {
@@ -14,11 +32,27 @@ var DataGridController = class extends Controller {
14
32
  };
15
33
  static actions = ["onKeydown", "sort", "toggleSelect"];
16
34
  static events = ["selectionchange", "sort"];
17
- /** Establishes a single tab stop across all navigable cells/headers. */
35
+ /** Gates the row callback so it does not re-walk every row once per authored row on mount. */
36
+ #connected = false;
37
+ /**
38
+ * Establishes a single tab stop across all navigable cells/headers and brings
39
+ * the rows to their baseline.
40
+ *
41
+ * Normalizing here rather than leaving it to {@link selectionValueChanged}
42
+ * guarantees exactly one pass per mount: a re-attached element reuses its
43
+ * cached Stimulus context, whose value observer already knows the `selection`
44
+ * attribute, so the Value callback does not fire a second time.
45
+ */
18
46
  connect() {
19
47
  const cells = this.#navigableCells();
20
48
  const active = cells.find((cell) => cell.tabIndex === 0) ?? cells[0];
21
49
  this.#setActiveCell(active, { focus: false });
50
+ this.#normalizeSelection();
51
+ this.#connected = true;
52
+ }
53
+ /** Reopens the row callback for the next mount. */
54
+ disconnect() {
55
+ this.#connected = false;
22
56
  }
23
57
  /**
24
58
  * Keeps `aria-multiselectable` in step with the `selection` Value. Fires on connect
@@ -27,6 +61,45 @@ var DataGridController = class extends Controller {
27
61
  */
28
62
  selectionValueChanged() {
29
63
  this.#syncSelectable();
64
+ this.#normalizeSelection();
65
+ }
66
+ /**
67
+ * Re-establishes the row baseline for a row added after connect.
68
+ *
69
+ * Each pass walks every row, and Stimulus reports the authored rows one by one
70
+ * before `connect()`, so the mount is gated to keep it linear in the row count
71
+ * rather than quadratic; `connect()` runs the single baseline pass instead.
72
+ */
73
+ rowTargetConnected() {
74
+ if (!this.#connected) return;
75
+ this.#normalizeSelection();
76
+ }
77
+ /**
78
+ * Brings the authored rows to the shape the APG requires, without changing
79
+ * which rows the author chose.
80
+ *
81
+ * Every selectable row gets an explicit value — an absent `aria-selected` means
82
+ * "not selectable" in ARIA, so a forgotten attribute hides a selectable row —
83
+ * and a single-select grid keeps at most one `true`, first in DOM order.
84
+ * A grid that declares `selection="none"` has no selectable rows, so the
85
+ * attribute is removed rather than written: in ARIA its absence is what "not
86
+ * selectable" looks like.
87
+ */
88
+ #normalizeSelection() {
89
+ const rows = this.rowTargets;
90
+ if (this.selectionValue === "none") {
91
+ for (const row of rows) row.removeAttribute("aria-selected");
92
+ return;
93
+ }
94
+ const single = this.selectionValue === "single";
95
+ const first = rows.find((row) => row.getAttribute("aria-selected") === "true");
96
+ for (const row of rows) {
97
+ if (single) {
98
+ row.setAttribute("aria-selected", row === first ? "true" : "false");
99
+ } else if (row.getAttribute("aria-selected") !== "true") {
100
+ row.setAttribute("aria-selected", "false");
101
+ }
102
+ }
30
103
  }
31
104
  /**
32
105
  * Mirrors `selection="multiple"` onto `aria-multiselectable` (APG Grid) so SRs
@@ -53,11 +126,14 @@ var DataGridController = class extends Controller {
53
126
  }
54
127
  /** Toggles selection of the row owning the event target. Bound optionally. */
55
128
  toggleSelect(event) {
129
+ if (this.selectionValue === "none") return;
56
130
  const row = event.currentTarget.closest("[role='row']");
57
131
  if (row && this.rowTargets.includes(row)) this.#toggleRow(row);
58
132
  }
59
133
  /** Grid navigation + sort/select activation. Bound to cells and headers. */
60
134
  onKeydown(event) {
135
+ if (event.defaultPrevented) return;
136
+ if (isReservedArrowChord(event)) return;
61
137
  const cell = event.currentTarget;
62
138
  const matrix = this.#matrix();
63
139
  const position = this.#locate(matrix, cell);
@@ -65,7 +141,7 @@ var DataGridController = class extends Controller {
65
141
  const [row, col] = position;
66
142
  const rowCells = matrix[row] ?? [];
67
143
  let target;
68
- switch (event.key) {
144
+ switch (logicalArrowKey(event.key, this.element)) {
69
145
  case "ArrowRight":
70
146
  target = this.#cellInRow(matrix, row, col + 1);
71
147
  break;
@@ -118,8 +194,10 @@ var DataGridController = class extends Controller {
118
194
  /** Toggles a row's `aria-selected`, honoring single vs. multiple selection. */
119
195
  #toggleRow(row) {
120
196
  const selected = row.getAttribute("aria-selected") === "true";
121
- if (this.selectionValue === "single" && !selected) {
122
- for (const other of this.rowTargets) other.setAttribute("aria-selected", "false");
197
+ if (this.selectionValue === "single") {
198
+ for (const other of this.rowTargets) {
199
+ if (other !== row) other.setAttribute("aria-selected", "false");
200
+ }
123
201
  }
124
202
  row.setAttribute("aria-selected", selected ? "false" : "true");
125
203
  const rows = this.rowTargets.filter((r) => r.getAttribute("aria-selected") === "true");
@@ -2,6 +2,22 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/date_range_picker_controller.ts
4
4
 
5
+ // src/utils/logical_scroll.ts
6
+ function isRtl(element) {
7
+ return window.getComputedStyle(element).direction === "rtl";
8
+ }
9
+
10
+ // src/utils/arrow_step.ts
11
+ function logicalArrowKey(key, element) {
12
+ if (key !== "ArrowRight" && key !== "ArrowLeft") return key;
13
+ if (!isRtl(element)) return key;
14
+ return key === "ArrowRight" ? "ArrowLeft" : "ArrowRight";
15
+ }
16
+ function isReservedArrowChord(event, allow = []) {
17
+ if (!event.key.startsWith("Arrow")) return false;
18
+ return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
19
+ }
20
+
5
21
  // src/utils/dates.ts
6
22
  function toISODateString(date) {
7
23
  const year = date.getFullYear();
@@ -119,6 +135,7 @@ var DateRangePickerController = class extends Controller {
119
135
  const anchor = parseISODateString(this.#startDate) ?? this.#clampToBounds(/* @__PURE__ */ new Date()) ?? /* @__PURE__ */ new Date();
120
136
  this.#focusedDate = anchor;
121
137
  this.#viewMonth = toISOMonthString(anchor);
138
+ if (this.hasGridTarget) this.gridTarget.setAttribute("aria-multiselectable", "true");
122
139
  this.#render();
123
140
  }
124
141
  /** Cancels any pending deferred focus so it never fires on a detached element. */
@@ -184,6 +201,8 @@ var DateRangePickerController = class extends Controller {
184
201
  }
185
202
  /** Grid keyboard navigation, selection (Enter/Space), and Escape-to-cancel. */
186
203
  onKeydown(event) {
204
+ if (event.defaultPrevented) return;
205
+ if (isReservedArrowChord(event)) return;
187
206
  const cell = this.#cellFrom(event.target);
188
207
  if (!cell) return;
189
208
  const dateStr = cell.getAttribute("data-date");
@@ -195,7 +214,7 @@ var DateRangePickerController = class extends Controller {
195
214
  return;
196
215
  }
197
216
  if (event.key === "Escape") {
198
- if (this.#pendingStart && !event.defaultPrevented && !event.isComposing) {
217
+ if (this.#pendingStart && !event.isComposing) {
199
218
  event.preventDefault();
200
219
  this.#pendingStart = "";
201
220
  this.#previewDate = "";
@@ -204,7 +223,7 @@ var DateRangePickerController = class extends Controller {
204
223
  return;
205
224
  }
206
225
  let next = null;
207
- switch (event.key) {
226
+ switch (logicalArrowKey(event.key, this.element)) {
208
227
  case "ArrowLeft":
209
228
  next = addDays(date, -1);
210
229
  break;
@@ -318,7 +337,12 @@ var DateRangePickerController = class extends Controller {
318
337
  el.toggleAttribute("data-range-start", isStart);
319
338
  el.toggleAttribute("data-range-end", isEnd);
320
339
  el.toggleAttribute("data-in-range", inside);
321
- el.setAttribute("aria-selected", String(isStart || isEnd));
340
+ el.setAttribute(
341
+ "aria-selected",
342
+ String(
343
+ !!this.#startDate && iso === this.#startDate || !!this.#endDate && iso === this.#endDate
344
+ )
345
+ );
322
346
  }
323
347
  }
324
348
  /** The ordered [start, end] pair to paint: the preview while selecting, else confirmed. */
@@ -107,6 +107,7 @@ var EditableController = class extends Controller {
107
107
  return;
108
108
  }
109
109
  if (event.key === "Enter") {
110
+ if (event.defaultPrevented) return;
110
111
  if (this.#isMultiline && !(event.ctrlKey || event.metaKey)) return;
111
112
  event.preventDefault();
112
113
  this.#save(true);
@@ -216,7 +216,7 @@ var FormValidationController = class _FormValidationController extends Controlle
216
216
  }
217
217
  /**
218
218
  * Where focus should land for an invalid control. A visible control is focused
219
- * directly (status quo for native fields and radios). A validatable mirror
219
+ * directly the case for native fields and radios. A validatable mirror
220
220
  * (the `hidden` attribute) cannot receive focus, so focus is delegated to the
221
221
  * visible widget: the owning field's `control` target when it is itself
222
222
  * focusable, else its first focusable descendant (e.g. a roving-tabindex