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
@@ -1,5 +1,12 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
+ // src/controllers/sortable_controller.ts
4
+
5
+ // src/utils/logical_scroll.ts
6
+ function isRtl(element) {
7
+ return window.getComputedStyle(element).direction === "rtl";
8
+ }
9
+
3
10
  // src/controllers/sortable_controller.ts
4
11
  var SortableController = class extends Controller {
5
12
  static targets = ["list", "item", "status"];
@@ -67,6 +74,12 @@ var SortableController = class extends Controller {
67
74
  * Keyboard stepping: `pointer-drag` reports *cumulative* synthetic deltas, so
68
75
  * the difference from the last consumed value is one arrow press — its sign is
69
76
  * the direction. Cross-axis arrows never change the primary delta (no move).
77
+ *
78
+ * The sign is physical (`ArrowRight` is always `+dx`), so a right-to-left row
79
+ * has to invert it: there, moving the item rightward means moving it *earlier*
80
+ * in the DOM. Skipping this would also split the two halves of one keypress —
81
+ * `roving` already moves focus logically, so the same arrow would send the
82
+ * focus and the grabbed item opposite ways.
70
83
  */
71
84
  #stepFromKeyboard(session, detail) {
72
85
  const primary = Number(this.#isVertical ? detail.dy : detail.dx) || 0;
@@ -75,7 +88,8 @@ var SortableController = class extends Controller {
75
88
  if (delta === 0) return;
76
89
  const items = this.#items();
77
90
  const index = items.indexOf(session.item);
78
- const next = Math.max(0, Math.min(index + (delta > 0 ? 1 : -1), items.length - 1));
91
+ const step = (delta > 0 ? 1 : -1) * (this.#isReversed ? -1 : 1);
92
+ const next = Math.max(0, Math.min(index + step, items.length - 1));
79
93
  if (next === index) return;
80
94
  this.#moveTo(session.item, next);
81
95
  this.#announce("moved", session.item);
@@ -91,11 +105,13 @@ var SortableController = class extends Controller {
91
105
  if (others.length === 0) return;
92
106
  let laidOut = false;
93
107
  let target = 0;
108
+ const reversed = this.#isReversed;
94
109
  for (const other of others) {
95
110
  const rect = other.getBoundingClientRect();
96
111
  if (rect.width > 0 || rect.height > 0) laidOut = true;
97
112
  const midpoint = this.#isVertical ? rect.top + rect.height / 2 : rect.left + rect.width / 2;
98
- if (pointer > midpoint) target += 1;
113
+ const precedes = reversed ? pointer < midpoint : pointer > midpoint;
114
+ if (precedes) target += 1;
99
115
  }
100
116
  if (!laidOut) return;
101
117
  const current = this.#items().indexOf(session.item);
@@ -118,7 +134,7 @@ var SortableController = class extends Controller {
118
134
  * Mirrors a step into the `status` live region. Copy is localizable through
119
135
  * `data-grabbed` / `data-moved` / `data-dropped` / `data-canceled` templates on
120
136
  * the status element (`%{name}` / `%{position}` / `%{total}` placeholders);
121
- * terse English is the fallback (the library's shared status-channel design).
137
+ * terse English is the fallback.
122
138
  */
123
139
  #announce(key, item) {
124
140
  if (!this.hasStatusTarget) return;
@@ -160,6 +176,21 @@ var SortableController = class extends Controller {
160
176
  get #isVertical() {
161
177
  return this.orientationValue !== "horizontal";
162
178
  }
179
+ /**
180
+ * Whether DOM order runs opposite to the primary coordinate — true only for a
181
+ * horizontal row under `dir="rtl"`, where the first item sits at the largest
182
+ * `x`. Both drag paths reach this controller in physical terms (`pointer-drag`
183
+ * documents its deltas as physical and hands RTL to its consumer, which is
184
+ * this controller), so both have to be mapped back onto DOM order here.
185
+ *
186
+ * Read from the list, the element that lays the items out — never from the
187
+ * dragged item, which may carry its own `dir`. The list inherits the computed
188
+ * direction, so authoring `dir` on the root works too. A vertical list is
189
+ * unaffected: writing direction does not mirror the block axis.
190
+ */
191
+ get #isReversed() {
192
+ return !this.#isVertical && isRtl(this.#list);
193
+ }
163
194
  };
164
195
 
165
196
  export { SortableController };
@@ -69,7 +69,7 @@ var SpinnerController = class extends Controller {
69
69
  #delayTimerId = null;
70
70
  /** Pending min-duration hide timer id, or `null` when none is scheduled. */
71
71
  #hideTimerId = null;
72
- /** Epoch ms when the spinner became visible, used to enforce `minDuration`. */
72
+ /** Epoch ms when the spinner became visible; `minDuration` is measured from it. */
73
73
  #shownAt = 0;
74
74
  connect() {
75
75
  if (!this.element.hasAttribute("data-state")) {
@@ -93,8 +93,9 @@ var StickToBottomController = class extends Controller {
93
93
  #watched() {
94
94
  return this.hasContentTarget ? this.contentTarget : this.element;
95
95
  }
96
+ /** Forces reduced-motion jumps while preserving the configured normal behavior. */
96
97
  #behavior() {
97
- if (prefersReducedMotion()) return "auto";
98
+ if (prefersReducedMotion()) return "instant";
98
99
  return this.behaviorValue === "smooth" ? "smooth" : "auto";
99
100
  }
100
101
  };
@@ -13,6 +13,7 @@ var IntersectionWatcher = class {
13
13
  #onEntries;
14
14
  #observer = null;
15
15
  #active = false;
16
+ #usingPlatformDefaults = false;
16
17
  constructor(onEntries) {
17
18
  this.#onEntries = onEntries;
18
19
  }
@@ -20,15 +21,22 @@ var IntersectionWatcher = class {
20
21
  get active() {
21
22
  return this.#active;
22
23
  }
24
+ /** Whether the live observer discarded configured options after construction failed. */
25
+ get usingPlatformDefaults() {
26
+ return this.#usingPlatformDefaults;
27
+ }
23
28
  /**
24
29
  * (Re)creates the observer and observes `targets`. Returns `false` — leaving
25
30
  * the watcher inert — without `IntersectionObserver` support (very old
26
31
  * browsers; the caller's no-JS fallback stays in charge) or with no targets.
32
+ * If initial construction with the configured options fails, the watcher
33
+ * warns and retries once with the same root and platform defaults.
27
34
  *
28
- * @throws Whatever the platform throws for an invalid `rootMargin`/`threshold`
29
- * or a failing `observe()`. The exception is passed through unchanged, but
30
- * the watcher rolls back first: every target observed so far is released and
31
- * `active` stays `false`, so a caller that retries starts from a clean slate.
35
+ * @throws The fallback constructor error if both construction attempts fail,
36
+ * or whatever the platform throws from `observe()`. The exception is passed
37
+ * through unchanged, but the watcher rolls back first: every target observed
38
+ * so far is released and `active` stays `false`, so a caller that retries
39
+ * starts from a clean slate.
32
40
  */
33
41
  start(targets, options = {}) {
34
42
  this.stop();
@@ -38,12 +46,23 @@ var IntersectionWatcher = class {
38
46
  const root = "root" in options ? options.root ?? null : options.rootSelector ? document.querySelector(options.rootSelector) : null;
39
47
  let observer = null;
40
48
  try {
41
- observer = new IntersectionObserver(
42
- (entries) => {
43
- if (this.#active && this.#observer === observer) this.#onEntries(entries);
44
- },
45
- { root, rootMargin: options.rootMargin, threshold: options.threshold }
46
- );
49
+ const onEntries = (entries) => {
50
+ if (this.#active && this.#observer === observer) this.#onEntries(entries);
51
+ };
52
+ try {
53
+ observer = new IntersectionObserver(onEntries, {
54
+ root,
55
+ rootMargin: options.rootMargin,
56
+ threshold: options.threshold
57
+ });
58
+ } catch (error) {
59
+ console.warn(
60
+ "Stimeo UI: IntersectionObserver could not be constructed with the configured options; retrying with platform defaults.",
61
+ error
62
+ );
63
+ observer = new IntersectionObserver(onEntries, { root });
64
+ this.#usingPlatformDefaults = true;
65
+ }
47
66
  for (const target of list) observer.observe(target);
48
67
  this.#observer = observer;
49
68
  this.#active = true;
@@ -52,6 +71,7 @@ var IntersectionWatcher = class {
52
71
  observer?.disconnect();
53
72
  this.#observer = null;
54
73
  this.#active = false;
74
+ this.#usingPlatformDefaults = false;
55
75
  throw error;
56
76
  }
57
77
  }
@@ -78,6 +98,7 @@ var IntersectionWatcher = class {
78
98
  this.#active = false;
79
99
  this.#observer?.disconnect();
80
100
  this.#observer = null;
101
+ this.#usingPlatformDefaults = false;
81
102
  }
82
103
  };
83
104
 
@@ -93,7 +114,7 @@ var StickyObserverController = class extends Controller {
93
114
  #watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));
94
115
  /** Last reported stuck state, so `change` fires only on transitions. */
95
116
  #stuck = null;
96
- /** Target currently owned by the watcher, used to avoid duplicate restarts. */
117
+ /** Target currently owned by the watcher; comparing it avoids duplicate restarts. */
97
118
  #observedSentinel = null;
98
119
  #connected = false;
99
120
  #onIntersect(entries) {
@@ -27,6 +27,7 @@ var SwitchController = class extends Controller {
27
27
  * which would otherwise toggle the switch twice.
28
28
  */
29
29
  onKeydown(event) {
30
+ if (event.defaultPrevented) return;
30
31
  if (this.element instanceof HTMLButtonElement) return;
31
32
  if (event.repeat) return;
32
33
  if (event.key === " " || event.key === "Enter") {
@@ -1,10 +1,30 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
+ // src/controllers/tabs_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 isReservedArrowChord(event, allow = []) {
12
+ if (!event.key.startsWith("Arrow")) return false;
13
+ return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
14
+ }
15
+
3
16
  // src/controllers/tabs_controller.ts
4
17
  var TabsController = class extends Controller {
5
18
  static targets = ["tab", "panel", "list"];
6
19
  static actions = ["onKeydown", "select"];
7
- /** Selects the initially active tab (the pre-selected one, else the first). */
20
+ /**
21
+ * Selects the initially active tab: the pre-selected one, else the first.
22
+ *
23
+ * `findIndex` makes this first-wins when the author marked several — the first
24
+ * in DOM order is the only deterministic reading of "which one did they mean" —
25
+ * and `#selectIndex` then writes an explicit value onto every tab, so a
26
+ * forgotten `aria-selected` cannot leave a tab looking unselectable.
27
+ */
8
28
  connect() {
9
29
  const preselected = this.tabTargets.findIndex(
10
30
  (tab) => tab.getAttribute("aria-selected") === "true"
@@ -18,16 +38,19 @@ var TabsController = class extends Controller {
18
38
  }
19
39
  /** Implements arrow/Home/End navigation with automatic activation. */
20
40
  onKeydown(event) {
41
+ if (event.defaultPrevented) return;
42
+ if (isReservedArrowChord(event)) return;
21
43
  const tabs = this.tabTargets;
22
44
  const currentIndex = tabs.indexOf(event.currentTarget);
23
45
  if (currentIndex === -1) return;
24
46
  let nextIndex = null;
47
+ const step = isRtl(this.element) ? -1 : 1;
25
48
  switch (event.key) {
26
49
  case "ArrowRight":
27
- nextIndex = (currentIndex + 1) % tabs.length;
50
+ nextIndex = (currentIndex + step + tabs.length) % tabs.length;
28
51
  break;
29
52
  case "ArrowLeft":
30
- nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
53
+ nextIndex = (currentIndex - step + tabs.length) % tabs.length;
31
54
  break;
32
55
  case "Home":
33
56
  nextIndex = 0;
@@ -2,6 +2,22 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/tags_input_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/composition_tracker.ts
6
22
  var CompositionTracker = class {
7
23
  #observedTargets = /* @__PURE__ */ new Set();
@@ -119,6 +135,8 @@ var TagsInputController = class extends Controller {
119
135
  }
120
136
  /** Commits on `Enter`/delimiter and deletes the last tag on empty `Backspace`. */
121
137
  onKeydown(event) {
138
+ if (event.defaultPrevented) return;
139
+ if (isReservedArrowChord(event)) return;
122
140
  if (this.#composition.isComposing(event)) return;
123
141
  if (event.key === "Enter" || event.key === this.delimiterValue) {
124
142
  event.preventDefault();
@@ -133,7 +151,7 @@ var TagsInputController = class extends Controller {
133
151
  }
134
152
  return;
135
153
  }
136
- if (event.key === "ArrowLeft" && this.inputTarget.value === "") {
154
+ if (logicalArrowKey(event.key, this.element) === "ArrowLeft" && this.inputTarget.value === "") {
137
155
  const buttons = this.#removeButtons;
138
156
  if (buttons.length > 0) {
139
157
  event.preventDefault();
@@ -206,12 +224,14 @@ var TagsInputController = class extends Controller {
206
224
  }
207
225
  /** Handles arrow navigation and deletion within the chip list (delegated). */
208
226
  #onTagKeydown = (event) => {
227
+ if (event.defaultPrevented) return;
228
+ if (isReservedArrowChord(event)) return;
209
229
  const button = event.target.closest("button");
210
230
  if (!button) return;
211
231
  const buttons = this.#removeButtons;
212
232
  const index = buttons.indexOf(button);
213
233
  if (index === -1) return;
214
- switch (event.key) {
234
+ switch (logicalArrowKey(event.key, this.element)) {
215
235
  case "ArrowLeft":
216
236
  if (index > 0) {
217
237
  event.preventDefault();
@@ -2,6 +2,24 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/theme_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 logicalArrowStep(key, element) {
12
+ if (key === "ArrowDown") return 1;
13
+ if (key === "ArrowUp") return -1;
14
+ if (key !== "ArrowRight" && key !== "ArrowLeft") return 0;
15
+ const forward = isRtl(element) ? "ArrowLeft" : "ArrowRight";
16
+ return key === forward ? 1 : -1;
17
+ }
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/safe_storage.ts
6
24
  function readLocalStorage(key) {
7
25
  try {
@@ -41,6 +59,8 @@ var ThemeController = class extends Controller {
41
59
  };
42
60
  /** Arrow/Home/End navigation for the radiogroup (APG radio pattern). */
43
61
  #onKeydown = (event) => {
62
+ if (event.defaultPrevented) return;
63
+ if (isReservedArrowChord(event)) return;
44
64
  const options = this.optionTargets;
45
65
  if (options.length === 0) return;
46
66
  const target = event.target;
@@ -48,14 +68,13 @@ var ThemeController = class extends Controller {
48
68
  if (current === -1) return;
49
69
  const last = options.length - 1;
50
70
  let next = current;
71
+ const step = logicalArrowStep(event.key, this.element);
51
72
  switch (event.key) {
52
73
  case "ArrowDown":
53
74
  case "ArrowRight":
54
- next = current === last ? 0 : current + 1;
55
- break;
56
75
  case "ArrowUp":
57
76
  case "ArrowLeft":
58
- next = current === 0 ? last : current - 1;
77
+ next = step === 1 ? current === last ? 0 : current + 1 : current === 0 ? last : current - 1;
59
78
  break;
60
79
  case "Home":
61
80
  next = 0;
@@ -1,5 +1,23 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
+ // src/controllers/time_picker_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/time_picker_controller.ts
4
22
  var AM = 0;
5
23
  var PM = 1;
@@ -34,12 +52,13 @@ var TimePickerController = class extends Controller {
34
52
  }
35
53
  /** Handles stepping, inter-segment focus moves, jumps, and direct entry. */
36
54
  onKeydown(event) {
55
+ if (isReservedArrowChord(event)) return;
37
56
  const segment = event.target?.closest(
38
57
  "[data-stimeo--time-picker-target='segment']"
39
58
  );
40
59
  const kind = segment ? this.#kindOf(segment) : null;
41
60
  if (!segment || !kind) return;
42
- switch (event.key) {
61
+ switch (logicalArrowKey(event.key, this.element)) {
43
62
  case "ArrowUp":
44
63
  event.preventDefault();
45
64
  this.#step(kind, this.#delta(kind));
@@ -348,11 +348,10 @@ var ToastController = class extends Controller {
348
348
  /**
349
349
  * Removes the oldest toasts when the list exceeds `maxValue`.
350
350
  *
351
- * Public (not `#private`) as a deterministic test seam: enforcement normally
352
- * runs from `itemTargetConnected`, a Stimulus callback that fires via a
353
- * MutationObserver which happy-dom does not reliably deliver — so the unit
354
- * tests invoke it directly. It is therefore listed in the contract guard's
355
- * NON_ACTION_ALLOWLIST (it is not a user-wired action).
351
+ * Public (not `#private`) as a deterministic seam: enforcement normally runs
352
+ * from `itemTargetConnected`, a Stimulus callback delivered through a
353
+ * MutationObserver, which a DOM-only environment does not reliably fire — so
354
+ * it can also be invoked directly. It is not a user-wired action.
356
355
  */
357
356
  enforceMaxLimit() {
358
357
  const currentItems = this.itemTargets;
@@ -2,6 +2,17 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/toggle_group_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 isReservedArrowChord(event, allow = []) {
12
+ if (!event.key.startsWith("Arrow")) return false;
13
+ return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
14
+ }
15
+
5
16
  // src/utils/roving_tabindex.ts
6
17
  var RovingTabindex = class {
7
18
  /** Returns the current ordered item elements; called on every operation. */
@@ -60,17 +71,27 @@ var ToggleGroupController = class extends Controller {
60
71
  }
61
72
  /** Arrow/Home/End move focus only; Space/Enter toggle non-button hosts. */
62
73
  onKeydown(event) {
74
+ if (event.defaultPrevented) return;
75
+ if (isReservedArrowChord(event)) return;
63
76
  const current = this.itemTargets.indexOf(event.currentTarget);
64
77
  if (current === -1) return;
65
78
  let next = null;
79
+ const rtl = isRtl(this.element);
80
+ const horizontalStep = (key) => rtl ? key === "ArrowRight" ? -1 : 1 : key === "ArrowRight" ? 1 : -1;
66
81
  switch (event.key) {
67
82
  case "ArrowRight":
68
83
  case "ArrowDown":
69
- next = rovingMove(current, this.itemTargets.length, 1);
84
+ next = rovingMove(
85
+ current,
86
+ this.itemTargets.length,
87
+ event.key === "ArrowDown" ? 1 : horizontalStep(event.key));
70
88
  break;
71
89
  case "ArrowLeft":
72
90
  case "ArrowUp":
73
- next = rovingMove(current, this.itemTargets.length, -1);
91
+ next = rovingMove(
92
+ current,
93
+ this.itemTargets.length,
94
+ event.key === "ArrowUp" ? -1 : horizontalStep(event.key));
74
95
  break;
75
96
  case "Home":
76
97
  next = 0;