stimeo-ui 0.2.0 → 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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +163 -0
  3. data/dist/controllers/accordion_controller.js +10 -0
  4. data/dist/controllers/alert_dialog_controller.js +318 -0
  5. data/dist/controllers/breadcrumb_controller.js +225 -13
  6. data/dist/controllers/calendar_controller.js +89 -22
  7. data/dist/controllers/carousel_controller.js +313 -0
  8. data/dist/controllers/clipboard_controller.js +144 -0
  9. data/dist/controllers/collapsible_controller.js +327 -0
  10. data/dist/controllers/color_picker_controller.js +252 -0
  11. data/dist/controllers/combobox_controller.js +162 -23
  12. data/dist/controllers/command_palette_controller.js +194 -17
  13. data/dist/controllers/context_menu_controller.js +32 -10
  14. data/dist/controllers/count_up_controller.js +8 -1
  15. data/dist/controllers/currency_input_controller.js +147 -0
  16. data/dist/controllers/data_grid_controller.js +246 -0
  17. data/dist/controllers/date_range_picker_controller.js +441 -0
  18. data/dist/controllers/dismissible_controller.js +117 -0
  19. data/dist/controllers/drawer_controller.js +630 -0
  20. data/dist/controllers/editable_controller.js +169 -0
  21. data/dist/controllers/file_dropzone_controller.js +165 -0
  22. data/dist/controllers/filter_controller.js +86 -0
  23. data/dist/controllers/flash_controller.js +36 -5
  24. data/dist/controllers/form_validation_controller.js +1 -1
  25. data/dist/controllers/highlight_controller.js +6 -4
  26. data/dist/controllers/intersection_controller.js +67 -19
  27. data/dist/controllers/lazy_frame_controller.js +54 -11
  28. data/dist/controllers/listbox_controller.js +257 -53
  29. data/dist/controllers/local_time_controller.js +2 -2
  30. data/dist/controllers/masonry_controller.js +142 -0
  31. data/dist/controllers/menu_controller.js +104 -17
  32. data/dist/controllers/menubar_controller.js +785 -0
  33. data/dist/controllers/multi_select_controller.js +755 -0
  34. data/dist/controllers/navigation_menu_controller.js +511 -0
  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 +246 -27
  38. data/dist/controllers/overflow_menu_controller.js +381 -57
  39. data/dist/controllers/pagination_controller.js +163 -32
  40. data/dist/controllers/password_reveal_controller.js +117 -0
  41. data/dist/controllers/persist_controller.js +6 -6
  42. data/dist/controllers/pointer_drag_controller.js +9 -1
  43. data/dist/controllers/popover_controller.js +2 -2
  44. data/dist/controllers/radio_group_controller.js +22 -3
  45. data/dist/controllers/range_slider_controller.js +192 -0
  46. data/dist/controllers/rating_controller.js +16 -2
  47. data/dist/controllers/read_more_controller.js +238 -0
  48. data/dist/controllers/resizable_controller.js +65 -1
  49. data/dist/controllers/roving_controller.js +17 -2
  50. data/dist/controllers/scroll_area_controller.js +101 -14
  51. data/dist/controllers/scroll_restore_controller.js +93 -0
  52. data/dist/controllers/scroll_visibility_controller.js +40 -6
  53. data/dist/controllers/scrollspy_controller.js +369 -74
  54. data/dist/controllers/separator_controller.js +96 -0
  55. data/dist/controllers/sidebar_controller.js +761 -0
  56. data/dist/controllers/skeleton_controller.js +1 -1
  57. data/dist/controllers/slider_controller.js +32 -6
  58. data/dist/controllers/sortable_controller.js +34 -3
  59. data/dist/controllers/spinner_controller.js +1 -1
  60. data/dist/controllers/stepper_controller.js +28 -12
  61. data/dist/controllers/stick_to_bottom_controller.js +9 -4
  62. data/dist/controllers/sticky_observer_controller.js +109 -20
  63. data/dist/controllers/switch_controller.js +1 -0
  64. data/dist/controllers/tabs_controller.js +26 -3
  65. data/dist/controllers/tags_input_controller.js +295 -0
  66. data/dist/controllers/theme_controller.js +42 -13
  67. data/dist/controllers/time_picker_controller.js +231 -0
  68. data/dist/controllers/toast_controller.js +40 -14
  69. data/dist/controllers/toggle_group_controller.js +23 -2
  70. data/dist/controllers/toolbar_controller.js +230 -31
  71. data/dist/controllers/transition_controller.js +153 -38
  72. data/dist/controllers/tree_view_controller.js +691 -0
  73. data/dist/index.js +4256 -915
  74. data/lib/stimeo/ui/version.rb +2 -3
  75. metadata +28 -2
@@ -0,0 +1,511 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/navigation_menu_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
+
16
+ // src/utils/escape_layer.ts
17
+ function claimsWhileFocusWithin(element) {
18
+ return () => {
19
+ const active = element.ownerDocument.activeElement;
20
+ return active === null || active === element.ownerDocument.body || element.contains(active);
21
+ };
22
+ }
23
+ var EscapeLayer = class _EscapeLayer {
24
+ static #registries = /* @__PURE__ */ new WeakMap();
25
+ #ownerDocument = null;
26
+ /** Dismissal callback while active; `null` when inactive. */
27
+ #onDismiss = null;
28
+ /** Live predicate deciding whether the layer claims a press; `null` = always. */
29
+ #claims = null;
30
+ /**
31
+ * Activates this layer at the top of its document's Escape stack, installing
32
+ * the document's shared resolver listener if this is its first layer.
33
+ * Re-activating an already-active layer moves it to the top.
34
+ */
35
+ activate(ownerDocument = document, options) {
36
+ this.deactivate();
37
+ let registry = _EscapeLayer.#registries.get(ownerDocument);
38
+ if (!registry) {
39
+ registry = _EscapeLayer.#createRegistry();
40
+ _EscapeLayer.#registries.set(ownerDocument, registry);
41
+ ownerDocument.addEventListener("keydown", registry.onKeydown);
42
+ }
43
+ registry.stack.push(this);
44
+ this.#ownerDocument = ownerDocument;
45
+ this.#onDismiss = options.onDismiss;
46
+ this.#claims = options.claims ?? null;
47
+ }
48
+ /**
49
+ * Removes this layer from its document's Escape stack, uninstalling the
50
+ * shared listener when the stack empties. Safe to call when inactive.
51
+ */
52
+ deactivate() {
53
+ const ownerDocument = this.#ownerDocument;
54
+ if (!ownerDocument) return;
55
+ const registry = _EscapeLayer.#registries.get(ownerDocument);
56
+ if (registry) {
57
+ const index = registry.stack.lastIndexOf(this);
58
+ if (index >= 0) registry.stack.splice(index, 1);
59
+ if (registry.stack.length === 0) {
60
+ ownerDocument.removeEventListener("keydown", registry.onKeydown);
61
+ _EscapeLayer.#registries.delete(ownerDocument);
62
+ }
63
+ }
64
+ this.#ownerDocument = null;
65
+ this.#onDismiss = null;
66
+ this.#claims = null;
67
+ }
68
+ /**
69
+ * Whether this active layer would own a press right now: it is the topmost
70
+ * layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests
71
+ * and diagnostics — production dismissal goes through the shared listener.
72
+ */
73
+ get ownsEscape() {
74
+ const ownerDocument = this.#ownerDocument;
75
+ if (!ownerDocument) return false;
76
+ const registry = _EscapeLayer.#registries.get(ownerDocument);
77
+ if (!registry) return false;
78
+ return _EscapeLayer.#resolveOwner(registry.stack) === this;
79
+ }
80
+ /** Builds a document's registry with its shared resolver listener. */
81
+ static #createRegistry() {
82
+ const registry = {
83
+ stack: [],
84
+ onKeydown: (event) => {
85
+ if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
86
+ const owner = _EscapeLayer.#resolveOwner(registry.stack);
87
+ if (!owner) return;
88
+ event.preventDefault();
89
+ owner.#onDismiss?.();
90
+ }
91
+ };
92
+ return registry;
93
+ }
94
+ /** The topmost stack layer whose claims predicate passes, or `null`. */
95
+ static #resolveOwner(stack) {
96
+ for (let index = stack.length - 1; index >= 0; index--) {
97
+ const layer = stack[index];
98
+ if (!layer) continue;
99
+ if (layer.#claims && !layer.#claims()) continue;
100
+ return layer;
101
+ }
102
+ return null;
103
+ }
104
+ };
105
+
106
+ // src/utils/safe_timeout.ts
107
+ var TimerRegistry = class {
108
+ /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
109
+ ids = /* @__PURE__ */ new Set();
110
+ /**
111
+ * Cancels a single tracked timer.
112
+ *
113
+ * No-ops if the id is unknown (already cleared, fired, or never owned by this
114
+ * registry), so callers can clear defensively without guarding.
115
+ */
116
+ clear(id) {
117
+ if (this.ids.delete(id)) {
118
+ this.cancel(id);
119
+ }
120
+ }
121
+ /**
122
+ * Cancels every tracked timer. Call this from a controller's `disconnect()`
123
+ * to guarantee no timer outlives the element.
124
+ */
125
+ clearAll() {
126
+ for (const id of this.ids) {
127
+ this.cancel(id);
128
+ }
129
+ this.ids.clear();
130
+ }
131
+ /** Number of timers currently tracked (pending). */
132
+ get size() {
133
+ return this.ids.size;
134
+ }
135
+ };
136
+ var SafeTimeout = class extends TimerRegistry {
137
+ /**
138
+ * Schedules `callback` after `delay` ms and returns the timer id.
139
+ *
140
+ * The id is removed from the registry automatically when the timeout fires,
141
+ * so {@link TimerRegistry.size | size} reflects only still-pending timers.
142
+ */
143
+ set(callback, delay) {
144
+ const id = this.schedule(() => {
145
+ this.ids.delete(id);
146
+ callback();
147
+ }, delay);
148
+ this.ids.add(id);
149
+ return id;
150
+ }
151
+ schedule(callback, delay) {
152
+ return window.setTimeout(callback, delay);
153
+ }
154
+ cancel(id) {
155
+ window.clearTimeout(id);
156
+ }
157
+ };
158
+
159
+ // src/controllers/navigation_menu_controller.ts
160
+ var NavigationMenuController = class extends Controller {
161
+ static targets = ["trigger", "panel", "hoverArea"];
162
+ static values = {
163
+ openOnHover: { type: Boolean, default: false },
164
+ hoverDelay: { type: Number, default: 150 }
165
+ };
166
+ static actions = ["onTriggerKeydown", "toggle"];
167
+ /** Open/close delay timers for hover mode; cleared together on disconnect. */
168
+ #hoverTimers = new SafeTimeout();
169
+ /**
170
+ * Elements currently carrying hover listeners. Removal always mirrors this
171
+ * set (not a recomputed target snapshot), so a rebuild driven by target churn
172
+ * can never strand a listener on an element that stopped being a target.
173
+ */
174
+ #hoverWired = /* @__PURE__ */ new Set();
175
+ /**
176
+ * The trigger whose hover region the pointer currently occupies, if any. Only
177
+ * the click semantics under `openOnHover` read it (see {@link toggle}).
178
+ */
179
+ #hoveredTrigger = null;
180
+ /**
181
+ * Whether the controller is between `connect()` and `disconnect()`. Stimulus
182
+ * disconnects the controller **before** disconnecting its targets, so the
183
+ * target callbacks below also fire during teardown; without this flag their
184
+ * rebuild would re-wire hover listeners onto an already-disconnected nav.
185
+ */
186
+ #connected = false;
187
+ /** Escape-stack membership while any panel is open; the shared resolver dismisses via it. */
188
+ #escapeLayer = new EscapeLayer();
189
+ /** Establishes the closed baseline and the dismissal listeners. */
190
+ connect() {
191
+ this.#connected = true;
192
+ this.#closeAll();
193
+ document.addEventListener("click", this.#onOutsideClick, true);
194
+ this.element.addEventListener("focusout", this.#onFocusOut);
195
+ if (this.openOnHoverValue) this.#addHoverListeners();
196
+ }
197
+ /** Removes every listener, pending hover timer, and stack membership taken while connected. */
198
+ disconnect() {
199
+ this.#connected = false;
200
+ this.#escapeLayer.deactivate();
201
+ document.removeEventListener("click", this.#onOutsideClick, true);
202
+ this.element.removeEventListener("focusout", this.#onFocusOut);
203
+ this.#removeHoverListeners();
204
+ this.#hoverTimers.clearAll();
205
+ }
206
+ /**
207
+ * Follows a runtime flip of `openOnHover` (a morph or a scripted attribute
208
+ * change) by wiring or unwiring the hover listeners in place. Stimulus also
209
+ * fires this once before `connect()`, which the `#connected` guard skips —
210
+ * `connect()` owns the initial wiring.
211
+ */
212
+ openOnHoverValueChanged() {
213
+ if (!this.#connected) return;
214
+ if (this.openOnHoverValue) this.#addHoverListeners();
215
+ else this.#removeHoverListeners();
216
+ }
217
+ /** Re-wires hover listeners when a target is added after connect (Turbo Streams etc.). */
218
+ triggerTargetConnected() {
219
+ this.#rewireHoverListeners();
220
+ }
221
+ /** Re-wires hover listeners when a target is removed while connected. */
222
+ triggerTargetDisconnected() {
223
+ this.#rewireHoverListeners();
224
+ }
225
+ /** See {@link NavigationMenuController.triggerTargetConnected}. */
226
+ panelTargetConnected() {
227
+ this.#rewireHoverListeners();
228
+ }
229
+ /** See {@link NavigationMenuController.triggerTargetDisconnected}. */
230
+ panelTargetDisconnected() {
231
+ this.#rewireHoverListeners();
232
+ }
233
+ /** See {@link NavigationMenuController.triggerTargetConnected}. */
234
+ hoverAreaTargetConnected() {
235
+ this.#rewireHoverListeners();
236
+ }
237
+ /** See {@link NavigationMenuController.triggerTargetDisconnected}. */
238
+ hoverAreaTargetDisconnected() {
239
+ this.#rewireHoverListeners();
240
+ }
241
+ /**
242
+ * Toggles a trigger's panel (single-open). Bound via `data-action` (click).
243
+ *
244
+ * An explicit activation always wins over a scheduled hover open/close, so any
245
+ * pending hover timer is dropped first — otherwise a close scheduled just
246
+ * before the click would fire into the freshly opened panel.
247
+ *
248
+ * Under `openOnHover`, closing a panel the pointer itself is holding open is
249
+ * refused: the pointer stays put, `mouseenter` does not re-fire, and the panel
250
+ * would be unopenable until the pointer left and came back. Keyboard
251
+ * activation (pointer elsewhere) still toggles it closed, and `Escape` and
252
+ * outside clicks dismiss either way.
253
+ */
254
+ toggle(event) {
255
+ const trigger = event.currentTarget;
256
+ this.#hoverTimers.clearAll();
257
+ if (!this.#isExpanded(trigger)) {
258
+ this.#openPanel(trigger);
259
+ return;
260
+ }
261
+ if (this.openOnHoverValue && this.#hoveredTrigger === trigger) return;
262
+ this.#closePanel(trigger);
263
+ }
264
+ /**
265
+ * `ArrowLeft`/`ArrowRight` move focus between triggers (keeping Tab order).
266
+ *
267
+ * Only navigable triggers are destinations, so a `hidden` or disabled item
268
+ * never swallows the press. A modified arrow belongs to the browser or OS
269
+ * (`Alt+←`/`Alt+→` is history back/forward), and with no destination at all
270
+ * (a single-trigger nav) the press is left to the page — `preventDefault()` is
271
+ * called only when focus actually moves, keeping the scroll-suppression
272
+ * contract honest.
273
+ */
274
+ onTriggerKeydown(event) {
275
+ if (event.defaultPrevented) return;
276
+ if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return;
277
+ if (isReservedArrowChord(event)) return;
278
+ const current = event.currentTarget;
279
+ const triggers = this.triggerTargets.filter(
280
+ (trigger) => trigger === current || this.#isNavigable(trigger)
281
+ );
282
+ const index = triggers.indexOf(current);
283
+ if (index === -1 || triggers.length < 2) return;
284
+ event.preventDefault();
285
+ const forward = isRtl(this.element) ? "ArrowLeft" : "ArrowRight";
286
+ const step = event.key === forward ? 1 : -1;
287
+ triggers[(index + step + triggers.length) % triggers.length]?.focus();
288
+ }
289
+ /**
290
+ * Opens `trigger`'s panel, closing any other open panel first. Re-opening an
291
+ * already-open panel only re-asserts the Escape layer: the close/open
292
+ * round-trip would rewrite `aria-expanded` and `hidden` for no state change.
293
+ */
294
+ #openPanel(trigger) {
295
+ if (trigger.getAttribute("aria-disabled") === "true") return;
296
+ if (this.#isExpanded(trigger)) {
297
+ this.#syncEscapeLayer();
298
+ return;
299
+ }
300
+ this.#closeAll();
301
+ const panel = this.#panelFor(trigger);
302
+ if (!panel) return;
303
+ panel.hidden = false;
304
+ trigger.setAttribute("aria-expanded", "true");
305
+ this.#syncEscapeLayer();
306
+ }
307
+ /** Closes `trigger`'s panel and reflects the collapsed state. */
308
+ #closePanel(trigger) {
309
+ const panel = this.#panelFor(trigger);
310
+ if (panel) panel.hidden = true;
311
+ trigger.setAttribute("aria-expanded", "false");
312
+ this.#syncEscapeLayer();
313
+ }
314
+ /**
315
+ * Closes every open panel and normalizes `aria-expanded="false"` on **all**
316
+ * triggers, so a trigger whose panel was hidden by other means cannot keep
317
+ * advertising an open panel.
318
+ */
319
+ #closeAll() {
320
+ for (const trigger of this.triggerTargets) this.#closePanel(trigger);
321
+ }
322
+ /**
323
+ * Whether a trigger can receive arrow-key focus: not `hidden`, not a natively
324
+ * `disabled` control (mirrors `toolbar`). CSS-only visibility cannot be
325
+ * detected headlessly and stays the consumer's responsibility.
326
+ *
327
+ * **`aria-disabled="true"` stays reachable.** APG separates the attributes by
328
+ * intent: `disabled` is for controls a neighbour makes inferable, while
329
+ * `aria-disabled` marks one that must remain *discoverable* — and a nav
330
+ * section the user cannot even arrow to is a section they cannot learn exists.
331
+ * Opening is suppressed separately, so the trigger announces itself and does
332
+ * nothing.
333
+ */
334
+ #isNavigable(trigger) {
335
+ if (trigger.hasAttribute("hidden")) return false;
336
+ return !trigger.disabled;
337
+ }
338
+ /**
339
+ * Aligns Escape-stack membership with the open state: joins (or re-asserts to
340
+ * the top) while a panel is open, leaves once none is. Re-asserting when the
341
+ * user switches panels is deliberate — the nav is again the newest layer.
342
+ */
343
+ #syncEscapeLayer() {
344
+ if (this.#isAnyOpen) {
345
+ this.#escapeLayer.activate(document, {
346
+ onDismiss: () => this.#closeAndRestore(),
347
+ claims: claimsWhileFocusWithin(this.element)
348
+ });
349
+ } else {
350
+ this.#escapeLayer.deactivate();
351
+ }
352
+ }
353
+ /**
354
+ * Closes any open panel and returns focus to its trigger (Escape path). A
355
+ * pending hover open would otherwise re-open the panel the user just
356
+ * dismissed, so the reservation is dropped with it.
357
+ */
358
+ #closeAndRestore() {
359
+ const open = this.#openTrigger;
360
+ if (!open) return;
361
+ this.#hoverTimers.clearAll();
362
+ this.#closePanel(open);
363
+ open.focus();
364
+ }
365
+ /** Closes panels when a click lands outside the nav element. */
366
+ #onOutsideClick = (event) => {
367
+ if (this.#isAnyOpen && !this.element.contains(event.target)) this.#closeAll();
368
+ };
369
+ /**
370
+ * Closes (without restoring focus) when focus leaves the nav for a known
371
+ * external destination. A null/non-Node destination is indeterminate:
372
+ * browsers use it for clicks on non-focusable content and for window
373
+ * deactivation, so those never close the nav here (matching the popover
374
+ * convention) — the outside-click handler decides pointer dismissal, and the
375
+ * Escape stack's body-focus claim keeps keyboard dismissal working.
376
+ */
377
+ #onFocusOut = (event) => {
378
+ const next = event.relatedTarget;
379
+ if (!(next instanceof Node) || this.element.contains(next)) return;
380
+ this.#closeAll();
381
+ };
382
+ /** Opens a trigger's panel after the hover delay (hover mode). */
383
+ #onPointerEnter = (event) => {
384
+ const trigger = this.#triggerForHover(event.currentTarget);
385
+ if (!trigger) return;
386
+ this.#hoveredTrigger = trigger;
387
+ this.#hoverTimers.clearAll();
388
+ this.#hoverTimers.set(() => this.#openFromHover(trigger), this.hoverDelayValue);
389
+ };
390
+ /**
391
+ * Closes the open panel after the hover delay (hover mode) — unless the
392
+ * pointer moved directly into another part of the hover region (an adjacent
393
+ * trigger, panel, or hoverArea): crossing a shared edge must not schedule a
394
+ * spurious close, e.g. a hoverArea whose panel sits outside it as a sibling.
395
+ */
396
+ #onPointerLeave = (event) => {
397
+ const next = event instanceof MouseEvent ? event.relatedTarget : null;
398
+ if (next instanceof Node && this.#hoverElements.some((el) => el.contains(next))) return;
399
+ this.#hoveredTrigger = null;
400
+ this.#hoverTimers.clearAll();
401
+ this.#hoverTimers.set(() => this.#closeFromHover(), this.hoverDelayValue);
402
+ };
403
+ /**
404
+ * Hover-driven open. Switching panels would hide the outgoing one, so focus
405
+ * standing inside it is handed back to its trigger first: pointer movement
406
+ * must never send focus to the body (the keyboard user would lose their
407
+ * place). Focus outside the closing panel is left alone.
408
+ */
409
+ #openFromHover(trigger) {
410
+ if (trigger.getAttribute("aria-disabled") === "true") return;
411
+ const open = this.#openTrigger;
412
+ if (open && open !== trigger && this.#holdsFocus(this.#panelFor(open))) open.focus();
413
+ this.#openPanel(trigger);
414
+ }
415
+ /**
416
+ * Hover-driven close: skipped while focus sits inside the panel it would hide,
417
+ * for the same no-focus-loss reason (cf. `hover_card`'s delayed close). The
418
+ * panel then stays open until `Escape`, an outside click, or focus leaving the
419
+ * nav dismisses it — all of which restore or keep focus deliberately.
420
+ */
421
+ #closeFromHover() {
422
+ const open = this.#openTrigger;
423
+ if (open && this.#holdsFocus(this.#panelFor(open))) return;
424
+ this.#closeAll();
425
+ }
426
+ /** Whether `panel` exists and currently contains the focused element. */
427
+ #holdsFocus(panel) {
428
+ return panel?.contains(document.activeElement) ?? false;
429
+ }
430
+ /** Wires hover open/close on each hover element (opt-in), tracking what was wired. */
431
+ #addHoverListeners() {
432
+ for (const element of this.#hoverElements) {
433
+ element.addEventListener("mouseenter", this.#onPointerEnter);
434
+ element.addEventListener("mouseleave", this.#onPointerLeave);
435
+ this.#hoverWired.add(element);
436
+ }
437
+ }
438
+ /** Removes the hover listeners from exactly the elements that were wired. */
439
+ #removeHoverListeners() {
440
+ for (const element of this.#hoverWired) {
441
+ element.removeEventListener("mouseenter", this.#onPointerEnter);
442
+ element.removeEventListener("mouseleave", this.#onPointerLeave);
443
+ }
444
+ this.#hoverWired.clear();
445
+ this.#hoveredTrigger = null;
446
+ }
447
+ /**
448
+ * Rebuilds the hover wiring from the current targets. Target callbacks call
449
+ * this so items added or removed after connect (e.g. a Turbo Stream append)
450
+ * participate in hover; the wired-set removal keeps the rebuild symmetric.
451
+ * Stimulus fires those callbacks during teardown too, hence the `#connected`
452
+ * guard — a rebuild after `disconnect()` would resurrect the listeners.
453
+ */
454
+ #rewireHoverListeners() {
455
+ if (!this.#connected || !this.openOnHoverValue) return;
456
+ this.#removeHoverListeners();
457
+ this.#addHoverListeners();
458
+ }
459
+ /**
460
+ * Elements that participate in hover: each `hoverArea`, plus every trigger and
461
+ * panel **not** wrapped by one. A wrapped trigger/panel must defer to its
462
+ * wrapper — its own mouseleave would otherwise schedule a close while the
463
+ * pointer is still inside the area (mouseenter does not re-fire on the wrapper
464
+ * when moving among its descendants), flickering the panel shut.
465
+ */
466
+ get #hoverElements() {
467
+ const areas = this.hoverAreaTargets;
468
+ const covered = (element) => areas.some((area) => area.contains(element));
469
+ return [
470
+ ...areas,
471
+ ...this.triggerTargets.filter((trigger) => !covered(trigger)),
472
+ ...this.panelTargets.filter((panel) => !covered(panel))
473
+ ];
474
+ }
475
+ /**
476
+ * Resolves the trigger for a hovered element: the trigger itself, the trigger
477
+ * controlling a hovered panel, or the first trigger contained in a hovered
478
+ * `hoverArea` wrapper.
479
+ */
480
+ #triggerForHover(element) {
481
+ if (this.triggerTargets.includes(element)) return element;
482
+ if (this.panelTargets.includes(element)) {
483
+ return this.triggerTargets.find((trigger) => this.#panelFor(trigger) === element) ?? null;
484
+ }
485
+ if (this.hoverAreaTargets.includes(element)) {
486
+ return this.triggerTargets.find((trigger) => element.contains(trigger)) ?? null;
487
+ }
488
+ return null;
489
+ }
490
+ /** The panel controlled by `trigger` (matched by `aria-controls`/`id`). */
491
+ #panelFor(trigger) {
492
+ const id = trigger.getAttribute("aria-controls");
493
+ return id ? this.panelTargets.find((panel) => panel.id === id) ?? null : null;
494
+ }
495
+ /** Whether `trigger`'s panel is currently expanded. */
496
+ #isExpanded(trigger) {
497
+ return trigger.getAttribute("aria-expanded") === "true";
498
+ }
499
+ /** The trigger whose panel is currently open, if any. */
500
+ get #openTrigger() {
501
+ return this.triggerTargets.find((trigger) => this.#isExpanded(trigger)) ?? null;
502
+ }
503
+ /** Whether any panel is currently open. */
504
+ get #isAnyOpen() {
505
+ return this.#openTrigger !== null;
506
+ }
507
+ };
508
+
509
+ export { NavigationMenuController };
510
+ //# sourceMappingURL=navigation_menu_controller.js.map
511
+ //# sourceMappingURL=navigation_menu_controller.js.map
@@ -2,6 +2,12 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/number_input_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/safe_timeout.ts
6
12
  var TimerRegistry = class {
7
13
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -148,6 +154,7 @@ var NumberInputController = class _NumberInputController extends Controller {
148
154
  }
149
155
  /** Keyboard stepping per the APG spinbutton model. */
150
156
  onKeydown(event) {
157
+ if (isReservedArrowChord(event)) return;
151
158
  const page = this.pageStepValue > 0 ? this.pageStepValue : this.stepValue * 10;
152
159
  let next = null;
153
160
  switch (event.key) {
@@ -2,6 +2,22 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/otp_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();
@@ -99,12 +115,13 @@ var OtpController = class extends Controller {
99
115
  }
100
116
  /** Handles Backspace retreating, arrows, and home/end navigation. */
101
117
  onKeydown(event) {
118
+ if (isReservedArrowChord(event)) return;
102
119
  const input = event.currentTarget;
103
120
  if (!input) return;
104
121
  const index = this.fieldTargets.indexOf(input);
105
122
  if (index === -1) return;
106
123
  if (this.#composition.isComposing(event)) return;
107
- switch (event.key) {
124
+ switch (logicalArrowKey(event.key, this.element)) {
108
125
  case "Backspace":
109
126
  if (!input.value) {
110
127
  if (index > 0) {