stimeo-ui 0.5.0 → 0.6.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 (96) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/dist/controllers/aspect_ratio_controller.d.ts +1 -3
  3. package/dist/controllers/aspect_ratio_controller.js +19 -11
  4. package/dist/controllers/aspect_ratio_controller.js.map +1 -1
  5. package/dist/controllers/avatar_controller.d.ts +36 -15
  6. package/dist/controllers/avatar_controller.js +195 -40
  7. package/dist/controllers/avatar_controller.js.map +1 -1
  8. package/dist/controllers/carousel_controller.d.ts +12 -3
  9. package/dist/controllers/carousel_controller.js +85 -9
  10. package/dist/controllers/carousel_controller.js.map +1 -1
  11. package/dist/controllers/checkbox_controller.d.ts +31 -5
  12. package/dist/controllers/checkbox_controller.js +136 -25
  13. package/dist/controllers/checkbox_controller.js.map +1 -1
  14. package/dist/controllers/color_picker_controller.d.ts +7 -1
  15. package/dist/controllers/color_picker_controller.js +35 -9
  16. package/dist/controllers/color_picker_controller.js.map +1 -1
  17. package/dist/controllers/date_range_picker_controller.d.ts +23 -4
  18. package/dist/controllers/date_range_picker_controller.js +157 -30
  19. package/dist/controllers/date_range_picker_controller.js.map +1 -1
  20. package/dist/controllers/file_dropzone_controller.d.ts +7 -1
  21. package/dist/controllers/file_dropzone_controller.js +26 -3
  22. package/dist/controllers/file_dropzone_controller.js.map +1 -1
  23. package/dist/controllers/focus_controller.d.ts +2 -3
  24. package/dist/controllers/focus_controller.js.map +1 -1
  25. package/dist/controllers/hover_card_controller.d.ts +2 -2
  26. package/dist/controllers/hover_card_controller.js.map +1 -1
  27. package/dist/controllers/idle_controller.d.ts +3 -3
  28. package/dist/controllers/idle_controller.js +27 -5
  29. package/dist/controllers/idle_controller.js.map +1 -1
  30. package/dist/controllers/menubar_controller.js +5 -3
  31. package/dist/controllers/menubar_controller.js.map +1 -1
  32. package/dist/controllers/multi_select_controller.d.ts +46 -12
  33. package/dist/controllers/multi_select_controller.js +460 -151
  34. package/dist/controllers/multi_select_controller.js.map +1 -1
  35. package/dist/controllers/number_input_controller.d.ts +24 -6
  36. package/dist/controllers/number_input_controller.js +275 -51
  37. package/dist/controllers/number_input_controller.js.map +1 -1
  38. package/dist/controllers/overflow_menu_controller.js +4 -0
  39. package/dist/controllers/overflow_menu_controller.js.map +1 -1
  40. package/dist/controllers/pagination_controller.js +33 -0
  41. package/dist/controllers/pagination_controller.js.map +1 -1
  42. package/dist/controllers/password_strength_controller.d.ts +3 -3
  43. package/dist/controllers/password_strength_controller.js +20 -2
  44. package/dist/controllers/password_strength_controller.js.map +1 -1
  45. package/dist/controllers/persist_controller.d.ts +4 -4
  46. package/dist/controllers/persist_controller.js +24 -5
  47. package/dist/controllers/persist_controller.js.map +1 -1
  48. package/dist/controllers/popover_controller.d.ts +2 -2
  49. package/dist/controllers/popover_controller.js.map +1 -1
  50. package/dist/controllers/portal_controller.d.ts +1 -2
  51. package/dist/controllers/portal_controller.js.map +1 -1
  52. package/dist/controllers/radio_group_controller.d.ts +41 -15
  53. package/dist/controllers/radio_group_controller.js +540 -56
  54. package/dist/controllers/radio_group_controller.js.map +1 -1
  55. package/dist/controllers/rating_controller.d.ts +36 -31
  56. package/dist/controllers/rating_controller.js +272 -89
  57. package/dist/controllers/rating_controller.js.map +1 -1
  58. package/dist/controllers/resizable_controller.js +33 -0
  59. package/dist/controllers/resizable_controller.js.map +1 -1
  60. package/dist/controllers/roving_controller.d.ts +4 -0
  61. package/dist/controllers/roving_controller.js +60 -5
  62. package/dist/controllers/roving_controller.js.map +1 -1
  63. package/dist/controllers/scroll_area_controller.d.ts +6 -0
  64. package/dist/controllers/scroll_area_controller.js +154 -22
  65. package/dist/controllers/scroll_area_controller.js.map +1 -1
  66. package/dist/controllers/scroll_visibility_controller.js +33 -0
  67. package/dist/controllers/scroll_visibility_controller.js.map +1 -1
  68. package/dist/controllers/tags_input_controller.d.ts +41 -11
  69. package/dist/controllers/tags_input_controller.js +356 -120
  70. package/dist/controllers/tags_input_controller.js.map +1 -1
  71. package/dist/controllers/time_picker_controller.d.ts +42 -13
  72. package/dist/controllers/time_picker_controller.js +296 -107
  73. package/dist/controllers/time_picker_controller.js.map +1 -1
  74. package/dist/controllers/toggle_group_controller.d.ts +39 -13
  75. package/dist/controllers/toggle_group_controller.js +378 -55
  76. package/dist/controllers/toggle_group_controller.js.map +1 -1
  77. package/dist/controllers/toolbar_controller.js +5 -3
  78. package/dist/controllers/toolbar_controller.js.map +1 -1
  79. package/dist/controllers/tooltip_controller.d.ts +2 -2
  80. package/dist/controllers/tooltip_controller.js.map +1 -1
  81. package/dist/controllers/transition_controller.d.ts +2 -3
  82. package/dist/controllers/transition_controller.js.map +1 -1
  83. package/dist/controllers/tree_view_controller.js +5 -3
  84. package/dist/controllers/tree_view_controller.js.map +1 -1
  85. package/dist/index.js +2637 -781
  86. package/dist/index.js.map +1 -1
  87. package/dist/inspector/cli.d.ts +16 -3
  88. package/dist/inspector/cli.js +22 -6
  89. package/dist/inspector/cli.js.map +1 -1
  90. package/dist/inspector/cli_bin.js +22 -6
  91. package/dist/inspector/cli_bin.js.map +1 -1
  92. package/dist/inspector/examples.json +8 -8
  93. package/dist/inspector/manifest.json +169 -37
  94. package/dist/positioning/index.d.ts +2 -2
  95. package/dist/positioning/index.js.map +1 -1
  96. package/package.json +1 -1
@@ -41,7 +41,10 @@ import { Controller } from '@hotwired/stimulus';
41
41
  * focus out — the user must press play), so motion never surprises a keyboard
42
42
  * user. The interval is cleared on `disconnect()` (Turbo navigation included).
43
43
  * Picker arrow keys move focus only (manual activation); slide changes never steal
44
- * focus from the control the user operated.
44
+ * focus from the control the user operated. A slide change the user drove is
45
+ * reported as `stimeo--carousel:change` with `{ index, total }`; the same detail
46
+ * arrives as `stimeo--carousel:reconcile` when removing a slide or picker clamps
47
+ * the active index instead.
45
48
  */
46
49
  declare class CarouselController extends Controller<HTMLElement> {
47
50
  #private;
@@ -61,7 +64,7 @@ declare class CarouselController extends Controller<HTMLElement> {
61
64
  };
62
65
  };
63
66
  static actions: readonly ["goto", "next", "onPickerKeydown", "pause", "prev", "resume", "togglePlay"];
64
- static events: readonly ["change", "pause", "play"];
67
+ static events: readonly ["change", "pause", "play", "reconcile"];
65
68
  readonly slideTargets: HTMLElement[];
66
69
  readonly pickerTargets: HTMLElement[];
67
70
  readonly playToggleTarget: HTMLElement;
@@ -85,7 +88,13 @@ declare class CarouselController extends Controller<HTMLElement> {
85
88
  * repaint re-derives every picker from `#index`, so a late arrival never steals
86
89
  * the selection.
87
90
  */
88
- pickerTargetConnected(): void;
91
+ pickerTargetConnected(picker: HTMLElement): void;
92
+ /** Repairs selection and roving after a picker leaves a retained carousel. */
93
+ pickerTargetDisconnected(): void;
94
+ /** Reconciles a slide added in the same DOM batch as its picker. */
95
+ slideTargetConnected(): void;
96
+ /** Re-clamps the active index after a slide is removed. */
97
+ slideTargetDisconnected(): void;
89
98
  /** Clears the autoplay interval so it never fires after teardown. */
90
99
  disconnect(): void;
91
100
  /** Advances to the next slide. Bound via `data-action`. */
@@ -20,6 +20,39 @@ function isReservedArrowChord(event, allow = []) {
20
20
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
21
21
  }
22
22
 
23
+ // src/utils/microtask_coalescer.ts
24
+ var MicrotaskCoalescer = class {
25
+ #run;
26
+ #queued = false;
27
+ #active = false;
28
+ #generation = 0;
29
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
30
+ constructor(run) {
31
+ this.#run = run;
32
+ }
33
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
34
+ activate() {
35
+ this.#active = true;
36
+ }
37
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
38
+ cancel() {
39
+ this.#active = false;
40
+ this.#queued = false;
41
+ this.#generation += 1;
42
+ }
43
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
44
+ schedule() {
45
+ if (!this.#active || this.#queued) return;
46
+ this.#queued = true;
47
+ const generation = this.#generation;
48
+ queueMicrotask(() => {
49
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
50
+ this.#queued = false;
51
+ this.#run();
52
+ });
53
+ }
54
+ };
55
+
23
56
  // src/utils/roving_tabindex.ts
24
57
  var RovingTabindex = class {
25
58
  /** Returns the current ordered item elements; called on every operation. */
@@ -42,10 +75,12 @@ var RovingTabindex = class {
42
75
  * "nothing is currently tabbable".
43
76
  *
44
77
  * @param index - Position of the item to make tabbable.
45
- * @param options - Pass `{ focus: true }` to also move DOM focus to that item.
78
+ * @param options - Pass `{ focus: true }` to also move DOM focus to that item,
79
+ * and `items` to reuse an event-scoped collection snapshot.
46
80
  */
47
- setActive(index, { focus = false } = {}) {
48
- const items = this.#getItems();
81
+ setActive(index, options = {}) {
82
+ const { focus = false } = options;
83
+ const items = options.items ?? this.#getItems();
49
84
  items.forEach((item, i) => {
50
85
  item.tabIndex = i === index ? 0 : -1;
51
86
  });
@@ -120,9 +155,14 @@ var CarouselController = class extends Controller {
120
155
  "resume",
121
156
  "togglePlay"
122
157
  ];
123
- static events = ["change", "pause", "play"];
158
+ static events = ["change", "pause", "play", "reconcile"];
124
159
  #roving = new RovingTabindex(() => this.pickerTargets);
125
- /** Gates the target callback so it does not repaint once per authored picker on mount. */
160
+ #reconcileTargets = new MicrotaskCoalescer(() => this.#reconcileTargetSet());
161
+ /**
162
+ * Whether `connect()` has run. Scheduling is already inert outside that window
163
+ * ({@link MicrotaskCoalescer}), so this only gates the Tab stop a picker
164
+ * present at mount authored for itself.
165
+ */
126
166
  #connected = false;
127
167
  #intervals = new SafeInterval();
128
168
  /** Index of the visible slide. */
@@ -148,6 +188,7 @@ var CarouselController = class extends Controller {
148
188
  this.#render({ focus: false });
149
189
  this.#syncTimer();
150
190
  this.#connected = true;
191
+ this.#reconcileTargets.activate();
151
192
  }
152
193
  /**
153
194
  * Re-establishes the single selected picker when one is added after connect.
@@ -158,9 +199,22 @@ var CarouselController = class extends Controller {
158
199
  * repaint re-derives every picker from `#index`, so a late arrival never steals
159
200
  * the selection.
160
201
  */
161
- pickerTargetConnected() {
202
+ pickerTargetConnected(picker) {
162
203
  if (!this.#connected) return;
163
- this.#render({ focus: false });
204
+ picker.tabIndex = -1;
205
+ this.#reconcileTargets.schedule();
206
+ }
207
+ /** Repairs selection and roving after a picker leaves a retained carousel. */
208
+ pickerTargetDisconnected() {
209
+ this.#reconcileTargets.schedule();
210
+ }
211
+ /** Reconciles a slide added in the same DOM batch as its picker. */
212
+ slideTargetConnected() {
213
+ this.#reconcileTargets.schedule();
214
+ }
215
+ /** Re-clamps the active index after a slide is removed. */
216
+ slideTargetDisconnected() {
217
+ this.#reconcileTargets.schedule();
164
218
  }
165
219
  /**
166
220
  * Resolves the starting autoplay intent. The play toggle's `aria-pressed` is the
@@ -178,6 +232,7 @@ var CarouselController = class extends Controller {
178
232
  /** Clears the autoplay interval so it never fires after teardown. */
179
233
  disconnect() {
180
234
  this.#connected = false;
235
+ this.#reconcileTargets.cancel();
181
236
  this.#intervals.clearAll();
182
237
  this.#timerId = null;
183
238
  }
@@ -282,10 +337,31 @@ var CarouselController = class extends Controller {
282
337
  slide.setAttribute("data-state", active ? "active" : "inactive");
283
338
  slide.hidden = !active;
284
339
  });
340
+ const pickerIndex = Math.min(this.#index, this.pickerTargets.length - 1);
285
341
  this.pickerTargets.forEach((picker, i) => {
286
- picker.setAttribute("aria-selected", i === this.#index ? "true" : "false");
342
+ picker.setAttribute("aria-selected", i === pickerIndex ? "true" : "false");
287
343
  });
288
- this.#roving.setActive(this.#index, { focus });
344
+ this.#roving.setActive(pickerIndex, { focus });
345
+ }
346
+ /** Keeps the live active slide when possible and otherwise selects the nearest survivor. */
347
+ #reconcileTargetSet() {
348
+ const activeSlide = this.slideTargets.findIndex(
349
+ (slide) => slide.getAttribute("data-state") === "active"
350
+ );
351
+ const selectedPicker = this.pickerTargets.findIndex(
352
+ (picker) => picker.getAttribute("aria-selected") === "true"
353
+ );
354
+ const lastSlide = this.slideTargets.length - 1;
355
+ const candidate = activeSlide !== -1 ? activeSlide : selectedPicker !== -1 ? selectedPicker : this.#index;
356
+ const previous = this.#index;
357
+ this.#index = lastSlide < 0 ? 0 : Math.min(lastSlide, Math.max(0, candidate));
358
+ this.#render({ focus: false });
359
+ this.#syncTimer();
360
+ if (this.#index !== previous) {
361
+ this.dispatch("reconcile", {
362
+ detail: { index: this.#index, total: this.slideTargets.length }
363
+ });
364
+ }
289
365
  }
290
366
  /**
291
367
  * Drives the autoplay interval toward the desired state. Autoplay should run
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/logical_scroll.ts","../../src/utils/arrow_step.ts","../../src/utils/roving_tabindex.ts","../../src/utils/safe_timeout.ts","../../src/controllers/carousel_controller.ts"],"names":[],"mappings":"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","file":"carousel_controller.js","sourcesContent":["/** Normalized scroll position and maximum distance on one logical axis. */\nexport interface LogicalScrollMetrics {\n position: number;\n max: number;\n}\n\n/**\n * Whether horizontal scrolling on `element` follows right-to-left inline flow.\n *\n * Resolved from the **computed** `direction`, so the authoring contract is the\n * usual `dir=\"rtl\"` (or a stylesheet) on the element or any ancestor.\n *\n * Scope: horizontal writing modes. A vertical writing mode (`writing-mode:\n * vertical-rl`) also inverts the horizontal axis, which this check does not\n * model — vertical writing modes are out of scope for the scroll utilities\n * (their consumers describe axes as horizontal/vertical, not inline/block).\n */\nexport function isRtl(element: Element): boolean {\n return window.getComputedStyle(element).direction === \"rtl\";\n}\n\n/**\n * Returns scroll distance from the logical start edge.\n *\n * CSSOM View exposes standards-mode RTL horizontal offsets as `0` at the inline\n * start (right) and increasingly negative values toward the inline end (left).\n * The normalized position is always clamped to `[0, max]`, which also absorbs\n * Safari's elastic overscroll values.\n */\nexport function logicalScrollMetrics(\n element: HTMLElement,\n horizontal: boolean,\n): LogicalScrollMetrics {\n const max = Math.max(\n 0,\n horizontal\n ? element.scrollWidth - element.clientWidth\n : element.scrollHeight - element.clientHeight,\n );\n const raw = horizontal ? element.scrollLeft : element.scrollTop;\n const position = horizontal && isRtl(element) ? -raw : raw;\n return { position: Math.min(max, Math.max(0, position)), max };\n}\n\n/**\n * Converts a logical start/end delta to the physical value accepted by\n * `Element.scrollBy`.\n */\nexport function physicalScrollDelta(\n element: HTMLElement,\n horizontal: boolean,\n logicalDelta: number,\n): number {\n return horizontal && isRtl(element) ? -logicalDelta : logicalDelta;\n}\n","import { isRtl } from \"./logical_scroll\";\n\n/**\n * Turns an arrow key into a **logical** step: `+1` for \"next\", `-1` for\n * \"previous\", `0` when the key names neither.\n *\n * APG defines the horizontal pair as *next / previous* and says a vertical\n * arrangement swaps in Down/Up for the same meaning — so the pair is one axis's\n * spelling of an order, and the order reverses with the writing direction. Only\n * the horizontal pair reverses. Down/Up name an axis the writing direction does\n * not mirror, and returning them unchanged is the point: many controllers fold\n * both pairs into one branch, where swapping the branches under RTL would flip\n * the vertical axis too — a bug that reads as \"the arrows work\" until someone\n * presses Down.\n *\n * **Direction is read from the element the caller passes, which should be the\n * container that lays the items out** — not the focused child. A child may carry\n * its own `dir` (an LTR input inside an RTL form is ordinary authoring), and\n * probing per handler makes two handlers disagree at the boundary between them.\n *\n * This decides direction only. Whether the axis is even active (an\n * `orientation=\"horizontal\"` widget ignoring Down/Up), how far the step lands,\n * and what wrapping does all stay with the caller.\n *\n * **It encodes the list-order convention: `ArrowDown` is *next*.** Widgets that\n * pair the arrows by *value* instead — `ArrowUp` meaning \"more\", as a rating or a\n * slider does — must not use this, or their vertical axis inverts. Reverse the\n * horizontal pair on its own there.\n *\n * @example\n * ```ts\n * const step = logicalArrowStep(event.key, this.element);\n * if (step === 0) return;\n * this.#roving.setActive(rovingMove(current, length, step, \"wrap\"), { focus: true });\n * ```\n */\nexport function logicalArrowStep(key: string, element: Element): 1 | -1 | 0 {\n if (key === \"ArrowDown\") return 1;\n if (key === \"ArrowUp\") return -1;\n if (key !== \"ArrowRight\" && key !== \"ArrowLeft\") return 0;\n const forward = isRtl(element) ? \"ArrowLeft\" : \"ArrowRight\";\n return key === forward ? 1 : -1;\n}\n\n/**\n * Rewrites `key` so an existing LTR-shaped branch keeps working under RTL:\n * `ArrowRight` and `ArrowLeft` trade places, everything else passes through.\n *\n * The alternative — negating a delta — silently breaks handlers whose two\n * horizontal branches are **not mirror images**. A grid that clamps one edge but\n * not the other, or a segmented field guarding `index > 0` on one side and\n * `index < length - 1` on the other, ends up applying the wrong guard to the\n * wrong direction. Swapping the key leaves each branch, guards and all, exactly\n * where its author put it.\n *\n * Same rule as {@link logicalArrowStep} about which element to read: pass the\n * container that lays the items out, not the focused child.\n *\n * @example\n * ```ts\n * switch (logicalArrowKey(event.key, this.element)) {\n * case \"ArrowLeft\": // \"previous\" — whatever direction that is on screen\n * ```\n */\nexport function logicalArrowKey(key: string, element: Element): string {\n if (key !== \"ArrowRight\" && key !== \"ArrowLeft\") return key;\n if (!isRtl(element)) return key;\n return key === \"ArrowRight\" ? \"ArrowLeft\" : \"ArrowRight\";\n}\n\n/** Modifiers a widget may claim on an arrow key, named for the `allow` list. */\nexport type ArrowModifier = \"alt\" | \"ctrl\" | \"meta\" | \"shift\";\n\n/**\n * True when an arrow key arrived carrying a modifier the widget must leave to\n * the browser: return without calling `preventDefault()` and without moving any\n * state.\n *\n * A bare arrow belongs to the widget; a chorded one usually does not.\n * `Alt`/`Meta` plus a horizontal arrow is history back/forward on every desktop\n * browser, and a widget that swallows it makes the shortcut work or not\n * depending on where focus happens to sit — a coin-flip the user cannot see.\n *\n * `allow` is for the combinations APG assigns to a pattern **and the widget\n * actually implements** — today only Combobox's optional `Alt+Down`/`Alt+Up`.\n * Listing one the widget does not implement defeats the point: the chord then\n * runs the plain-arrow branch, which is exactly what this guard exists to stop.\n * Non-arrow keys return `false`, so chorded letters and\n * `Control+Home`/`Control+End` are untouched.\n *\n * @example\n * ```ts\n * if (isReservedArrowChord(event)) return;\n * ```\n */\nexport function isReservedArrowChord(\n event: KeyboardEvent,\n allow: readonly ArrowModifier[] = [],\n): boolean {\n if (!event.key.startsWith(\"Arrow\")) return false;\n return (\n (event.altKey && !allow.includes(\"alt\")) ||\n (event.ctrlKey && !allow.includes(\"ctrl\")) ||\n (event.metaKey && !allow.includes(\"meta\")) ||\n (event.shiftKey && !allow.includes(\"shift\"))\n );\n}\n","/**\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 { isReservedArrowChord, logicalArrowStep } from \"../utils/arrow_step\";\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 /** Gates the target callback so it does not repaint once per authored picker on mount. */\n #connected = false;\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 /**\n * Renders the initial slide and starts autoplay when requested.\n *\n * `findIndex` makes the authored pre-selection first-wins when several pickers\n * are marked; `#render` then writes an explicit value onto every picker.\n */\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 this.#connected = true;\n }\n\n /**\n * Re-establishes the single selected picker when one is added after connect.\n *\n * Without this an appended picker that arrives `aria-selected=\"true\"` leaves two\n * marked at once — the authored pre-selection is only read on connect, so\n * nothing else ever resolves the conflict. The current slide is kept: the\n * repaint re-derives every picker from `#index`, so a late arrival never steals\n * the selection.\n */\n pickerTargetConnected(): void {\n if (!this.#connected) return;\n this.#render({ focus: false });\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.#connected = false;\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 // A descendant widget that already claimed the key must not ALSO move the\n // picker focus or change the slide — composition depends on this yield.\n if (event.defaultPrevented) return;\n if (isReservedArrowChord(event)) return;\n const current = this.pickerTargets.indexOf(event.currentTarget as HTMLElement);\n if (current === -1) return;\n\n const length = this.pickerTargets.length;\n // Logical, not physical. The helper reverses only the horizontal\n // pair, so folding Down/Up into the same branch stays correct.\n switch (event.key) {\n case \"ArrowRight\":\n case \"ArrowDown\":\n case \"ArrowLeft\":\n case \"ArrowUp\": {\n event.preventDefault();\n const step = logicalArrowStep(event.key, this.element);\n this.#roving.setActive(rovingMove(current, length, step, \"wrap\"), { focus: true });\n return;\n }\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 /**\n * Reflects `this.#index` onto slides and pickers (state hooks + roving).\n *\n * @stimeoRenderRoot\n */\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"]}
1
+ {"version":3,"sources":["../../src/utils/logical_scroll.ts","../../src/utils/arrow_step.ts","../../src/utils/microtask_coalescer.ts","../../src/utils/roving_tabindex.ts","../../src/utils/safe_timeout.ts","../../src/controllers/carousel_controller.ts"],"names":[],"mappings":"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","file":"carousel_controller.js","sourcesContent":["/** Normalized scroll position and maximum distance on one logical axis. */\nexport interface LogicalScrollMetrics {\n position: number;\n max: number;\n}\n\n/**\n * Whether horizontal scrolling on `element` follows right-to-left inline flow.\n *\n * Resolved from the **computed** `direction`, so the authoring contract is the\n * usual `dir=\"rtl\"` (or a stylesheet) on the element or any ancestor.\n *\n * Scope: horizontal writing modes. A vertical writing mode (`writing-mode:\n * vertical-rl`) also inverts the horizontal axis, which this check does not\n * model — vertical writing modes are out of scope for the scroll utilities\n * (their consumers describe axes as horizontal/vertical, not inline/block).\n */\nexport function isRtl(element: Element): boolean {\n return window.getComputedStyle(element).direction === \"rtl\";\n}\n\n/**\n * Returns scroll distance from the logical start edge.\n *\n * CSSOM View exposes standards-mode RTL horizontal offsets as `0` at the inline\n * start (right) and increasingly negative values toward the inline end (left).\n * The normalized position is always clamped to `[0, max]`, which also absorbs\n * Safari's elastic overscroll values.\n */\nexport function logicalScrollMetrics(\n element: HTMLElement,\n horizontal: boolean,\n): LogicalScrollMetrics {\n const max = Math.max(\n 0,\n horizontal\n ? element.scrollWidth - element.clientWidth\n : element.scrollHeight - element.clientHeight,\n );\n const raw = horizontal ? element.scrollLeft : element.scrollTop;\n const position = horizontal && isRtl(element) ? -raw : raw;\n return { position: Math.min(max, Math.max(0, position)), max };\n}\n\n/**\n * Converts a logical start/end delta to the physical value accepted by\n * `Element.scrollBy`.\n */\nexport function physicalScrollDelta(\n element: HTMLElement,\n horizontal: boolean,\n logicalDelta: number,\n): number {\n return horizontal && isRtl(element) ? -logicalDelta : logicalDelta;\n}\n","import { isRtl } from \"./logical_scroll\";\n\n/**\n * Turns an arrow key into a **logical** step: `+1` for \"next\", `-1` for\n * \"previous\", `0` when the key names neither.\n *\n * APG defines the horizontal pair as *next / previous* and says a vertical\n * arrangement swaps in Down/Up for the same meaning — so the pair is one axis's\n * spelling of an order, and the order reverses with the writing direction. Only\n * the horizontal pair reverses. Down/Up name an axis the writing direction does\n * not mirror, and returning them unchanged is the point: many controllers fold\n * both pairs into one branch, where swapping the branches under RTL would flip\n * the vertical axis too — a bug that reads as \"the arrows work\" until someone\n * presses Down.\n *\n * **Direction is read from the element the caller passes, which should be the\n * container that lays the items out** — not the focused child. A child may carry\n * its own `dir` (an LTR input inside an RTL form is ordinary authoring), and\n * probing per handler makes two handlers disagree at the boundary between them.\n *\n * This decides direction only. Whether the axis is even active (an\n * `orientation=\"horizontal\"` widget ignoring Down/Up), how far the step lands,\n * and what wrapping does all stay with the caller.\n *\n * **It encodes the list-order convention: `ArrowDown` is *next*.** Widgets that\n * pair the arrows by *value* instead — `ArrowUp` meaning \"more\", as a rating or a\n * slider does — must not use this, or their vertical axis inverts. Reverse the\n * horizontal pair on its own there.\n *\n * @example\n * ```ts\n * const step = logicalArrowStep(event.key, this.element);\n * if (step === 0) return;\n * this.#roving.setActive(rovingMove(current, length, step, \"wrap\"), { focus: true });\n * ```\n */\nexport function logicalArrowStep(key: string, element: Element): 1 | -1 | 0 {\n if (key === \"ArrowDown\") return 1;\n if (key === \"ArrowUp\") return -1;\n if (key !== \"ArrowRight\" && key !== \"ArrowLeft\") return 0;\n const forward = isRtl(element) ? \"ArrowLeft\" : \"ArrowRight\";\n return key === forward ? 1 : -1;\n}\n\n/**\n * Rewrites `key` so an existing LTR-shaped branch keeps working under RTL:\n * `ArrowRight` and `ArrowLeft` trade places, everything else passes through.\n *\n * The alternative — negating a delta — silently breaks handlers whose two\n * horizontal branches are **not mirror images**. A grid that clamps one edge but\n * not the other, or a segmented field guarding `index > 0` on one side and\n * `index < length - 1` on the other, ends up applying the wrong guard to the\n * wrong direction. Swapping the key leaves each branch, guards and all, exactly\n * where its author put it.\n *\n * Same rule as {@link logicalArrowStep} about which element to read: pass the\n * container that lays the items out, not the focused child.\n *\n * @example\n * ```ts\n * switch (logicalArrowKey(event.key, this.element)) {\n * case \"ArrowLeft\": // \"previous\" — whatever direction that is on screen\n * ```\n */\nexport function logicalArrowKey(key: string, element: Element): string {\n if (key !== \"ArrowRight\" && key !== \"ArrowLeft\") return key;\n if (!isRtl(element)) return key;\n return key === \"ArrowRight\" ? \"ArrowLeft\" : \"ArrowRight\";\n}\n\n/** Modifiers a widget may claim on an arrow key, named for the `allow` list. */\nexport type ArrowModifier = \"alt\" | \"ctrl\" | \"meta\" | \"shift\";\n\n/**\n * True when an arrow key arrived carrying a modifier the widget must leave to\n * the browser: return without calling `preventDefault()` and without moving any\n * state.\n *\n * A bare arrow belongs to the widget; a chorded one usually does not.\n * `Alt`/`Meta` plus a horizontal arrow is history back/forward on every desktop\n * browser, and a widget that swallows it makes the shortcut work or not\n * depending on where focus happens to sit — a coin-flip the user cannot see.\n *\n * `allow` is for the combinations APG assigns to a pattern **and the widget\n * actually implements** — today only Combobox's optional `Alt+Down`/`Alt+Up`.\n * Listing one the widget does not implement defeats the point: the chord then\n * runs the plain-arrow branch, which is exactly what this guard exists to stop.\n * Non-arrow keys return `false`, so chorded letters and\n * `Control+Home`/`Control+End` are untouched.\n *\n * @example\n * ```ts\n * if (isReservedArrowChord(event)) return;\n * ```\n */\nexport function isReservedArrowChord(\n event: KeyboardEvent,\n allow: readonly ArrowModifier[] = [],\n): boolean {\n if (!event.key.startsWith(\"Arrow\")) return false;\n return (\n (event.altKey && !allow.includes(\"alt\")) ||\n (event.ctrlKey && !allow.includes(\"ctrl\")) ||\n (event.metaKey && !allow.includes(\"meta\")) ||\n (event.shiftKey && !allow.includes(\"shift\"))\n );\n}\n","/**\n * Collapses many Stimulus lifecycle callbacks from one DOM mutation into a\n * single pass.\n *\n * Stimulus fires `<name>TargetConnected` / `Disconnected` once per element and\n * `<name>ValueChanged` once per changed attribute. Replacing a list of N options\n * or morphing several render Values therefore delivers N callbacks — but the\n * useful unit of work is \"reconcile against the resulting declarative input\",\n * once, after the batch has settled. Every controller with reconcilable targets\n * or render Values needs the same shape: a `queued` flag plus `queueMicrotask`.\n *\n * **A microtask is the right horizon, and the reason is specific.** Stimulus\n * drives these callbacks from a `MutationObserver`, whose own callback already\n * runs as a microtask with the whole batch in hand; scheduling one more lands\n * after the last sibling callback of that batch and still before paint or any\n * event handler. A timer would be later than it needs to be, and reconciling\n * synchronously would run once per element against a half-applied DOM.\n *\n * **The two guards are not the same guard.** Scheduling is refused before the\n * controller connects, and running is refused after it disconnects:\n *\n * - **Before `connect()`** — Stimulus delivers initial target and Value callbacks\n * ahead of `connect()`. Reconciling there would compute output against a\n * controller whose own state has not been initialised, and `connect()` is\n * about to do a full pass anyway.\n * - **After `disconnect()`** — Stimulus fires a callback for **every** target\n * during teardown, and a microtask queued just before it would otherwise run\n * against a detached tree. {@link MicrotaskCoalescer.cancel} exists for the\n * teardown path to drop the pending pass outright.\n *\n * Both guards are part of one contract here rather than something each consumer\n * has to remember separately.\n *\n * Scope is the scheduling only. *What* to reconcile — keep the surviving active\n * option, fall back to the next / previous / first visible one, rebuild derived\n * chips or hidden fields — stays in the controller, because no two consumers\n * answer it the same way.\n *\n * This file's own doc block is dropped from `dist`, but every member comment is\n * inlined into each consumer entry (`tsup` builds with `splitting: false`), so\n * rationale belongs here and only the contract belongs on the members.\n *\n * @example\n * ```ts\n * readonly #reconcile = new MicrotaskCoalescer(() => this.#reconcileOptions());\n *\n * connect() { this.#reconcile.activate(); }\n * disconnect() { this.#reconcile.cancel(); }\n *\n * optionTargetConnected() { this.#reconcile.schedule(); }\n * optionTargetDisconnected() { this.#reconcile.schedule(); }\n * ```\n */\nexport class MicrotaskCoalescer {\n readonly #run: () => void;\n #queued = false;\n #active = false;\n #generation = 0;\n\n /** @param run - the single reconciliation pass, invoked at most once per batch. */\n constructor(run: () => void) {\n this.#run = run;\n }\n\n /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */\n activate(): void {\n this.#active = true;\n }\n\n /** Closes the window and drops any pending pass; call from `disconnect()`. */\n cancel(): void {\n this.#active = false;\n this.#queued = false;\n this.#generation += 1;\n }\n\n /** Requests one pass after the batch settles. Idempotent; inert outside the window. */\n schedule(): void {\n if (!this.#active || this.#queued) return;\n this.#queued = true;\n const generation = this.#generation;\n queueMicrotask(() => {\n // A cancelled callback must not consume a pass queued after reconnect.\n if (generation !== this.#generation || !this.#queued || !this.#active) return;\n this.#queued = false;\n this.#run();\n });\n }\n}\n","/**\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. A caller\n * that already captured one event-scoped snapshot may pass it to\n * {@link RovingTabindex.setActive}, avoiding a second live-target scan while\n * preserving one coherent collection for the whole operation.\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 * and `items` to reuse an event-scoped collection snapshot.\n */\n setActive(\n index: number,\n options: { focus?: boolean; items?: readonly HTMLElement[] } = {},\n ): void {\n const { focus = false } = options;\n const items = options.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 { isReservedArrowChord, logicalArrowStep } from \"../utils/arrow_step\";\nimport { MicrotaskCoalescer } from \"../utils/microtask_coalescer\";\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. A slide change the user drove is\n * reported as `stimeo--carousel:change` with `{ index, total }`; the same detail\n * arrives as `stimeo--carousel:reconcile` when removing a slide or picker clamps\n * the active index instead.\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\", \"reconcile\"] 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 #reconcileTargets = new MicrotaskCoalescer(() => this.#reconcileTargetSet());\n /**\n * Whether `connect()` has run. Scheduling is already inert outside that window\n * ({@link MicrotaskCoalescer}), so this only gates the Tab stop a picker\n * present at mount authored for itself.\n */\n #connected = false;\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 /**\n * Renders the initial slide and starts autoplay when requested.\n *\n * `findIndex` makes the authored pre-selection first-wins when several pickers\n * are marked; `#render` then writes an explicit value onto every picker.\n */\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 this.#connected = true;\n this.#reconcileTargets.activate();\n }\n\n /**\n * Re-establishes the single selected picker when one is added after connect.\n *\n * Without this an appended picker that arrives `aria-selected=\"true\"` leaves two\n * marked at once — the authored pre-selection is only read on connect, so\n * nothing else ever resolves the conflict. The current slide is kept: the\n * repaint re-derives every picker from `#index`, so a late arrival never steals\n * the selection.\n */\n pickerTargetConnected(picker: HTMLElement): void {\n if (!this.#connected) return;\n picker.tabIndex = -1;\n this.#reconcileTargets.schedule();\n }\n\n /** Repairs selection and roving after a picker leaves a retained carousel. */\n pickerTargetDisconnected(): void {\n this.#reconcileTargets.schedule();\n }\n\n /** Reconciles a slide added in the same DOM batch as its picker. */\n slideTargetConnected(): void {\n this.#reconcileTargets.schedule();\n }\n\n /** Re-clamps the active index after a slide is removed. */\n slideTargetDisconnected(): void {\n this.#reconcileTargets.schedule();\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.#connected = false;\n this.#reconcileTargets.cancel();\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 // A descendant widget that already claimed the key must not ALSO move the\n // picker focus or change the slide — composition depends on this yield.\n if (event.defaultPrevented) return;\n if (isReservedArrowChord(event)) return;\n const current = this.pickerTargets.indexOf(event.currentTarget as HTMLElement);\n if (current === -1) return;\n\n const length = this.pickerTargets.length;\n // Logical, not physical. The helper reverses only the horizontal\n // pair, so folding Down/Up into the same branch stays correct.\n switch (event.key) {\n case \"ArrowRight\":\n case \"ArrowDown\":\n case \"ArrowLeft\":\n case \"ArrowUp\": {\n event.preventDefault();\n const step = logicalArrowStep(event.key, this.element);\n this.#roving.setActive(rovingMove(current, length, step, \"wrap\"), { focus: true });\n return;\n }\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 /**\n * Reflects `this.#index` onto slides and pickers (state hooks + roving).\n *\n * @stimeoRenderRoot\n */\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 // The picker set can be shorter than the slide set (a picker removed on its\n // own, or a carousel that only pickers part of its slides). Resolving the\n // selection inside the picker range keeps exactly one selected tab and one Tab\n // stop; an out-of-range index would leave every picker at `tabindex=\"-1\"` and\n // strand the tablist outside the Tab sequence.\n const pickerIndex = Math.min(this.#index, this.pickerTargets.length - 1);\n this.pickerTargets.forEach((picker, i) => {\n picker.setAttribute(\"aria-selected\", i === pickerIndex ? \"true\" : \"false\");\n });\n this.#roving.setActive(pickerIndex, { focus });\n }\n\n /** Keeps the live active slide when possible and otherwise selects the nearest survivor. */\n #reconcileTargetSet(): void {\n const activeSlide = this.slideTargets.findIndex(\n (slide) => slide.getAttribute(\"data-state\") === \"active\",\n );\n const selectedPicker = this.pickerTargets.findIndex(\n (picker) => picker.getAttribute(\"aria-selected\") === \"true\",\n );\n const lastSlide = this.slideTargets.length - 1;\n const candidate =\n activeSlide !== -1 ? activeSlide : selectedPicker !== -1 ? selectedPicker : this.#index;\n const previous = this.#index;\n this.#index = lastSlide < 0 ? 0 : Math.min(lastSlide, Math.max(0, candidate));\n this.#render({ focus: false });\n this.#syncTimer();\n // `change` stays reserved for user navigation; a clamp onto the nearest\n // surviving slide is this controller's decision, not the reader's.\n if (this.#index !== previous) {\n this.dispatch(\"reconcile\", {\n detail: { index: this.#index, total: this.slideTargets.length },\n });\n }\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"]}
@@ -23,26 +23,52 @@ import { Controller } from '@hotwired/stimulus';
23
23
  *
24
24
  * @remarks
25
25
  * Behavior only — the check mark and any "mixed" affordance are the consumer's
26
- * CSS (keyed off `:checked` / `:indeterminate` / `data-state`). A lone tri-state
27
- * checkbox can use just `parent` and drive `indeterminate` externally.
26
+ * CSS (keyed off `:checked` / `:indeterminate` / `data-state`). The `parent`
27
+ * target is optional: a child-only group still exposes its aggregate on the root,
28
+ * while a lone tri-state checkbox can use just `parent` and drive
29
+ * `indeterminate` externally — with no `child` target the parent's own `checked`
30
+ * and `indeterminate` stay the consumer's, because only children are
31
+ * authoritative over them. At most one `parent` may be present.
28
32
  *
29
33
  * Behavior provided:
30
34
  * - Parent toggle checks/unchecks every child and clears its own `indeterminate`.
35
+ * All child targets, including disabled ones, participate; omit the target to
36
+ * exclude an input. The cascade does not synthesize native child `change`
37
+ * events — one aggregate custom event describes the action.
31
38
  * - A child change recomputes the parent: all → checked, none → unchecked,
32
39
  * some → `indeterminate`.
33
40
  * - The aggregate (`all` / `partial` / `none`) is mirrored to `data-state` on the
34
- * root, and `stimeo--checkbox:change` is dispatched on every change.
41
+ * root. Dynamic targets, checked-attribute changes, Turbo morphs, and native
42
+ * form resets are reconciled from the children. Callers that assign the live
43
+ * `checked` property directly must dispatch `change`, because property writes
44
+ * are not observable by a `MutationObserver`.
45
+ * - `stimeo--checkbox:change` is dispatched for the two public change actions;
46
+ * `stimeo--checkbox:reconcile` is dispatched instead when a reconciliation —
47
+ * not the user — moves the aggregate. Both carry `{ checked: boolean,
48
+ * indeterminate: boolean, state: "all" | "partial" | "none" }` and describe
49
+ * the aggregate even without a parent. Neither fires on connect.
35
50
  */
36
51
  declare class CheckboxController extends Controller<HTMLElement> {
37
52
  #private;
38
53
  static targets: string[];
39
54
  static actions: readonly ["onChildChange", "onParentChange"];
40
- static events: readonly ["change"];
55
+ static events: readonly ["change", "reconcile"];
41
56
  readonly parentTarget: HTMLInputElement;
57
+ readonly parentTargets: HTMLInputElement[];
42
58
  readonly hasParentTarget: boolean;
43
59
  readonly childTargets: HTMLInputElement[];
44
- /** Reflects the initial aggregate (e.g. from server-rendered child states). */
60
+ /** Reflects the initial aggregate and starts retained-DOM reconciliation. */
45
61
  connect(): void;
62
+ /** Releases the observer, global reset listener, and every pending reconciliation. */
63
+ disconnect(): void;
64
+ /** Reconciles the aggregate for a parent added or replaced at runtime. */
65
+ parentTargetConnected(): void;
66
+ /** Reconciles the aggregate after a parent target leaves the group. */
67
+ parentTargetDisconnected(): void;
68
+ /** Reconciles the aggregate for a child added at runtime. */
69
+ childTargetConnected(): void;
70
+ /** Reconciles the aggregate after a child leaves the group. */
71
+ childTargetDisconnected(): void;
46
72
  /** Cascades the parent's state to every child. Bound via `data-action` (change). */
47
73
  onParentChange(): void;
48
74
  /** Recomputes the parent from its children. Bound via `data-action` (change). */
@@ -1,17 +1,89 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
+ // src/controllers/checkbox_controller.ts
4
+
5
+ // src/utils/microtask_coalescer.ts
6
+ var MicrotaskCoalescer = class {
7
+ #run;
8
+ #queued = false;
9
+ #active = false;
10
+ #generation = 0;
11
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
12
+ constructor(run) {
13
+ this.#run = run;
14
+ }
15
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
16
+ activate() {
17
+ this.#active = true;
18
+ }
19
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
20
+ cancel() {
21
+ this.#active = false;
22
+ this.#queued = false;
23
+ this.#generation += 1;
24
+ }
25
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
26
+ schedule() {
27
+ if (!this.#active || this.#queued) return;
28
+ this.#queued = true;
29
+ const generation = this.#generation;
30
+ queueMicrotask(() => {
31
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
32
+ this.#queued = false;
33
+ this.#run();
34
+ });
35
+ }
36
+ };
37
+
3
38
  // src/controllers/checkbox_controller.ts
4
39
  var CheckboxController = class extends Controller {
5
40
  static targets = ["parent", "child"];
6
41
  static actions = ["onChildChange", "onParentChange"];
7
- static events = ["change"];
8
- /** Reflects the initial aggregate (e.g. from server-rendered child states). */
9
- connect() {
10
- if (this.childTargets.length > 0) {
11
- this.#syncFromChildren(false);
12
- } else {
13
- this.element.setAttribute("data-state", this.#aggregate());
42
+ static events = ["change", "reconcile"];
43
+ /** Collapses every lifecycle signal from one DOM update into one derived pass. */
44
+ #reconcile = new MicrotaskCoalescer(() => this.#reconcileFromChildren());
45
+ /** Aggregate this root last settled on, so a derived repair is reported once. */
46
+ #committedState = null;
47
+ /** Watches authored checked-attribute changes on retained target elements. */
48
+ #checkedObserver = new MutationObserver((records) => {
49
+ if (records.some((record) => this.#isManagedCheckbox(record.target))) {
50
+ this.#reconcile.schedule();
14
51
  }
52
+ });
53
+ /** Reflects the initial aggregate and starts retained-DOM reconciliation. */
54
+ connect() {
55
+ this.#reconcile.activate();
56
+ this.#syncFromChildren();
57
+ this.#checkedObserver.observe(this.element, {
58
+ attributes: true,
59
+ attributeFilter: ["checked"],
60
+ subtree: true
61
+ });
62
+ this.element.addEventListener("turbo:morph-element", this.#onMorph);
63
+ document.addEventListener("reset", this.#onReset, true);
64
+ }
65
+ /** Releases the observer, global reset listener, and every pending reconciliation. */
66
+ disconnect() {
67
+ this.#reconcile.cancel();
68
+ this.#checkedObserver.disconnect();
69
+ this.element.removeEventListener("turbo:morph-element", this.#onMorph);
70
+ document.removeEventListener("reset", this.#onReset, true);
71
+ }
72
+ /** Reconciles the aggregate for a parent added or replaced at runtime. */
73
+ parentTargetConnected() {
74
+ this.#reconcile.schedule();
75
+ }
76
+ /** Reconciles the aggregate after a parent target leaves the group. */
77
+ parentTargetDisconnected() {
78
+ this.#reconcile.schedule();
79
+ }
80
+ /** Reconciles the aggregate for a child added at runtime. */
81
+ childTargetConnected() {
82
+ this.#reconcile.schedule();
83
+ }
84
+ /** Reconciles the aggregate after a child leaves the group. */
85
+ childTargetDisconnected() {
86
+ this.#reconcile.schedule();
15
87
  }
16
88
  /** Cascades the parent's state to every child. Bound via `data-action` (change). */
17
89
  onParentChange() {
@@ -20,35 +92,74 @@ var CheckboxController = class extends Controller {
20
92
  for (const child of this.childTargets) {
21
93
  child.checked = checked;
22
94
  }
23
- this.parentTarget.indeterminate = false;
24
- const state = checked ? "all" : "none";
25
- this.element.setAttribute("data-state", state);
26
- this.dispatch("change", { detail: { checked, indeterminate: false, state } });
95
+ this.#reflect(checked ? "all" : "none", true);
96
+ const detail = this.#settledDetail();
97
+ if (detail) this.dispatch("change", { detail });
27
98
  }
28
99
  /** Recomputes the parent from its children. Bound via `data-action` (change). */
29
100
  onChildChange() {
30
- this.#syncFromChildren(true);
101
+ this.#syncFromChildren();
102
+ const detail = this.#settledDetail();
103
+ if (detail) this.dispatch("change", { detail });
104
+ }
105
+ /**
106
+ * Announces an aggregate this pass derived rather than the user. `change` stays
107
+ * reserved for the two public actions, so automation never reads a repair as an edit.
108
+ */
109
+ #reconcileFromChildren() {
110
+ const previous = this.#committedState;
111
+ this.#syncFromChildren();
112
+ if (this.#committedState === previous) return;
113
+ const detail = this.#settledDetail();
114
+ if (detail) this.dispatch("reconcile", { detail });
31
115
  }
32
116
  /**
33
117
  * Derives the parent's `checked`/`indeterminate` and the root `data-state` from
34
- * the children, optionally dispatching `change`.
118
+ * the children. Writing state and reporting it are separate so the caller — not
119
+ * a flag threaded through the write — decides which event describes the cause.
35
120
  */
36
- #syncFromChildren(dispatch) {
37
- const state = this.#aggregate();
38
- if (this.hasParentTarget) {
121
+ #syncFromChildren() {
122
+ this.#reflect(this.#aggregate(), this.childTargets.length > 0);
123
+ }
124
+ /**
125
+ * Reflects one aggregate state.
126
+ *
127
+ * @param writeParent - whether the state is authoritative over the `parent`
128
+ * target's own `checked` / `indeterminate`.
129
+ */
130
+ #reflect(state, writeParent) {
131
+ if (writeParent && this.hasParentTarget) {
39
132
  this.parentTarget.checked = state === "all";
40
133
  this.parentTarget.indeterminate = state === "partial";
41
134
  }
42
135
  this.element.setAttribute("data-state", state);
43
- if (dispatch) {
44
- this.dispatch("change", {
45
- detail: {
46
- checked: this.hasParentTarget ? this.parentTarget.checked : state === "all",
47
- indeterminate: this.hasParentTarget ? this.parentTarget.indeterminate : false,
48
- state
49
- }
50
- });
51
- }
136
+ this.#committedState = state;
137
+ }
138
+ /** The settled aggregate as event detail, or `null` before anything has settled. */
139
+ #settledDetail() {
140
+ const state = this.#committedState;
141
+ if (state === null) return null;
142
+ return { checked: state === "all", indeterminate: state === "partial", state };
143
+ }
144
+ /** Reconciles retained targets after Turbo has finished morphing their live state. */
145
+ #onMorph = () => {
146
+ this.#reconcile.schedule();
147
+ };
148
+ /** Reconciles after a non-cancelled reset restores any managed checkbox. */
149
+ #onReset = (event) => {
150
+ const form = event.target;
151
+ if (!(form instanceof HTMLFormElement) || !this.#hasCheckboxOwnedBy(form)) return;
152
+ queueMicrotask(() => {
153
+ if (!event.defaultPrevented) this.#reconcile.schedule();
154
+ });
155
+ };
156
+ /** Whether a form owns at least one current parent or child target. */
157
+ #hasCheckboxOwnedBy(form) {
158
+ return [...this.parentTargets, ...this.childTargets].some((checkbox) => checkbox.form === form);
159
+ }
160
+ /** Whether an observed attribute mutation belongs to this controller's target set. */
161
+ #isManagedCheckbox(node) {
162
+ return this.parentTargets.some((checkbox) => checkbox === node) || this.childTargets.some((checkbox) => checkbox === node);
52
163
  }
53
164
  /**
54
165
  * Computes the aggregate state. With children it counts them; with none it