stimeo-ui 0.2.0 → 0.2.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 (124) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/controllers/accordion_controller.d.ts +3 -2
  3. package/dist/controllers/accordion_controller.js.map +1 -1
  4. package/dist/controllers/alert_dialog_controller.d.ts +64 -0
  5. package/dist/controllers/alert_dialog_controller.js +318 -0
  6. package/dist/controllers/alert_dialog_controller.js.map +1 -0
  7. package/dist/controllers/carousel_controller.d.ts +100 -0
  8. package/dist/controllers/carousel_controller.js +272 -0
  9. package/dist/controllers/carousel_controller.js.map +1 -0
  10. package/dist/controllers/clipboard_controller.d.ts +72 -0
  11. package/dist/controllers/clipboard_controller.js +144 -0
  12. package/dist/controllers/clipboard_controller.js.map +1 -0
  13. package/dist/controllers/collapsible_controller.d.ts +70 -0
  14. package/dist/controllers/collapsible_controller.js +327 -0
  15. package/dist/controllers/collapsible_controller.js.map +1 -0
  16. package/dist/controllers/color_picker_controller.d.ts +76 -0
  17. package/dist/controllers/color_picker_controller.js +213 -0
  18. package/dist/controllers/color_picker_controller.js.map +1 -0
  19. package/dist/controllers/count_up_controller.js +8 -1
  20. package/dist/controllers/count_up_controller.js.map +1 -1
  21. package/dist/controllers/currency_input_controller.d.ts +72 -0
  22. package/dist/controllers/currency_input_controller.js +147 -0
  23. package/dist/controllers/currency_input_controller.js.map +1 -0
  24. package/dist/controllers/data_grid_controller.d.ts +71 -0
  25. package/dist/controllers/data_grid_controller.js +168 -0
  26. package/dist/controllers/data_grid_controller.js.map +1 -0
  27. package/dist/controllers/date_range_picker_controller.d.ts +87 -0
  28. package/dist/controllers/date_range_picker_controller.js +417 -0
  29. package/dist/controllers/date_range_picker_controller.js.map +1 -0
  30. package/dist/controllers/dismissible_controller.d.ts +57 -0
  31. package/dist/controllers/dismissible_controller.js +117 -0
  32. package/dist/controllers/dismissible_controller.js.map +1 -0
  33. package/dist/controllers/drawer_controller.d.ts +96 -0
  34. package/dist/controllers/drawer_controller.js +630 -0
  35. package/dist/controllers/drawer_controller.js.map +1 -0
  36. package/dist/controllers/editable_controller.d.ts +73 -0
  37. package/dist/controllers/editable_controller.js +168 -0
  38. package/dist/controllers/editable_controller.js.map +1 -0
  39. package/dist/controllers/file_dropzone_controller.d.ts +92 -0
  40. package/dist/controllers/file_dropzone_controller.js +165 -0
  41. package/dist/controllers/file_dropzone_controller.js.map +1 -0
  42. package/dist/controllers/filter_controller.d.ts +60 -0
  43. package/dist/controllers/filter_controller.js +86 -0
  44. package/dist/controllers/filter_controller.js.map +1 -0
  45. package/dist/controllers/flash_controller.js +36 -5
  46. package/dist/controllers/flash_controller.js.map +1 -1
  47. package/dist/controllers/highlight_controller.js +6 -4
  48. package/dist/controllers/highlight_controller.js.map +1 -1
  49. package/dist/controllers/intersection_controller.js +41 -18
  50. package/dist/controllers/intersection_controller.js.map +1 -1
  51. package/dist/controllers/lazy_frame_controller.js +33 -11
  52. package/dist/controllers/lazy_frame_controller.js.map +1 -1
  53. package/dist/controllers/masonry_controller.d.ts +54 -0
  54. package/dist/controllers/masonry_controller.js +142 -0
  55. package/dist/controllers/masonry_controller.js.map +1 -0
  56. package/dist/controllers/menubar_controller.d.ts +62 -0
  57. package/dist/controllers/menubar_controller.js +433 -0
  58. package/dist/controllers/menubar_controller.js.map +1 -0
  59. package/dist/controllers/multi_select_controller.d.ts +115 -0
  60. package/dist/controllers/multi_select_controller.js +472 -0
  61. package/dist/controllers/multi_select_controller.js.map +1 -0
  62. package/dist/controllers/navigation_menu_controller.d.ts +99 -0
  63. package/dist/controllers/navigation_menu_controller.js +384 -0
  64. package/dist/controllers/navigation_menu_controller.js.map +1 -0
  65. package/dist/controllers/overflow_indicator_controller.d.ts +12 -4
  66. package/dist/controllers/overflow_indicator_controller.js +178 -27
  67. package/dist/controllers/overflow_indicator_controller.js.map +1 -1
  68. package/dist/controllers/password_reveal_controller.d.ts +49 -0
  69. package/dist/controllers/password_reveal_controller.js +117 -0
  70. package/dist/controllers/password_reveal_controller.js.map +1 -0
  71. package/dist/controllers/range_slider_controller.d.ts +87 -0
  72. package/dist/controllers/range_slider_controller.js +166 -0
  73. package/dist/controllers/range_slider_controller.js.map +1 -0
  74. package/dist/controllers/read_more_controller.d.ts +52 -0
  75. package/dist/controllers/read_more_controller.js +194 -0
  76. package/dist/controllers/read_more_controller.js.map +1 -0
  77. package/dist/controllers/scroll_area_controller.js +15 -2
  78. package/dist/controllers/scroll_area_controller.js.map +1 -1
  79. package/dist/controllers/scroll_restore_controller.d.ts +54 -0
  80. package/dist/controllers/scroll_restore_controller.js +93 -0
  81. package/dist/controllers/scroll_restore_controller.js.map +1 -0
  82. package/dist/controllers/scroll_visibility_controller.js +8 -4
  83. package/dist/controllers/scroll_visibility_controller.js.map +1 -1
  84. package/dist/controllers/scrollspy_controller.js +33 -11
  85. package/dist/controllers/scrollspy_controller.js.map +1 -1
  86. package/dist/controllers/separator_controller.d.ts +54 -0
  87. package/dist/controllers/separator_controller.js +87 -0
  88. package/dist/controllers/separator_controller.js.map +1 -0
  89. package/dist/controllers/sidebar_controller.d.ts +107 -0
  90. package/dist/controllers/sidebar_controller.js +761 -0
  91. package/dist/controllers/sidebar_controller.js.map +1 -0
  92. package/dist/controllers/stepper_controller.d.ts +4 -0
  93. package/dist/controllers/stepper_controller.js +28 -12
  94. package/dist/controllers/stepper_controller.js.map +1 -1
  95. package/dist/controllers/stick_to_bottom_controller.js +8 -4
  96. package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
  97. package/dist/controllers/sticky_observer_controller.d.ts +17 -4
  98. package/dist/controllers/sticky_observer_controller.js +88 -20
  99. package/dist/controllers/sticky_observer_controller.js.map +1 -1
  100. package/dist/controllers/tabs_controller.d.ts +3 -2
  101. package/dist/controllers/tabs_controller.js.map +1 -1
  102. package/dist/controllers/tags_input_controller.d.ts +91 -0
  103. package/dist/controllers/tags_input_controller.js +275 -0
  104. package/dist/controllers/tags_input_controller.js.map +1 -0
  105. package/dist/controllers/theme_controller.js +20 -10
  106. package/dist/controllers/theme_controller.js.map +1 -1
  107. package/dist/controllers/time_picker_controller.d.ts +67 -0
  108. package/dist/controllers/time_picker_controller.js +212 -0
  109. package/dist/controllers/time_picker_controller.js.map +1 -0
  110. package/dist/controllers/toast_controller.d.ts +1 -9
  111. package/dist/controllers/toast_controller.js +36 -9
  112. package/dist/controllers/toast_controller.js.map +1 -1
  113. package/dist/controllers/transition_controller.d.ts +14 -8
  114. package/dist/controllers/transition_controller.js +153 -38
  115. package/dist/controllers/transition_controller.js.map +1 -1
  116. package/dist/controllers/tree_view_controller.d.ts +54 -0
  117. package/dist/controllers/tree_view_controller.js +275 -0
  118. package/dist/controllers/tree_view_controller.js.map +1 -0
  119. package/dist/index.d.ts +27 -1818
  120. package/dist/index.js +811 -295
  121. package/dist/index.js.map +1 -1
  122. package/dist/inspector/examples.json +2 -2
  123. package/dist/inspector/manifest.json +15 -3
  124. package/package.json +1 -1
@@ -0,0 +1,100 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ /**
4
+ * Headless, accessible **Carousel** (slideshow) behavior.
5
+ *
6
+ * Markup contract (identifier: `stimeo--carousel`):
7
+ * <section data-controller="stimeo--carousel" aria-roledescription="carousel"
8
+ * aria-label="Featured"
9
+ * data-stimeo--carousel-autoplay-value="false"
10
+ * data-stimeo--carousel-interval-value="5000"
11
+ * data-stimeo--carousel-loop-value="true"
12
+ * data-action="mouseenter->stimeo--carousel#pause
13
+ * mouseleave->stimeo--carousel#resume
14
+ * focusin->stimeo--carousel#pause
15
+ * focusout->stimeo--carousel#resume">
16
+ * <button data-stimeo--carousel-target="playToggle"
17
+ * data-action="stimeo--carousel#togglePlay">…</button>
18
+ * <div data-stimeo--carousel-target="viewport">
19
+ * <div role="tabpanel" data-stimeo--carousel-target="slide">…</div>
20
+ * <div role="tabpanel" data-stimeo--carousel-target="slide" hidden>…</div>
21
+ * </div>
22
+ * <button data-stimeo--carousel-target="prev" data-action="stimeo--carousel#prev">‹</button>
23
+ * <button data-stimeo--carousel-target="next" data-action="stimeo--carousel#next">›</button>
24
+ * <div role="tablist">
25
+ * <button role="tab" data-stimeo--carousel-target="picker"
26
+ * data-action="stimeo--carousel#goto
27
+ * keydown->stimeo--carousel#onPickerKeydown"></button>
28
+ * </div>
29
+ * </section>
30
+ *
31
+ * Implements the WAI-ARIA APG **Carousel** (tabbed) pattern. The current slide is
32
+ * exposed through `data-state` (`active`/`inactive`) and the `hidden` attribute on
33
+ * inactive slides (removing them from focus order); the matching picker carries
34
+ * `aria-selected` and the single roving `tabindex`. The play/pause toggle's
35
+ * `aria-pressed` mirrors the autoplay state.
36
+ *
37
+ * @remarks
38
+ * Behavior only — transitions, layout, and visuals are the consumer's CSS.
39
+ * Autoplay honors WCAG 2.2.2: it suspends while the pointer is over the carousel
40
+ * and **hard-stops** when keyboard focus enters (it does not silently resume on
41
+ * focus out — the user must press play), so motion never surprises a keyboard
42
+ * user. The interval is cleared on `disconnect()` (Turbo navigation included).
43
+ * Picker arrow keys move focus only (manual activation); slide changes never steal
44
+ * focus from the control the user operated.
45
+ */
46
+ declare class CarouselController extends Controller<HTMLElement> {
47
+ #private;
48
+ static targets: string[];
49
+ static values: {
50
+ autoplay: {
51
+ type: BooleanConstructor;
52
+ default: boolean;
53
+ };
54
+ interval: {
55
+ type: NumberConstructor;
56
+ default: number;
57
+ };
58
+ loop: {
59
+ type: BooleanConstructor;
60
+ default: boolean;
61
+ };
62
+ };
63
+ static actions: readonly ["goto", "next", "onPickerKeydown", "pause", "prev", "resume", "togglePlay"];
64
+ static events: readonly ["change", "pause", "play"];
65
+ readonly slideTargets: HTMLElement[];
66
+ readonly pickerTargets: HTMLElement[];
67
+ readonly playToggleTarget: HTMLElement;
68
+ readonly hasPlayToggleTarget: boolean;
69
+ autoplayValue: boolean;
70
+ intervalValue: number;
71
+ loopValue: boolean;
72
+ /** Renders the initial slide and starts autoplay when requested. */
73
+ connect(): void;
74
+ /** Clears the autoplay interval so it never fires after teardown. */
75
+ disconnect(): void;
76
+ /** Advances to the next slide. Bound via `data-action`. */
77
+ next(): void;
78
+ /** Returns to the previous slide. Bound via `data-action`. */
79
+ prev(): void;
80
+ /** Jumps to the slide whose picker was activated (click / Enter / Space). */
81
+ goto(event: Event): void;
82
+ /** Toggles autoplay on the user's explicit request and syncs the timer. */
83
+ togglePlay(): void;
84
+ /**
85
+ * Suspends autoplay. Hover (`mouseenter`) is a temporary suspension that resumes
86
+ * on leave; keyboard focus (`focusin`) is a hard stop that turns autoplay off so
87
+ * it cannot resume without an explicit play (WCAG 2.2.2).
88
+ */
89
+ pause(event?: Event): void;
90
+ /**
91
+ * Lifts a hover suspension (`mouseleave`) and resumes autoplay if it is still
92
+ * on. A `focusout` does nothing here: the focus pause was a hard stop, so the
93
+ * user must press play to restart.
94
+ */
95
+ resume(event?: Event): void;
96
+ /** Picker roving: arrows move focus only; Home/End activate first/last slide. */
97
+ onPickerKeydown(event: KeyboardEvent): void;
98
+ }
99
+
100
+ export { CarouselController };
@@ -0,0 +1,272 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/carousel_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/utils/safe_timeout.ts
44
+ var TimerRegistry = class {
45
+ /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
46
+ ids = /* @__PURE__ */ new Set();
47
+ /**
48
+ * Cancels a single tracked timer.
49
+ *
50
+ * No-ops if the id is unknown (already cleared, fired, or never owned by this
51
+ * registry), so callers can clear defensively without guarding.
52
+ */
53
+ clear(id) {
54
+ if (this.ids.delete(id)) {
55
+ this.cancel(id);
56
+ }
57
+ }
58
+ /**
59
+ * Cancels every tracked timer. Call this from a controller's `disconnect()`
60
+ * to guarantee no timer outlives the element.
61
+ */
62
+ clearAll() {
63
+ for (const id of this.ids) {
64
+ this.cancel(id);
65
+ }
66
+ this.ids.clear();
67
+ }
68
+ /** Number of timers currently tracked (pending). */
69
+ get size() {
70
+ return this.ids.size;
71
+ }
72
+ };
73
+ var SafeInterval = class extends TimerRegistry {
74
+ /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */
75
+ set(callback, delay) {
76
+ const id = this.schedule(callback, delay);
77
+ this.ids.add(id);
78
+ return id;
79
+ }
80
+ schedule(callback, delay) {
81
+ return window.setInterval(callback, delay);
82
+ }
83
+ cancel(id) {
84
+ window.clearInterval(id);
85
+ }
86
+ };
87
+
88
+ // src/controllers/carousel_controller.ts
89
+ var CarouselController = class extends Controller {
90
+ static targets = ["slide", "viewport", "prev", "next", "picker", "playToggle"];
91
+ static values = {
92
+ autoplay: { type: Boolean, default: false },
93
+ interval: { type: Number, default: 5e3 },
94
+ loop: { type: Boolean, default: true }
95
+ };
96
+ static actions = [
97
+ "goto",
98
+ "next",
99
+ "onPickerKeydown",
100
+ "pause",
101
+ "prev",
102
+ "resume",
103
+ "togglePlay"
104
+ ];
105
+ static events = ["change", "pause", "play"];
106
+ #roving = new RovingTabindex(() => this.pickerTargets);
107
+ #intervals = new SafeInterval();
108
+ /** Index of the visible slide. */
109
+ #index = 0;
110
+ /** User intent to autoplay (toggled by the play button / focus hard-stop). */
111
+ #playing = false;
112
+ /** Pointer is hovering the carousel: a temporary, auto-resuming suspension. */
113
+ #pointerPaused = false;
114
+ /** Id of the live autoplay interval, or null when stopped. */
115
+ #timerId = null;
116
+ /** Renders the initial slide and starts autoplay when requested. */
117
+ connect() {
118
+ const preselected = this.pickerTargets.findIndex(
119
+ (picker) => picker.getAttribute("aria-selected") === "true"
120
+ );
121
+ this.#index = preselected === -1 ? 0 : preselected;
122
+ this.#playing = this.#initialPlaying();
123
+ this.#render({ focus: false });
124
+ this.#syncTimer();
125
+ }
126
+ /**
127
+ * Resolves the starting autoplay intent. The play toggle's `aria-pressed` is the
128
+ * source of truth **when present**, so a Turbo Drive cache restore / morph that
129
+ * re-runs `connect()` against existing DOM does not silently resume autoplay the
130
+ * user had stopped (e.g. by focusing into the carousel). Only when no toggle
131
+ * carries `aria-pressed` does it fall back to the declarative `autoplay` value.
132
+ */
133
+ #initialPlaying() {
134
+ if (this.hasPlayToggleTarget && this.playToggleTarget.hasAttribute("aria-pressed")) {
135
+ return this.playToggleTarget.getAttribute("aria-pressed") === "true";
136
+ }
137
+ return this.autoplayValue;
138
+ }
139
+ /** Clears the autoplay interval so it never fires after teardown. */
140
+ disconnect() {
141
+ this.#intervals.clearAll();
142
+ this.#timerId = null;
143
+ }
144
+ /** Advances to the next slide. Bound via `data-action`. */
145
+ next() {
146
+ this.#select(this.#step(1), { focus: false });
147
+ }
148
+ /** Returns to the previous slide. Bound via `data-action`. */
149
+ prev() {
150
+ this.#select(this.#step(-1), { focus: false });
151
+ }
152
+ /** Jumps to the slide whose picker was activated (click / Enter / Space). */
153
+ goto(event) {
154
+ const target = event.currentTarget;
155
+ const index = this.pickerTargets.indexOf(target);
156
+ if (index !== -1) this.#select(index, { focus: false });
157
+ }
158
+ /** Toggles autoplay on the user's explicit request and syncs the timer. */
159
+ togglePlay() {
160
+ this.#playing = !this.#playing;
161
+ this.#syncTimer();
162
+ }
163
+ /**
164
+ * Suspends autoplay. Hover (`mouseenter`) is a temporary suspension that resumes
165
+ * on leave; keyboard focus (`focusin`) is a hard stop that turns autoplay off so
166
+ * it cannot resume without an explicit play (WCAG 2.2.2).
167
+ */
168
+ pause(event) {
169
+ if (event?.type.startsWith("focus")) {
170
+ this.#playing = false;
171
+ } else {
172
+ this.#pointerPaused = true;
173
+ }
174
+ this.#syncTimer();
175
+ }
176
+ /**
177
+ * Lifts a hover suspension (`mouseleave`) and resumes autoplay if it is still
178
+ * on. A `focusout` does nothing here: the focus pause was a hard stop, so the
179
+ * user must press play to restart.
180
+ */
181
+ resume(event) {
182
+ if (event?.type.startsWith("focus")) return;
183
+ this.#pointerPaused = false;
184
+ this.#syncTimer();
185
+ }
186
+ /** Picker roving: arrows move focus only; Home/End activate first/last slide. */
187
+ onPickerKeydown(event) {
188
+ const current = this.pickerTargets.indexOf(event.currentTarget);
189
+ if (current === -1) return;
190
+ const length = this.pickerTargets.length;
191
+ switch (event.key) {
192
+ case "ArrowRight":
193
+ case "ArrowDown":
194
+ event.preventDefault();
195
+ this.#roving.setActive(rovingMove(current, length, 1), { focus: true });
196
+ return;
197
+ case "ArrowLeft":
198
+ case "ArrowUp":
199
+ event.preventDefault();
200
+ this.#roving.setActive(rovingMove(current, length, -1), { focus: true });
201
+ return;
202
+ case "Home":
203
+ event.preventDefault();
204
+ this.#select(0, { focus: true });
205
+ return;
206
+ case "End":
207
+ event.preventDefault();
208
+ this.#select(length - 1, { focus: true });
209
+ return;
210
+ }
211
+ }
212
+ /** Resolves the index one step away from the current one, honoring `loop`. */
213
+ #step(delta) {
214
+ const total = this.slideTargets.length;
215
+ if (total === 0) return 0;
216
+ const next = this.#index + delta;
217
+ if (this.loopValue) return (next + total) % total;
218
+ return Math.min(total - 1, Math.max(0, next));
219
+ }
220
+ /**
221
+ * Changes the active slide, updates state hooks, and emits `change` — but only
222
+ * when the index actually changes, so a `next`/`prev` clamped at the end (or an
223
+ * autoplay tick at a non-looping boundary) re-renders without a spurious event
224
+ * (matching the "emit on real change" policy of flash/masonry/bulk-select).
225
+ */
226
+ #select(index, { focus }) {
227
+ const changed = index !== this.#index;
228
+ this.#index = index;
229
+ this.#render({ focus });
230
+ this.#syncTimer();
231
+ if (changed) this.dispatch("change", { detail: { index, total: this.slideTargets.length } });
232
+ }
233
+ /** Reflects `this.#index` onto slides and pickers (state hooks + roving). */
234
+ #render({ focus }) {
235
+ this.slideTargets.forEach((slide, i) => {
236
+ const active = i === this.#index;
237
+ slide.setAttribute("data-state", active ? "active" : "inactive");
238
+ slide.hidden = !active;
239
+ });
240
+ this.pickerTargets.forEach((picker, i) => {
241
+ picker.setAttribute("aria-selected", i === this.#index ? "true" : "false");
242
+ });
243
+ this.#roving.setActive(this.#index, { focus });
244
+ }
245
+ /**
246
+ * Drives the autoplay interval toward the desired state. Autoplay should run
247
+ * only when the user wants it (`playing`), the pointer is not hovering, and more
248
+ * than one slide exists. Transitions emit `play`/`pause` and keep the toggle's
249
+ * `aria-pressed` in sync.
250
+ */
251
+ #syncTimer() {
252
+ if (!this.loopValue && this.#index >= this.slideTargets.length - 1) {
253
+ this.#playing = false;
254
+ }
255
+ const shouldRun = this.#playing && !this.#pointerPaused && this.slideTargets.length > 1;
256
+ if (shouldRun && this.#timerId === null) {
257
+ this.#timerId = this.#intervals.set(() => this.next(), this.intervalValue);
258
+ this.dispatch("play");
259
+ } else if (!shouldRun && this.#timerId !== null) {
260
+ this.#intervals.clear(this.#timerId);
261
+ this.#timerId = null;
262
+ this.dispatch("pause");
263
+ }
264
+ if (this.hasPlayToggleTarget) {
265
+ this.playToggleTarget.setAttribute("aria-pressed", this.#playing ? "true" : "false");
266
+ }
267
+ }
268
+ };
269
+
270
+ export { CarouselController };
271
+ //# sourceMappingURL=carousel_controller.js.map
272
+ //# sourceMappingURL=carousel_controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utils/roving_tabindex.ts","../../src/utils/safe_timeout.ts","../../src/controllers/carousel_controller.ts"],"names":[],"mappings":";;;;;AAqBO,IAAM,iBAAN,MAAqB;AAAA;AAAA,EAEjB,SAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMT,YAAY,QAAA,EAA+B;AACzC,IAAA,IAAA,CAAK,SAAA,GAAY,QAAA;AAAA,EACnB;AAAA;AAAA,EAGA,IAAI,WAAA,GAAsB;AACxB,IAAA,OAAO,IAAA,CAAK,WAAU,CAAE,SAAA,CAAU,CAAC,IAAA,KAAS,IAAA,CAAK,aAAa,CAAC,CAAA;AAAA,EACjE;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAWA,UAAU,KAAA,EAAe,EAAE,QAAQ,KAAA,EAAM,GAAyB,EAAC,EAAS;AAC1E,IAAA,MAAM,KAAA,GAAQ,KAAK,SAAA,EAAU;AAC7B,IAAA,KAAA,CAAM,OAAA,CAAQ,CAAC,IAAA,EAAM,CAAA,KAAM;AACzB,MAAA,IAAA,CAAK,QAAA,GAAW,CAAA,KAAM,KAAA,GAAQ,CAAA,GAAI,EAAA;AAAA,IACpC,CAAC,CAAA;AACD,IAAA,IAAI,KAAA,EAAO,KAAA,CAAM,KAAK,CAAA,EAAG,KAAA,EAAM;AAAA,EACjC;AACF,CAAA;AAkBO,SAAS,UAAA,CACd,OAAA,EACA,MAAA,EACA,KAAA,EACA,IAAA,EACQ;AACR,EAAA,IAAI,MAAA,KAAW,GAAG,OAAO,EAAA;AACzB,EAAA,MAAM,OAAO,OAAA,GAAU,KAAA;AACvB,EAAqB,OAAA,CAAQ,IAAA,GAAO,MAAA,IAAU,MAAA;AAEhD;;;AC1DA,IAAe,gBAAf,MAA6B;AAAA;AAAA,EAER,GAAA,uBAAU,GAAA,EAAY;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAczC,MAAM,EAAA,EAAkB;AACtB,IAAA,IAAI,IAAA,CAAK,GAAA,CAAI,MAAA,CAAO,EAAE,CAAA,EAAG;AACvB,MAAA,IAAA,CAAK,OAAO,EAAE,CAAA;AAAA,IAChB;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,QAAA,GAAiB;AACf,IAAA,KAAA,MAAW,EAAA,IAAM,KAAK,GAAA,EAAK;AACzB,MAAA,IAAA,CAAK,OAAO,EAAE,CAAA;AAAA,IAChB;AACA,IAAA,IAAA,CAAK,IAAI,KAAA,EAAM;AAAA,EACjB;AAAA;AAAA,EAGA,IAAI,IAAA,GAAe;AACjB,IAAA,OAAO,KAAK,GAAA,CAAI,IAAA;AAAA,EAClB;AACF,CAAA;AA8DO,IAAM,YAAA,GAAN,cAA2B,aAAA,CAAc;AAAA;AAAA,EAE9C,GAAA,CAAI,UAAsB,KAAA,EAAuB;AAC/C,IAAA,MAAM,EAAA,GAAK,IAAA,CAAK,QAAA,CAAS,QAAA,EAAU,KAAK,CAAA;AACxC,IAAA,IAAA,CAAK,GAAA,CAAI,IAAI,EAAE,CAAA;AACf,IAAA,OAAO,EAAA;AAAA,EACT;AAAA,EAEU,QAAA,CAAS,UAAsB,KAAA,EAAuB;AAC9D,IAAA,OAAO,MAAA,CAAO,WAAA,CAAY,QAAA,EAAU,KAAK,CAAA;AAAA,EAC3C;AAAA,EAEU,OAAO,EAAA,EAAkB;AACjC,IAAA,MAAA,CAAO,cAAc,EAAE,CAAA;AAAA,EACzB;AACF,CAAA;;;AC3FO,IAAM,kBAAA,GAAN,cAAiC,UAAA,CAAwB;AAAA,EAC9D,OAAgB,UAAU,CAAC,OAAA,EAAS,YAAY,MAAA,EAAQ,MAAA,EAAQ,UAAU,YAAY,CAAA;AAAA,EACtF,OAAgB,MAAA,GAAS;AAAA,IACvB,QAAA,EAAU,EAAE,IAAA,EAAM,OAAA,EAAS,SAAS,KAAA,EAAM;AAAA,IAC1C,QAAA,EAAU,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,GAAA,EAAK;AAAA,IACxC,IAAA,EAAM,EAAE,IAAA,EAAM,OAAA,EAAS,SAAS,IAAA;AAAK,GACvC;AAAA,EACA,OAAO,OAAA,GAAU;AAAA,IACf,MAAA;AAAA,IACA,MAAA;AAAA,IACA,iBAAA;AAAA,IACA,OAAA;AAAA,IACA,MAAA;AAAA,IACA,QAAA;AAAA,IACA;AAAA,GACF;AAAA,EACA,OAAO,MAAA,GAAS,CAAC,QAAA,EAAU,SAAS,MAAM,CAAA;AAAA,EAUjC,OAAA,GAAU,IAAI,cAAA,CAAe,MAAM,KAAK,aAAa,CAAA;AAAA,EACrD,UAAA,GAAa,IAAI,YAAA,EAAa;AAAA;AAAA,EAEvC,MAAA,GAAS,CAAA;AAAA;AAAA,EAET,QAAA,GAAW,KAAA;AAAA;AAAA,EAEX,cAAA,GAAiB,KAAA;AAAA;AAAA,EAEjB,QAAA,GAA0B,IAAA;AAAA;AAAA,EAGjB,OAAA,GAAgB;AACvB,IAAA,MAAM,WAAA,GAAc,KAAK,aAAA,CAAc,SAAA;AAAA,MACrC,CAAC,MAAA,KAAW,MAAA,CAAO,YAAA,CAAa,eAAe,CAAA,KAAM;AAAA,KACvD;AACA,IAAA,IAAA,CAAK,MAAA,GAAS,WAAA,KAAgB,EAAA,GAAK,CAAA,GAAI,WAAA;AACvC,IAAA,IAAA,CAAK,QAAA,GAAW,KAAK,eAAA,EAAgB;AACrC,IAAA,IAAA,CAAK,OAAA,CAAQ,EAAE,KAAA,EAAO,KAAA,EAAO,CAAA;AAC7B,IAAA,IAAA,CAAK,UAAA,EAAW;AAAA,EAClB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,eAAA,GAA2B;AACzB,IAAA,IAAI,KAAK,mBAAA,IAAuB,IAAA,CAAK,gBAAA,CAAiB,YAAA,CAAa,cAAc,CAAA,EAAG;AAClF,MAAA,OAAO,IAAA,CAAK,gBAAA,CAAiB,YAAA,CAAa,cAAc,CAAA,KAAM,MAAA;AAAA,IAChE;AACA,IAAA,OAAO,IAAA,CAAK,aAAA;AAAA,EACd;AAAA;AAAA,EAGS,UAAA,GAAmB;AAC1B,IAAA,IAAA,CAAK,WAAW,QAAA,EAAS;AACzB,IAAA,IAAA,CAAK,QAAA,GAAW,IAAA;AAAA,EAClB;AAAA;AAAA,EAGA,IAAA,GAAa;AACX,IAAA,IAAA,CAAK,OAAA,CAAQ,KAAK,KAAA,CAAM,CAAC,GAAG,EAAE,KAAA,EAAO,OAAO,CAAA;AAAA,EAC9C;AAAA;AAAA,EAGA,IAAA,GAAa;AACX,IAAA,IAAA,CAAK,OAAA,CAAQ,KAAK,KAAA,CAAM,EAAE,GAAG,EAAE,KAAA,EAAO,OAAO,CAAA;AAAA,EAC/C;AAAA;AAAA,EAGA,KAAK,KAAA,EAAoB;AACvB,IAAA,MAAM,SAAS,KAAA,CAAM,aAAA;AACrB,IAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,aAAA,CAAc,OAAA,CAAQ,MAAM,CAAA;AAC/C,IAAA,IAAI,KAAA,KAAU,IAAI,IAAA,CAAK,OAAA,CAAQ,OAAO,EAAE,KAAA,EAAO,OAAO,CAAA;AAAA,EACxD;AAAA;AAAA,EAGA,UAAA,GAAmB;AACjB,IAAA,IAAA,CAAK,QAAA,GAAW,CAAC,IAAA,CAAK,QAAA;AACtB,IAAA,IAAA,CAAK,UAAA,EAAW;AAAA,EAClB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAM,KAAA,EAAqB;AACzB,IAAA,IAAI,KAAA,EAAO,IAAA,CAAK,UAAA,CAAW,OAAO,CAAA,EAAG;AACnC,MAAA,IAAA,CAAK,QAAA,GAAW,KAAA;AAAA,IAClB,CAAA,MAAO;AACL,MAAA,IAAA,CAAK,cAAA,GAAiB,IAAA;AAAA,IACxB;AACA,IAAA,IAAA,CAAK,UAAA,EAAW;AAAA,EAClB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,OAAO,KAAA,EAAqB;AAC1B,IAAA,IAAI,KAAA,EAAO,IAAA,CAAK,UAAA,CAAW,OAAO,CAAA,EAAG;AACrC,IAAA,IAAA,CAAK,cAAA,GAAiB,KAAA;AACtB,IAAA,IAAA,CAAK,UAAA,EAAW;AAAA,EAClB;AAAA;AAAA,EAGA,gBAAgB,KAAA,EAA4B;AAC1C,IAAA,MAAM,OAAA,GAAU,IAAA,CAAK,aAAA,CAAc,OAAA,CAAQ,MAAM,aAA4B,CAAA;AAC7E,IAAA,IAAI,YAAY,EAAA,EAAI;AAEpB,IAAA,MAAM,MAAA,GAAS,KAAK,aAAA,CAAc,MAAA;AAClC,IAAA,QAAQ,MAAM,GAAA;AAAK,MACjB,KAAK,YAAA;AAAA,MACL,KAAK,WAAA;AACH,QAAA,KAAA,CAAM,cAAA,EAAe;AACrB,QAAA,IAAA,CAAK,OAAA,CAAQ,SAAA,CAAU,UAAA,CAAW,OAAA,EAAS,MAAA,EAAQ,CAAS,CAAA,EAAG,EAAE,KAAA,EAAO,IAAA,EAAM,CAAA;AAC9E,QAAA;AAAA,MACF,KAAK,WAAA;AAAA,MACL,KAAK,SAAA;AACH,QAAA,KAAA,CAAM,cAAA,EAAe;AACrB,QAAA,IAAA,CAAK,OAAA,CAAQ,SAAA,CAAU,UAAA,CAAW,OAAA,EAAS,MAAA,EAAQ,EAAU,CAAA,EAAG,EAAE,KAAA,EAAO,IAAA,EAAM,CAAA;AAC/E,QAAA;AAAA,MACF,KAAK,MAAA;AACH,QAAA,KAAA,CAAM,cAAA,EAAe;AACrB,QAAA,IAAA,CAAK,OAAA,CAAQ,CAAA,EAAG,EAAE,KAAA,EAAO,MAAM,CAAA;AAC/B,QAAA;AAAA,MACF,KAAK,KAAA;AACH,QAAA,KAAA,CAAM,cAAA,EAAe;AACrB,QAAA,IAAA,CAAK,QAAQ,MAAA,GAAS,CAAA,EAAG,EAAE,KAAA,EAAO,MAAM,CAAA;AACxC,QAAA;AACF;AACF,EACF;AAAA;AAAA,EAGA,MAAM,KAAA,EAAuB;AAC3B,IAAA,MAAM,KAAA,GAAQ,KAAK,YAAA,CAAa,MAAA;AAChC,IAAA,IAAI,KAAA,KAAU,GAAG,OAAO,CAAA;AACxB,IAAA,MAAM,IAAA,GAAO,KAAK,MAAA,GAAS,KAAA;AAC3B,IAAA,IAAI,IAAA,CAAK,SAAA,EAAW,OAAA,CAAQ,IAAA,GAAO,KAAA,IAAS,KAAA;AAC5C,IAAA,OAAO,IAAA,CAAK,IAAI,KAAA,GAAQ,CAAA,EAAG,KAAK,GAAA,CAAI,CAAA,EAAG,IAAI,CAAC,CAAA;AAAA,EAC9C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,OAAA,CAAQ,KAAA,EAAe,EAAE,KAAA,EAAM,EAA6B;AAC1D,IAAA,MAAM,OAAA,GAAU,UAAU,IAAA,CAAK,MAAA;AAC/B,IAAA,IAAA,CAAK,MAAA,GAAS,KAAA;AACd,IAAA,IAAA,CAAK,OAAA,CAAQ,EAAE,KAAA,EAAO,CAAA;AAGtB,IAAA,IAAA,CAAK,UAAA,EAAW;AAChB,IAAA,IAAI,OAAA,EAAS,IAAA,CAAK,QAAA,CAAS,QAAA,EAAU,EAAE,MAAA,EAAQ,EAAE,KAAA,EAAO,KAAA,EAAO,IAAA,CAAK,YAAA,CAAa,MAAA,IAAU,CAAA;AAAA,EAC7F;AAAA;AAAA,EAGA,OAAA,CAAQ,EAAE,KAAA,EAAM,EAA6B;AAC3C,IAAA,IAAA,CAAK,YAAA,CAAa,OAAA,CAAQ,CAAC,KAAA,EAAO,CAAA,KAAM;AACtC,MAAA,MAAM,MAAA,GAAS,MAAM,IAAA,CAAK,MAAA;AAC1B,MAAA,KAAA,CAAM,YAAA,CAAa,YAAA,EAAc,MAAA,GAAS,QAAA,GAAW,UAAU,CAAA;AAC/D,MAAA,KAAA,CAAM,SAAS,CAAC,MAAA;AAAA,IAClB,CAAC,CAAA;AACD,IAAA,IAAA,CAAK,aAAA,CAAc,OAAA,CAAQ,CAAC,MAAA,EAAQ,CAAA,KAAM;AACxC,MAAA,MAAA,CAAO,aAAa,eAAA,EAAiB,CAAA,KAAM,IAAA,CAAK,MAAA,GAAS,SAAS,OAAO,CAAA;AAAA,IAC3E,CAAC,CAAA;AACD,IAAA,IAAA,CAAK,QAAQ,SAAA,CAAU,IAAA,CAAK,MAAA,EAAQ,EAAE,OAAO,CAAA;AAAA,EAC/C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,UAAA,GAAmB;AAKjB,IAAA,IAAI,CAAC,KAAK,SAAA,IAAa,IAAA,CAAK,UAAU,IAAA,CAAK,YAAA,CAAa,SAAS,CAAA,EAAG;AAClE,MAAA,IAAA,CAAK,QAAA,GAAW,KAAA;AAAA,IAClB;AACA,IAAA,MAAM,SAAA,GAAY,KAAK,QAAA,IAAY,CAAC,KAAK,cAAA,IAAkB,IAAA,CAAK,aAAa,MAAA,GAAS,CAAA;AAEtF,IAAA,IAAI,SAAA,IAAa,IAAA,CAAK,QAAA,KAAa,IAAA,EAAM;AACvC,MAAA,IAAA,CAAK,QAAA,GAAW,KAAK,UAAA,CAAW,GAAA,CAAI,MAAM,IAAA,CAAK,IAAA,EAAK,EAAG,IAAA,CAAK,aAAa,CAAA;AACzE,MAAA,IAAA,CAAK,SAAS,MAAM,CAAA;AAAA,IACtB,CAAA,MAAA,IAAW,CAAC,SAAA,IAAa,IAAA,CAAK,aAAa,IAAA,EAAM;AAC/C,MAAA,IAAA,CAAK,UAAA,CAAW,KAAA,CAAM,IAAA,CAAK,QAAQ,CAAA;AACnC,MAAA,IAAA,CAAK,QAAA,GAAW,IAAA;AAChB,MAAA,IAAA,CAAK,SAAS,OAAO,CAAA;AAAA,IACvB;AAEA,IAAA,IAAI,KAAK,mBAAA,EAAqB;AAC5B,MAAA,IAAA,CAAK,iBAAiB,YAAA,CAAa,cAAA,EAAgB,IAAA,CAAK,QAAA,GAAW,SAAS,OAAO,CAAA;AAAA,IACrF;AAAA,EACF;AACF","file":"carousel_controller.js","sourcesContent":["/**\n * Low-level roving-tabindex primitive shared by composite-widget controllers.\n *\n * The APG roving-tabindex pattern keeps a composite widget a single Tab stop:\n * exactly one item is in the Tab sequence (`tabindex=\"0\"`) while the rest are\n * removed from it (`tabindex=\"-1\"`), and the arrow keys move both DOM focus and\n * that single tabbable position together. {@link RovingTabindex} owns *only* that\n * mechanical bookkeeping — \"which one item is tabbable, and move focus there\".\n *\n * It is intentionally **policy-free**. Orientation, wrapping vs. clamping,\n * selection-follows-focus, typeahead, and `Home`/`End` semantics differ per APG\n * pattern (Radio Group, Toolbar, Rating, …); folding them into one helper would\n * flatten those widgets to a lowest common denominator and lose each pattern's\n * correctness. Those decisions therefore stay in each controller, which calls\n * {@link RovingTabindex.setActive} with an index it computed itself (optionally\n * via the pure {@link rovingMove} helper).\n *\n * @remarks\n * Items are read lazily through a getter so a controller can add or remove\n * targets (Stimulus re-scans the DOM) without re-wiring this helper.\n */\nexport class RovingTabindex {\n /** Returns the current ordered item elements; called on every operation. */\n readonly #getItems: () => HTMLElement[];\n\n /**\n * @param getItems - Returns the current ordered item elements. Called on every\n * operation so the live target list is always used.\n */\n constructor(getItems: () => HTMLElement[]) {\n this.#getItems = getItems;\n }\n\n /** Index of the currently tabbable item (`tabindex=\"0\"`), or `-1` if none. */\n get activeIndex(): number {\n return this.#getItems().findIndex((item) => item.tabIndex === 0);\n }\n\n /**\n * Makes exactly the item at `index` tabbable (`tabindex=\"0\"`) and removes every\n * other item from the Tab sequence (`tabindex=\"-1\"`). An out-of-range `index`\n * (e.g. `-1`) leaves all items at `-1`, which a controller can use to express\n * \"nothing is currently tabbable\".\n *\n * @param index - Position of the item to make tabbable.\n * @param options - Pass `{ focus: true }` to also move DOM focus to that item.\n */\n setActive(index: number, { focus = false }: { focus?: boolean } = {}): void {\n const items = this.#getItems();\n items.forEach((item, i) => {\n item.tabIndex = i === index ? 0 : -1;\n });\n if (focus) items[index]?.focus();\n }\n}\n\n/** Edge behavior for {@link rovingMove}: cycle past the ends, or stop at them. */\nexport type RovingWrap = \"wrap\" | \"clamp\";\n\n/**\n * Pure helper that resolves the target index for a one-step directional move.\n *\n * Keyboard/orientation mapping stays in the caller: it decides that a key means\n * `delta` `+1` (next) or `-1` (previous) and whether the widget should `\"wrap\"`\n * (Radio Group, Toolbar) or `\"clamp\"` at the ends.\n *\n * @param current - The index focus is moving from.\n * @param length - Number of items in the set.\n * @param delta - `+1` to move to the next item, `-1` for the previous.\n * @param wrap - `\"wrap\"` cycles around the ends; `\"clamp\"` stops at them.\n * @returns The resolved index, or `-1` when there are no items.\n */\nexport function rovingMove(\n current: number,\n length: number,\n delta: number,\n wrap: RovingWrap,\n): number {\n if (length === 0) return -1;\n const next = current + delta;\n if (wrap === \"wrap\") return (next + length) % length;\n return Math.min(length - 1, Math.max(0, next));\n}\n","/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { RovingTabindex, rovingMove } from \"../utils/roving_tabindex\";\nimport { SafeInterval } from \"../utils/safe_timeout\";\n\n/**\n * Headless, accessible **Carousel** (slideshow) behavior.\n *\n * Markup contract (identifier: `stimeo--carousel`):\n * <section data-controller=\"stimeo--carousel\" aria-roledescription=\"carousel\"\n * aria-label=\"Featured\"\n * data-stimeo--carousel-autoplay-value=\"false\"\n * data-stimeo--carousel-interval-value=\"5000\"\n * data-stimeo--carousel-loop-value=\"true\"\n * data-action=\"mouseenter->stimeo--carousel#pause\n * mouseleave->stimeo--carousel#resume\n * focusin->stimeo--carousel#pause\n * focusout->stimeo--carousel#resume\">\n * <button data-stimeo--carousel-target=\"playToggle\"\n * data-action=\"stimeo--carousel#togglePlay\">…</button>\n * <div data-stimeo--carousel-target=\"viewport\">\n * <div role=\"tabpanel\" data-stimeo--carousel-target=\"slide\">…</div>\n * <div role=\"tabpanel\" data-stimeo--carousel-target=\"slide\" hidden>…</div>\n * </div>\n * <button data-stimeo--carousel-target=\"prev\" data-action=\"stimeo--carousel#prev\">‹</button>\n * <button data-stimeo--carousel-target=\"next\" data-action=\"stimeo--carousel#next\">›</button>\n * <div role=\"tablist\">\n * <button role=\"tab\" data-stimeo--carousel-target=\"picker\"\n * data-action=\"stimeo--carousel#goto\n * keydown->stimeo--carousel#onPickerKeydown\"></button>\n * </div>\n * </section>\n *\n * Implements the WAI-ARIA APG **Carousel** (tabbed) pattern. The current slide is\n * exposed through `data-state` (`active`/`inactive`) and the `hidden` attribute on\n * inactive slides (removing them from focus order); the matching picker carries\n * `aria-selected` and the single roving `tabindex`. The play/pause toggle's\n * `aria-pressed` mirrors the autoplay state.\n *\n * @remarks\n * Behavior only — transitions, layout, and visuals are the consumer's CSS.\n * Autoplay honors WCAG 2.2.2: it suspends while the pointer is over the carousel\n * and **hard-stops** when keyboard focus enters (it does not silently resume on\n * focus out — the user must press play), so motion never surprises a keyboard\n * user. The interval is cleared on `disconnect()` (Turbo navigation included).\n * Picker arrow keys move focus only (manual activation); slide changes never steal\n * focus from the control the user operated.\n */\nexport class CarouselController extends Controller<HTMLElement> {\n static override targets = [\"slide\", \"viewport\", \"prev\", \"next\", \"picker\", \"playToggle\"];\n static override values = {\n autoplay: { type: Boolean, default: false },\n interval: { type: Number, default: 5000 },\n loop: { type: Boolean, default: true },\n };\n static actions = [\n \"goto\",\n \"next\",\n \"onPickerKeydown\",\n \"pause\",\n \"prev\",\n \"resume\",\n \"togglePlay\",\n ] as const;\n static events = [\"change\", \"pause\", \"play\"] as const;\n\n declare readonly slideTargets: HTMLElement[];\n declare readonly pickerTargets: HTMLElement[];\n declare readonly playToggleTarget: HTMLElement;\n declare readonly hasPlayToggleTarget: boolean;\n declare autoplayValue: boolean;\n declare intervalValue: number;\n declare loopValue: boolean;\n\n readonly #roving = new RovingTabindex(() => this.pickerTargets);\n readonly #intervals = new SafeInterval();\n /** Index of the visible slide. */\n #index = 0;\n /** User intent to autoplay (toggled by the play button / focus hard-stop). */\n #playing = false;\n /** Pointer is hovering the carousel: a temporary, auto-resuming suspension. */\n #pointerPaused = false;\n /** Id of the live autoplay interval, or null when stopped. */\n #timerId: number | null = null;\n\n /** Renders the initial slide and starts autoplay when requested. */\n override connect(): void {\n const preselected = this.pickerTargets.findIndex(\n (picker) => picker.getAttribute(\"aria-selected\") === \"true\",\n );\n this.#index = preselected === -1 ? 0 : preselected;\n this.#playing = this.#initialPlaying();\n this.#render({ focus: false });\n this.#syncTimer();\n }\n\n /**\n * Resolves the starting autoplay intent. The play toggle's `aria-pressed` is the\n * source of truth **when present**, so a Turbo Drive cache restore / morph that\n * re-runs `connect()` against existing DOM does not silently resume autoplay the\n * user had stopped (e.g. by focusing into the carousel). Only when no toggle\n * carries `aria-pressed` does it fall back to the declarative `autoplay` value.\n */\n #initialPlaying(): boolean {\n if (this.hasPlayToggleTarget && this.playToggleTarget.hasAttribute(\"aria-pressed\")) {\n return this.playToggleTarget.getAttribute(\"aria-pressed\") === \"true\";\n }\n return this.autoplayValue;\n }\n\n /** Clears the autoplay interval so it never fires after teardown. */\n override disconnect(): void {\n this.#intervals.clearAll();\n this.#timerId = null;\n }\n\n /** Advances to the next slide. Bound via `data-action`. */\n next(): void {\n this.#select(this.#step(1), { focus: false });\n }\n\n /** Returns to the previous slide. Bound via `data-action`. */\n prev(): void {\n this.#select(this.#step(-1), { focus: false });\n }\n\n /** Jumps to the slide whose picker was activated (click / Enter / Space). */\n goto(event: Event): void {\n const target = event.currentTarget as HTMLElement;\n const index = this.pickerTargets.indexOf(target);\n if (index !== -1) this.#select(index, { focus: false });\n }\n\n /** Toggles autoplay on the user's explicit request and syncs the timer. */\n togglePlay(): void {\n this.#playing = !this.#playing;\n this.#syncTimer();\n }\n\n /**\n * Suspends autoplay. Hover (`mouseenter`) is a temporary suspension that resumes\n * on leave; keyboard focus (`focusin`) is a hard stop that turns autoplay off so\n * it cannot resume without an explicit play (WCAG 2.2.2).\n */\n pause(event?: Event): void {\n if (event?.type.startsWith(\"focus\")) {\n this.#playing = false;\n } else {\n this.#pointerPaused = true;\n }\n this.#syncTimer();\n }\n\n /**\n * Lifts a hover suspension (`mouseleave`) and resumes autoplay if it is still\n * on. A `focusout` does nothing here: the focus pause was a hard stop, so the\n * user must press play to restart.\n */\n resume(event?: Event): void {\n if (event?.type.startsWith(\"focus\")) return;\n this.#pointerPaused = false;\n this.#syncTimer();\n }\n\n /** Picker roving: arrows move focus only; Home/End activate first/last slide. */\n onPickerKeydown(event: KeyboardEvent): void {\n const current = this.pickerTargets.indexOf(event.currentTarget as HTMLElement);\n if (current === -1) return;\n\n const length = this.pickerTargets.length;\n switch (event.key) {\n case \"ArrowRight\":\n case \"ArrowDown\":\n event.preventDefault();\n this.#roving.setActive(rovingMove(current, length, 1, \"wrap\"), { focus: true });\n return;\n case \"ArrowLeft\":\n case \"ArrowUp\":\n event.preventDefault();\n this.#roving.setActive(rovingMove(current, length, -1, \"wrap\"), { focus: true });\n return;\n case \"Home\":\n event.preventDefault();\n this.#select(0, { focus: true });\n return;\n case \"End\":\n event.preventDefault();\n this.#select(length - 1, { focus: true });\n return;\n default:\n }\n }\n\n /** Resolves the index one step away from the current one, honoring `loop`. */\n #step(delta: number): number {\n const total = this.slideTargets.length;\n if (total === 0) return 0;\n const next = this.#index + delta;\n if (this.loopValue) return (next + total) % total;\n return Math.min(total - 1, Math.max(0, next));\n }\n\n /**\n * Changes the active slide, updates state hooks, and emits `change` — but only\n * when the index actually changes, so a `next`/`prev` clamped at the end (or an\n * autoplay tick at a non-looping boundary) re-renders without a spurious event\n * (matching the \"emit on real change\" policy of flash/masonry/bulk-select).\n */\n #select(index: number, { focus }: { focus: boolean }): void {\n const changed = index !== this.#index;\n this.#index = index;\n this.#render({ focus });\n // Re-evaluate autoplay after every move so reaching the non-looping end stops\n // the timer (see `#syncTimer`); idempotent for moves that don't cross a boundary.\n this.#syncTimer();\n if (changed) this.dispatch(\"change\", { detail: { index, total: this.slideTargets.length } });\n }\n\n /** Reflects `this.#index` onto slides and pickers (state hooks + roving). */\n #render({ focus }: { focus: boolean }): void {\n this.slideTargets.forEach((slide, i) => {\n const active = i === this.#index;\n slide.setAttribute(\"data-state\", active ? \"active\" : \"inactive\");\n slide.hidden = !active;\n });\n this.pickerTargets.forEach((picker, i) => {\n picker.setAttribute(\"aria-selected\", i === this.#index ? \"true\" : \"false\");\n });\n this.#roving.setActive(this.#index, { focus });\n }\n\n /**\n * Drives the autoplay interval toward the desired state. Autoplay should run\n * only when the user wants it (`playing`), the pointer is not hovering, and more\n * than one slide exists. Transitions emit `play`/`pause` and keep the toggle's\n * `aria-pressed` in sync.\n */\n #syncTimer(): void {\n // A non-looping carousel sitting on its last slide has nothing left to advance\n // to, so autoplay turns itself off (a hard stop, like the focus pause): the\n // timer is cleared, `aria-pressed` flips to false, and a manual step back will\n // not silently restart it without an explicit play.\n if (!this.loopValue && this.#index >= this.slideTargets.length - 1) {\n this.#playing = false;\n }\n const shouldRun = this.#playing && !this.#pointerPaused && this.slideTargets.length > 1;\n\n if (shouldRun && this.#timerId === null) {\n this.#timerId = this.#intervals.set(() => this.next(), this.intervalValue);\n this.dispatch(\"play\");\n } else if (!shouldRun && this.#timerId !== null) {\n this.#intervals.clear(this.#timerId);\n this.#timerId = null;\n this.dispatch(\"pause\");\n }\n\n if (this.hasPlayToggleTarget) {\n this.playToggleTarget.setAttribute(\"aria-pressed\", this.#playing ? \"true\" : \"false\");\n }\n }\n}\n"]}
@@ -0,0 +1,72 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ /**
4
+ * Headless copy-to-clipboard behavior with a live-region completion notice.
5
+ *
6
+ * Markup contract (identifier: `stimeo--clipboard`):
7
+ * <div data-controller="stimeo--clipboard"
8
+ * data-stimeo--clipboard-feedback-duration-value="2000">
9
+ * <input type="text" value="https://example.com" readonly
10
+ * data-stimeo--clipboard-target="source">
11
+ * <button type="button" data-stimeo--clipboard-target="button"
12
+ * data-action="stimeo--clipboard#copy">Copy</button>
13
+ * <span role="status" aria-live="polite"
14
+ * data-stimeo--clipboard-target="feedback"></span>
15
+ * </div>
16
+ *
17
+ * No dedicated APG pattern; this follows the Button + live-region practice. The
18
+ * copy uses the standard `navigator.clipboard` API (no extra dependency); when
19
+ * it is unavailable or rejects, the failure is surfaced rather than silently
20
+ * swallowed, and never communicated by icon alone — the `role="status"` region
21
+ * carries text so screen readers announce the outcome.
22
+ *
23
+ * @remarks
24
+ * Behavior only — icon swaps and styling are the consumer's, keyed off
25
+ * `data-state` (`idle` / `copied` / `error`). The completion notice clears
26
+ * itself after `feedbackDuration`; that timer is torn down on disconnect (Turbo)
27
+ * via {@link SafeTimeout}.
28
+ */
29
+ declare class ClipboardController extends Controller<HTMLElement> {
30
+ #private;
31
+ static targets: string[];
32
+ static values: {
33
+ text: {
34
+ type: StringConstructor;
35
+ default: string;
36
+ };
37
+ feedbackDuration: {
38
+ type: NumberConstructor;
39
+ default: number;
40
+ };
41
+ copiedLabel: {
42
+ type: StringConstructor;
43
+ default: string;
44
+ };
45
+ errorLabel: {
46
+ type: StringConstructor;
47
+ default: string;
48
+ };
49
+ };
50
+ static actions: readonly ["copy"];
51
+ static events: readonly ["copy"];
52
+ readonly sourceTarget: HTMLElement;
53
+ readonly buttonTarget: HTMLElement;
54
+ readonly feedbackTarget: HTMLElement;
55
+ readonly hasSourceTarget: boolean;
56
+ readonly hasButtonTarget: boolean;
57
+ readonly hasFeedbackTarget: boolean;
58
+ textValue: string;
59
+ feedbackDurationValue: number;
60
+ copiedLabelValue: string;
61
+ errorLabelValue: string;
62
+ connect(): void;
63
+ disconnect(): void;
64
+ /**
65
+ * Copies the resolved text and reports the outcome. Bound via `data-action`
66
+ * (click). Always dispatches `stimeo--clipboard:copy` with `{ success, text }`
67
+ * — including on failure — so consumers can react either way.
68
+ */
69
+ copy(): Promise<void>;
70
+ }
71
+
72
+ export { ClipboardController };
@@ -0,0 +1,144 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/clipboard_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/clipboard_controller.ts
59
+ var ClipboardController = class extends Controller {
60
+ static targets = ["source", "button", "feedback"];
61
+ static values = {
62
+ text: { type: String, default: "" },
63
+ feedbackDuration: { type: Number, default: 2e3 },
64
+ copiedLabel: { type: String, default: "Copied" },
65
+ errorLabel: { type: String, default: "Copy failed" }
66
+ };
67
+ static actions = ["copy"];
68
+ static events = ["copy"];
69
+ /** Auto-clear timer for the completion notice; torn down on disconnect. */
70
+ #timers = new SafeTimeout();
71
+ /**
72
+ * The pending auto-clear timer id, or `null` when none is scheduled. Tracked so
73
+ * a rapid second copy cancels the first window instead of letting a stale timer
74
+ * reset the freshly-shown notice early.
75
+ */
76
+ #resetTimerId = null;
77
+ connect() {
78
+ if (!this.element.hasAttribute("data-state")) {
79
+ this.element.setAttribute("data-state", "idle");
80
+ }
81
+ }
82
+ disconnect() {
83
+ this.#timers.clearAll();
84
+ }
85
+ /**
86
+ * Copies the resolved text and reports the outcome. Bound via `data-action`
87
+ * (click). Always dispatches `stimeo--clipboard:copy` with `{ success, text }`
88
+ * — including on failure — so consumers can react either way.
89
+ */
90
+ async copy() {
91
+ const text = this.#resolveText();
92
+ let success = false;
93
+ try {
94
+ if (!navigator.clipboard?.writeText) throw new Error("Clipboard API unavailable");
95
+ await navigator.clipboard.writeText(text);
96
+ success = true;
97
+ } catch {
98
+ success = false;
99
+ }
100
+ this.#reportResult(success);
101
+ this.dispatch("copy", { detail: { success, text } });
102
+ }
103
+ /**
104
+ * The text to copy: the explicit `text` value when set, otherwise the source
105
+ * target's current value (inputs/textareas) or text content.
106
+ */
107
+ #resolveText() {
108
+ if (this.textValue.length > 0) return this.textValue;
109
+ if (!this.hasSourceTarget) return "";
110
+ const source = this.sourceTarget;
111
+ if (source instanceof HTMLInputElement || source instanceof HTMLTextAreaElement) {
112
+ return source.value;
113
+ }
114
+ return source.textContent ?? "";
115
+ }
116
+ /** Reflects the result on `data-state`, announces it, and schedules a reset. */
117
+ #reportResult(success) {
118
+ this.element.setAttribute("data-state", success ? "copied" : "error");
119
+ if (this.hasFeedbackTarget) {
120
+ this.feedbackTarget.textContent = success ? this.copiedLabelValue : this.errorLabelValue;
121
+ }
122
+ if (this.#resetTimerId !== null) {
123
+ this.#timers.clear(this.#resetTimerId);
124
+ this.#resetTimerId = null;
125
+ }
126
+ if (this.feedbackDurationValue > 0) {
127
+ this.#resetTimerId = this.#timers.set(() => {
128
+ this.#resetTimerId = null;
129
+ this.#reset();
130
+ }, this.feedbackDurationValue);
131
+ }
132
+ }
133
+ /** Returns to the idle state and clears the completion notice. */
134
+ #reset() {
135
+ this.element.setAttribute("data-state", "idle");
136
+ if (this.hasFeedbackTarget) {
137
+ this.feedbackTarget.textContent = "";
138
+ }
139
+ }
140
+ };
141
+
142
+ export { ClipboardController };
143
+ //# sourceMappingURL=clipboard_controller.js.map
144
+ //# sourceMappingURL=clipboard_controller.js.map