stimeo-ui 0.1.0.pre.alpha.1

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 (85) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +72 -0
  4. data/dist/controllers/accordion_controller.js +76 -0
  5. data/dist/controllers/announcer_controller.js +184 -0
  6. data/dist/controllers/aspect_ratio_controller.js +36 -0
  7. data/dist/controllers/auto_submit_controller.js +147 -0
  8. data/dist/controllers/avatar_controller.js +66 -0
  9. data/dist/controllers/breadcrumb_controller.js +123 -0
  10. data/dist/controllers/bulk_select_controller.js +104 -0
  11. data/dist/controllers/calendar_controller.js +394 -0
  12. data/dist/controllers/character_counter_controller.js +179 -0
  13. data/dist/controllers/checkbox_controller.js +73 -0
  14. data/dist/controllers/combobox_controller.js +186 -0
  15. data/dist/controllers/command_palette_controller.js +381 -0
  16. data/dist/controllers/conditional_fields_controller.js +112 -0
  17. data/dist/controllers/confirm_controller.js +276 -0
  18. data/dist/controllers/context_menu_controller.js +112 -0
  19. data/dist/controllers/countdown_controller.js +202 -0
  20. data/dist/controllers/dialog_controller.js +207 -0
  21. data/dist/controllers/direct_upload_controller.js +212 -0
  22. data/dist/controllers/dirty_form_controller.js +128 -0
  23. data/dist/controllers/dropdown_controller.js +66 -0
  24. data/dist/controllers/empty_state_controller.js +67 -0
  25. data/dist/controllers/flash_controller.js +221 -0
  26. data/dist/controllers/focus_controller.js +216 -0
  27. data/dist/controllers/form_field_controller.js +154 -0
  28. data/dist/controllers/form_validation_controller.js +202 -0
  29. data/dist/controllers/frame_loading_controller.js +177 -0
  30. data/dist/controllers/highlight_controller.js +107 -0
  31. data/dist/controllers/hover_card_controller.js +165 -0
  32. data/dist/controllers/idle_controller.js +141 -0
  33. data/dist/controllers/input_mask_controller.js +166 -0
  34. data/dist/controllers/lazy_frame_controller.js +68 -0
  35. data/dist/controllers/listbox_controller.js +256 -0
  36. data/dist/controllers/local_time_controller.js +81 -0
  37. data/dist/controllers/menu_controller.js +134 -0
  38. data/dist/controllers/meter_controller.js +96 -0
  39. data/dist/controllers/nested_form_controller.js +131 -0
  40. data/dist/controllers/network_status_controller.js +126 -0
  41. data/dist/controllers/number_input_controller.js +306 -0
  42. data/dist/controllers/otp_controller.js +201 -0
  43. data/dist/controllers/overflow_indicator_controller.js +169 -0
  44. data/dist/controllers/overflow_menu_controller.js +274 -0
  45. data/dist/controllers/pagination_controller.js +89 -0
  46. data/dist/controllers/password_strength_controller.js +175 -0
  47. data/dist/controllers/persist_controller.js +259 -0
  48. data/dist/controllers/popover_controller.js +94 -0
  49. data/dist/controllers/portal_controller.js +63 -0
  50. data/dist/controllers/preview_guard_controller.js +69 -0
  51. data/dist/controllers/progress_controller.js +93 -0
  52. data/dist/controllers/radio_group_controller.js +128 -0
  53. data/dist/controllers/rating_controller.js +179 -0
  54. data/dist/controllers/relative_time_controller.js +129 -0
  55. data/dist/controllers/reset_before_cache_controller.js +62 -0
  56. data/dist/controllers/resizable_controller.js +163 -0
  57. data/dist/controllers/roving_controller.js +116 -0
  58. data/dist/controllers/scroll_area_controller.js +183 -0
  59. data/dist/controllers/scroll_visibility_controller.js +103 -0
  60. data/dist/controllers/scrollspy_controller.js +171 -0
  61. data/dist/controllers/skeleton_controller.js +125 -0
  62. data/dist/controllers/slider_controller.js +109 -0
  63. data/dist/controllers/spinner_controller.js +164 -0
  64. data/dist/controllers/step_indicator_controller.js +55 -0
  65. data/dist/controllers/stepper_controller.js +78 -0
  66. data/dist/controllers/stick_to_bottom_controller.js +100 -0
  67. data/dist/controllers/sticky_observer_controller.js +53 -0
  68. data/dist/controllers/submit_once_controller.js +206 -0
  69. data/dist/controllers/switch_controller.js +50 -0
  70. data/dist/controllers/tabs_controller.js +63 -0
  71. data/dist/controllers/textarea_autosize_controller.js +72 -0
  72. data/dist/controllers/theme_controller.js +154 -0
  73. data/dist/controllers/toast_controller.js +310 -0
  74. data/dist/controllers/toggle_group_controller.js +130 -0
  75. data/dist/controllers/toolbar_controller.js +113 -0
  76. data/dist/controllers/tooltip_controller.js +165 -0
  77. data/dist/controllers/transition_controller.js +203 -0
  78. data/dist/index.js +12241 -0
  79. data/dist/positioning/index.js +145 -0
  80. data/lib/generators/stimeo/install/install_generator.rb +114 -0
  81. data/lib/generators/stimeo/install/templates/stimeo.js +12 -0
  82. data/lib/stimeo/ui/version.rb +10 -0
  83. data/lib/stimeo/ui.rb +19 -0
  84. data/lib/stimeo-ui.rb +4 -0
  85. metadata +152 -0
@@ -0,0 +1,310 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/toast_controller.ts
4
+
5
+ // src/utils/safe_timeout.ts
6
+ var TimerRegistry = class {
7
+ /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
8
+ ids = /* @__PURE__ */ new Set();
9
+ /**
10
+ * Cancels a single tracked timer.
11
+ *
12
+ * No-ops if the id is unknown (already cleared, fired, or never owned by this
13
+ * registry), so callers can clear defensively without guarding.
14
+ */
15
+ clear(id) {
16
+ if (this.ids.delete(id)) {
17
+ this.cancel(id);
18
+ }
19
+ }
20
+ /**
21
+ * Cancels every tracked timer. Call this from a controller's `disconnect()`
22
+ * to guarantee no timer outlives the element.
23
+ */
24
+ clearAll() {
25
+ for (const id of this.ids) {
26
+ this.cancel(id);
27
+ }
28
+ this.ids.clear();
29
+ }
30
+ /** Number of timers currently tracked (pending). */
31
+ get size() {
32
+ return this.ids.size;
33
+ }
34
+ };
35
+ var SafeTimeout = class extends TimerRegistry {
36
+ /**
37
+ * Schedules `callback` after `delay` ms and returns the timer id.
38
+ *
39
+ * The id is removed from the registry automatically when the timeout fires,
40
+ * so {@link TimerRegistry.size | size} reflects only still-pending timers.
41
+ */
42
+ set(callback, delay) {
43
+ const id = this.schedule(() => {
44
+ this.ids.delete(id);
45
+ callback();
46
+ }, delay);
47
+ this.ids.add(id);
48
+ return id;
49
+ }
50
+ schedule(callback, delay) {
51
+ return window.setTimeout(callback, delay);
52
+ }
53
+ cancel(id) {
54
+ window.clearTimeout(id);
55
+ }
56
+ };
57
+
58
+ // src/controllers/toast_controller.ts
59
+ var ToastController = class extends Controller {
60
+ static targets = ["list", "template", "item"];
61
+ static values = {
62
+ duration: { type: Number, default: 0 },
63
+ max: { type: Number, default: 3 }
64
+ };
65
+ static actions = ["dismiss", "onKeydown", "pause", "resume", "show"];
66
+ static events = ["dismiss", "show"];
67
+ /**
68
+ * Registry for every auto-dismiss and transition-finalize timer the controller
69
+ * schedules. {@link SafeTimeout} owns *registration and teardown only*; the
70
+ * pause/resume remaining-time accounting stays in `#activeTimeouts` so the
71
+ * per-widget WCAG 2.2.1 semantics are not flattened into the helper.
72
+ */
73
+ #timers = new SafeTimeout();
74
+ /**
75
+ * Pending one-shot `requestAnimationFrame` handles (the entering→visible flip).
76
+ * Tracked so {@link disconnect} can cancel any that have not fired, preventing a
77
+ * detached element from being mutated after it leaves the DOM (Turbo).
78
+ */
79
+ #rafHandles = /* @__PURE__ */ new Set();
80
+ /** Track active timeouts mapped by each toast element for safe cancellation. */
81
+ #activeTimeouts = /* @__PURE__ */ new Map();
82
+ /** Track active pause reasons (hover/focus) per toast for WCAG 2.2.1 pause/resume. */
83
+ #pauseReasons = /* @__PURE__ */ new Map();
84
+ connect() {
85
+ this.enforceMaxLimit();
86
+ for (const item of this.itemTargets) {
87
+ if (!this.#activeTimeouts.has(item)) {
88
+ this.#startTimer(item);
89
+ }
90
+ }
91
+ }
92
+ disconnect() {
93
+ this.#timers.clearAll();
94
+ for (const handle of this.#rafHandles) {
95
+ window.cancelAnimationFrame(handle);
96
+ }
97
+ this.#rafHandles.clear();
98
+ this.#activeTimeouts.clear();
99
+ this.#pauseReasons.clear();
100
+ }
101
+ durationValueChanged() {
102
+ if (this.durationValue > 0) {
103
+ for (const item of this.itemTargets) {
104
+ if (!this.#activeTimeouts.has(item)) {
105
+ this.#startTimer(item);
106
+ }
107
+ }
108
+ }
109
+ }
110
+ maxValueChanged() {
111
+ this.enforceMaxLimit();
112
+ }
113
+ /**
114
+ * Stimulus lifecycle callback triggered automatically when a new item target
115
+ * enters the DOM. Perfectly handles dynamic client-side injections and server-side
116
+ * Turbo Stream appends alike.
117
+ */
118
+ itemTargetConnected(element) {
119
+ this.enforceMaxLimit();
120
+ this.#startTimer(element);
121
+ element.setAttribute("data-state", "entering");
122
+ const handle = window.requestAnimationFrame(() => {
123
+ this.#rafHandles.delete(handle);
124
+ element.setAttribute("data-state", "visible");
125
+ });
126
+ this.#rafHandles.add(handle);
127
+ }
128
+ /** Clears any active timer when a toast is removed from the DOM. */
129
+ itemTargetDisconnected(element) {
130
+ this.#clearTimer(element);
131
+ }
132
+ /**
133
+ * Shows a new toast. Accepts its content from either a Stimulus action param
134
+ * (attribute-only trigger) or a programmatic `show` CustomEvent `detail`
135
+ * (remote / Turbo trigger); the action param wins when both are present.
136
+ *
137
+ * <button data-action="click->stimeo--toast#show"
138
+ * data-stimeo--toast-body-param="Saved"
139
+ * data-stimeo--toast-type-param="status">Show</button>
140
+ *
141
+ * element.dispatchEvent(new CustomEvent("show", { detail: { body, type } }))
142
+ *
143
+ * Clones the template slot, interpolates the body text, and appends to the list.
144
+ */
145
+ show(event) {
146
+ if (!this.hasTemplateTarget || !this.hasListTarget) return;
147
+ const body = this.#readField(event, "body");
148
+ if (!body) return;
149
+ const clone = this.templateTarget.content.cloneNode(true);
150
+ const item = clone.querySelector("[data-stimeo--toast-target='item']");
151
+ if (!item) return;
152
+ const bodySlot = item.querySelector("[data-toast-slot='body']");
153
+ if (bodySlot) {
154
+ bodySlot.textContent = body;
155
+ }
156
+ item.setAttribute("role", this.#readField(event, "type") === "alert" ? "alert" : "status");
157
+ this.listTarget.appendChild(item);
158
+ this.dispatch("show", { detail: { item } });
159
+ }
160
+ /**
161
+ * Reads a string field from a Stimulus action param or a CustomEvent `detail`,
162
+ * preferring the action param. Returns null unless a non-empty string is found,
163
+ * so untrusted runtime payloads cannot inject non-string values.
164
+ */
165
+ #readField(event, key) {
166
+ const params = event.params;
167
+ const fromParams = params?.[key];
168
+ if (typeof fromParams === "string" && fromParams.length > 0) return fromParams;
169
+ const detail = event.detail;
170
+ if (detail && typeof detail === "object" && key in detail) {
171
+ const value = detail[key];
172
+ if (typeof value === "string" && value.length > 0) return value;
173
+ }
174
+ return null;
175
+ }
176
+ /** Dismisses the toast that contained the trigger. */
177
+ dismiss(event) {
178
+ const target = event.currentTarget || event.target;
179
+ if (!target) return;
180
+ const item = target.closest("[data-stimeo--toast-target='item']");
181
+ if (!item) return;
182
+ this.#removeWithTransition(item, "user");
183
+ }
184
+ /** Dismisses the focused toast when Escape is pressed. */
185
+ onKeydown(event) {
186
+ if (event.key === "Escape") {
187
+ const target = event.currentTarget || event.target;
188
+ if (!target) return;
189
+ const item = target.closest("[data-stimeo--toast-target='item']");
190
+ if (!item) return;
191
+ event.preventDefault();
192
+ this.#removeWithTransition(item, "user");
193
+ }
194
+ }
195
+ /**
196
+ * Pauses the auto-dismiss timer on mouse entry or keyboard focus.
197
+ *
198
+ * Hover and focus are tracked as independent reasons: the timer is only
199
+ * snapshotted on the first active reason, and only resumed once *every*
200
+ * reason has been released (see {@link resume}). This keeps a toast paused
201
+ * while it is still hovered *or* focused, per WCAG 2.2.1.
202
+ */
203
+ pause(event) {
204
+ const item = this.#itemFromEvent(event);
205
+ if (!item || this.durationValue <= 0) return;
206
+ const timeout = this.#activeTimeouts.get(item);
207
+ if (!timeout) return;
208
+ const reasons = this.#pauseReasonsFor(item);
209
+ const wasActive = reasons.size > 0;
210
+ reasons.add(this.#pauseReason(event));
211
+ if (wasActive || timeout.id === 0) return;
212
+ this.#timers.clear(timeout.id);
213
+ const elapsed = Date.now() - timeout.startedAt;
214
+ const remaining = Math.max(0, timeout.remaining - elapsed);
215
+ this.#activeTimeouts.set(item, { id: 0, startedAt: 0, remaining });
216
+ item.setAttribute("data-paused", "true");
217
+ }
218
+ /** Resumes the auto-dismiss timer once both hover and focus have been released. */
219
+ resume(event) {
220
+ const item = this.#itemFromEvent(event);
221
+ if (!item || this.durationValue <= 0) return;
222
+ const reasons = this.#pauseReasonsFor(item);
223
+ reasons.delete(this.#pauseReason(event));
224
+ if (reasons.size > 0) return;
225
+ const timeout = this.#activeTimeouts.get(item);
226
+ if (!timeout || timeout.remaining <= 0) return;
227
+ item.removeAttribute("data-paused");
228
+ this.#startTimer(item, timeout.remaining);
229
+ }
230
+ /** Resolves the toast item element a pause/resume event targets. */
231
+ #itemFromEvent(event) {
232
+ const target = event.currentTarget || event.target;
233
+ return target?.closest("[data-stimeo--toast-target='item']") ?? null;
234
+ }
235
+ /** Classifies a pause/resume event as a hover or focus reason. */
236
+ #pauseReason(event) {
237
+ return event.type === "focusin" || event.type === "focusout" ? "focus" : "hover";
238
+ }
239
+ /** Lazily creates and returns the active pause-reason set for an item. */
240
+ #pauseReasonsFor(item) {
241
+ let reasons = this.#pauseReasons.get(item);
242
+ if (!reasons) {
243
+ reasons = /* @__PURE__ */ new Set();
244
+ this.#pauseReasons.set(item, reasons);
245
+ }
246
+ return reasons;
247
+ }
248
+ #startTimer(element, duration = this.durationValue) {
249
+ if (duration <= 0) return;
250
+ this.#clearTimer(element);
251
+ const id = this.#timers.set(() => {
252
+ this.#removeWithTransition(element, "timeout");
253
+ }, duration);
254
+ this.#activeTimeouts.set(element, { id, startedAt: Date.now(), remaining: duration });
255
+ }
256
+ #clearTimer(element) {
257
+ const timeout = this.#activeTimeouts.get(element);
258
+ if (timeout) {
259
+ if (timeout.id) this.#timers.clear(timeout.id);
260
+ this.#activeTimeouts.delete(element);
261
+ }
262
+ this.#pauseReasons.delete(element);
263
+ }
264
+ #removeWithTransition(element, reason) {
265
+ this.#clearTimer(element);
266
+ element.setAttribute("data-state", "leaving");
267
+ const finalize = () => {
268
+ if (element.parentNode === this.listTarget) {
269
+ this.listTarget.removeChild(element);
270
+ }
271
+ this.dispatch("dismiss", { detail: { item: element, reason } });
272
+ };
273
+ const transitions = window.getComputedStyle(element).transitionDuration;
274
+ const duration = cssTimeToMs(transitions);
275
+ if (duration > 0) {
276
+ this.#timers.set(finalize, duration);
277
+ } else {
278
+ finalize();
279
+ }
280
+ }
281
+ /**
282
+ * Removes the oldest toasts when the list exceeds `maxValue`.
283
+ *
284
+ * Public (not `#private`) as a deterministic test seam: enforcement normally
285
+ * runs from `itemTargetConnected`, a Stimulus callback that fires via a
286
+ * MutationObserver — which happy-dom does not reliably deliver — so the unit
287
+ * tests invoke it directly. It is therefore listed in the contract guard's
288
+ * NON_ACTION_ALLOWLIST (it is not a user-wired action).
289
+ */
290
+ enforceMaxLimit() {
291
+ const currentItems = this.itemTargets;
292
+ if (currentItems.length > this.maxValue) {
293
+ const excessCount = currentItems.length - this.maxValue;
294
+ for (let i = 0; i < excessCount; i++) {
295
+ const oldest = currentItems[i];
296
+ if (oldest) this.#removeWithTransition(oldest, "timeout");
297
+ }
298
+ }
299
+ }
300
+ };
301
+ function cssTimeToMs(value) {
302
+ const first = value.split(",")[0]?.trim() ?? "";
303
+ const amount = Number.parseFloat(first);
304
+ if (Number.isNaN(amount)) return 0;
305
+ return first.endsWith("ms") ? amount : amount * 1e3;
306
+ }
307
+
308
+ export { ToastController, cssTimeToMs };
309
+ //# sourceMappingURL=toast_controller.js.map
310
+ //# sourceMappingURL=toast_controller.js.map
@@ -0,0 +1,130 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/toggle_group_controller.ts
4
+
5
+ // src/utils/roving_tabindex.ts
6
+ var RovingTabindex = class {
7
+ /** Returns the current ordered item elements; called on every operation. */
8
+ #getItems;
9
+ /**
10
+ * @param getItems - Returns the current ordered item elements. Called on every
11
+ * operation so the live target list is always used.
12
+ */
13
+ constructor(getItems) {
14
+ this.#getItems = getItems;
15
+ }
16
+ /** Index of the currently tabbable item (`tabindex="0"`), or `-1` if none. */
17
+ get activeIndex() {
18
+ return this.#getItems().findIndex((item) => item.tabIndex === 0);
19
+ }
20
+ /**
21
+ * Makes exactly the item at `index` tabbable (`tabindex="0"`) and removes every
22
+ * other item from the Tab sequence (`tabindex="-1"`). An out-of-range `index`
23
+ * (e.g. `-1`) leaves all items at `-1`, which a controller can use to express
24
+ * "nothing is currently tabbable".
25
+ *
26
+ * @param index - Position of the item to make tabbable.
27
+ * @param options - Pass `{ focus: true }` to also move DOM focus to that item.
28
+ */
29
+ setActive(index, { focus = false } = {}) {
30
+ const items = this.#getItems();
31
+ items.forEach((item, i) => {
32
+ item.tabIndex = i === index ? 0 : -1;
33
+ });
34
+ if (focus) items[index]?.focus();
35
+ }
36
+ };
37
+ function rovingMove(current, length, delta, wrap) {
38
+ if (length === 0) return -1;
39
+ const next = current + delta;
40
+ return (next + length) % length;
41
+ }
42
+
43
+ // src/controllers/toggle_group_controller.ts
44
+ var ToggleGroupController = class extends Controller {
45
+ static targets = ["item"];
46
+ static values = {
47
+ mode: { type: String, default: "multiple" }
48
+ };
49
+ static actions = ["onKeydown", "toggle"];
50
+ static events = ["change"];
51
+ #roving = new RovingTabindex(() => this.itemTargets);
52
+ /** Establishes the roving entry point (first pressed item, else the first). */
53
+ connect() {
54
+ const firstPressed = this.itemTargets.findIndex((item) => this.#isPressed(item));
55
+ this.#roving.setActive(firstPressed === -1 ? 0 : firstPressed);
56
+ }
57
+ /** Toggles the activated item. Bound via `data-action` (click). */
58
+ toggle(event) {
59
+ this.#toggleIndex(this.itemTargets.indexOf(event.currentTarget));
60
+ }
61
+ /** Arrow/Home/End move focus only; Space/Enter toggle non-button hosts. */
62
+ onKeydown(event) {
63
+ const current = this.itemTargets.indexOf(event.currentTarget);
64
+ if (current === -1) return;
65
+ let next = null;
66
+ switch (event.key) {
67
+ case "ArrowRight":
68
+ case "ArrowDown":
69
+ next = rovingMove(current, this.itemTargets.length, 1);
70
+ break;
71
+ case "ArrowLeft":
72
+ case "ArrowUp":
73
+ next = rovingMove(current, this.itemTargets.length, -1);
74
+ break;
75
+ case "Home":
76
+ next = 0;
77
+ break;
78
+ case "End":
79
+ next = this.itemTargets.length - 1;
80
+ break;
81
+ case " ":
82
+ case "Enter":
83
+ if (event.currentTarget instanceof HTMLButtonElement) return;
84
+ event.preventDefault();
85
+ this.#toggleIndex(current);
86
+ return;
87
+ default:
88
+ return;
89
+ }
90
+ event.preventDefault();
91
+ this.#roving.setActive(next, { focus: true });
92
+ }
93
+ /** Applies the toggle at `index` per the current mode and dispatches `change`. */
94
+ #toggleIndex(index) {
95
+ const item = this.itemTargets[index];
96
+ if (!item) return;
97
+ const willPress = !this.#isPressed(item);
98
+ if (this.modeValue === "single") {
99
+ this.itemTargets.forEach((other, i) => {
100
+ this.#setPressed(other, i === index && willPress);
101
+ });
102
+ } else {
103
+ this.#setPressed(item, willPress);
104
+ }
105
+ this.#roving.setActive(index);
106
+ this.dispatch("change", {
107
+ detail: { value: this.#itemValue(item), pressed: willPress, values: this.#pressedValues() }
108
+ });
109
+ }
110
+ /** Whether an item is currently pressed. */
111
+ #isPressed(item) {
112
+ return item.getAttribute("aria-pressed") === "true";
113
+ }
114
+ /** Reflects the pressed state on `aria-pressed`. */
115
+ #setPressed(item, pressed) {
116
+ item.setAttribute("aria-pressed", pressed ? "true" : "false");
117
+ }
118
+ /** The `data-value` of every currently pressed item. */
119
+ #pressedValues() {
120
+ return this.itemTargets.filter((item) => this.#isPressed(item)).map((item) => this.#itemValue(item));
121
+ }
122
+ /** An item's value (`data-value`, defaulting to empty). */
123
+ #itemValue(item) {
124
+ return item.getAttribute("data-value") ?? "";
125
+ }
126
+ };
127
+
128
+ export { ToggleGroupController };
129
+ //# sourceMappingURL=toggle_group_controller.js.map
130
+ //# sourceMappingURL=toggle_group_controller.js.map
@@ -0,0 +1,113 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/toolbar_controller.ts
4
+
5
+ // src/utils/roving_tabindex.ts
6
+ var RovingTabindex = class {
7
+ /** Returns the current ordered item elements; called on every operation. */
8
+ #getItems;
9
+ /**
10
+ * @param getItems - Returns the current ordered item elements. Called on every
11
+ * operation so the live target list is always used.
12
+ */
13
+ constructor(getItems) {
14
+ this.#getItems = getItems;
15
+ }
16
+ /** Index of the currently tabbable item (`tabindex="0"`), or `-1` if none. */
17
+ get activeIndex() {
18
+ return this.#getItems().findIndex((item) => item.tabIndex === 0);
19
+ }
20
+ /**
21
+ * Makes exactly the item at `index` tabbable (`tabindex="0"`) and removes every
22
+ * other item from the Tab sequence (`tabindex="-1"`). An out-of-range `index`
23
+ * (e.g. `-1`) leaves all items at `-1`, which a controller can use to express
24
+ * "nothing is currently tabbable".
25
+ *
26
+ * @param index - Position of the item to make tabbable.
27
+ * @param options - Pass `{ focus: true }` to also move DOM focus to that item.
28
+ */
29
+ setActive(index, { focus = false } = {}) {
30
+ const items = this.#getItems();
31
+ items.forEach((item, i) => {
32
+ item.tabIndex = i === index ? 0 : -1;
33
+ });
34
+ if (focus) items[index]?.focus();
35
+ }
36
+ };
37
+ function rovingMove(current, length, delta, wrap) {
38
+ if (length === 0) return -1;
39
+ const next = current + delta;
40
+ if (wrap === "wrap") return (next + length) % length;
41
+ return Math.min(length - 1, Math.max(0, next));
42
+ }
43
+
44
+ // src/controllers/toolbar_controller.ts
45
+ var ToolbarController = class extends Controller {
46
+ static targets = ["control"];
47
+ static values = {
48
+ orientation: { type: String, default: "horizontal" },
49
+ wrap: { type: Boolean, default: true }
50
+ };
51
+ static actions = ["onKeydown"];
52
+ #roving = new RovingTabindex(() => this.controlTargets);
53
+ /**
54
+ * Establishes the single tab stop: keep an existing one if it is still
55
+ * navigable, else the first navigable control. Disabled / hidden controls are
56
+ * skipped so the toolbar's lone Tab stop never lands on an unfocusable element
57
+ * (which would make the whole group unreachable on Tab re-entry).
58
+ */
59
+ connect() {
60
+ const active = this.#roving.activeIndex;
61
+ const activeEl = active === -1 ? null : this.controlTargets[active];
62
+ if (activeEl && this.#isNavigable(activeEl)) {
63
+ this.#roving.setActive(active);
64
+ return;
65
+ }
66
+ const first = this.#navigableControls[0];
67
+ this.#roving.setActive(first ? this.controlTargets.indexOf(first) : -1);
68
+ }
69
+ /** Arrow/Home/End move focus and the single tab stop. Bound via `data-action`. */
70
+ onKeydown(event) {
71
+ const navigable = this.#navigableControls;
72
+ const current = navigable.indexOf(event.currentTarget);
73
+ if (current === -1) return;
74
+ const length = navigable.length;
75
+ const wrap = this.wrapValue ? "wrap" : "clamp";
76
+ const vertical = this.orientationValue === "vertical";
77
+ const forwardKey = vertical ? "ArrowDown" : "ArrowRight";
78
+ const backwardKey = vertical ? "ArrowUp" : "ArrowLeft";
79
+ let next;
80
+ if (event.key === forwardKey) {
81
+ next = rovingMove(current, length, 1, wrap);
82
+ } else if (event.key === backwardKey) {
83
+ next = rovingMove(current, length, -1, wrap);
84
+ } else if (event.key === "Home") {
85
+ next = 0;
86
+ } else if (event.key === "End") {
87
+ next = length - 1;
88
+ } else {
89
+ return;
90
+ }
91
+ event.preventDefault();
92
+ const target = navigable[next];
93
+ if (target) this.#roving.setActive(this.controlTargets.indexOf(target), { focus: true });
94
+ }
95
+ /** Controls eligible for the roving tab stop (excludes disabled / hidden). */
96
+ get #navigableControls() {
97
+ return this.controlTargets.filter((control) => this.#isNavigable(control));
98
+ }
99
+ /**
100
+ * A control can hold the tab stop unless it is `hidden`, `aria-disabled`, or a
101
+ * natively `disabled` form control. CSS-only visibility cannot be detected
102
+ * headlessly and stays the consumer's responsibility.
103
+ */
104
+ #isNavigable(control) {
105
+ if (control.hasAttribute("hidden")) return false;
106
+ if (control.getAttribute("aria-disabled") === "true") return false;
107
+ return !control.disabled;
108
+ }
109
+ };
110
+
111
+ export { ToolbarController };
112
+ //# sourceMappingURL=toolbar_controller.js.map
113
+ //# sourceMappingURL=toolbar_controller.js.map