stimeo-ui 0.7.0 → 0.9.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 (77) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/dist/controllers/auto_submit_controller.d.ts +14 -3
  3. package/dist/controllers/auto_submit_controller.js +94 -16
  4. package/dist/controllers/auto_submit_controller.js.map +1 -1
  5. package/dist/controllers/bulk_select_controller.d.ts +62 -15
  6. package/dist/controllers/bulk_select_controller.js +139 -28
  7. package/dist/controllers/bulk_select_controller.js.map +1 -1
  8. package/dist/controllers/carousel_controller.d.ts +81 -47
  9. package/dist/controllers/carousel_controller.js +451 -100
  10. package/dist/controllers/carousel_controller.js.map +1 -1
  11. package/dist/controllers/clipboard_controller.d.ts +48 -15
  12. package/dist/controllers/clipboard_controller.js +102 -20
  13. package/dist/controllers/clipboard_controller.js.map +1 -1
  14. package/dist/controllers/color_picker_controller.d.ts +37 -6
  15. package/dist/controllers/color_picker_controller.js +180 -43
  16. package/dist/controllers/color_picker_controller.js.map +1 -1
  17. package/dist/controllers/currency_input_controller.d.ts +39 -5
  18. package/dist/controllers/currency_input_controller.js +305 -74
  19. package/dist/controllers/currency_input_controller.js.map +1 -1
  20. package/dist/controllers/data_grid_controller.d.ts +25 -9
  21. package/dist/controllers/data_grid_controller.js +150 -24
  22. package/dist/controllers/data_grid_controller.js.map +1 -1
  23. package/dist/controllers/direct_upload_controller.d.ts +3 -1
  24. package/dist/controllers/direct_upload_controller.js +12 -2
  25. package/dist/controllers/direct_upload_controller.js.map +1 -1
  26. package/dist/controllers/editable_controller.d.ts +34 -9
  27. package/dist/controllers/editable_controller.js +83 -30
  28. package/dist/controllers/editable_controller.js.map +1 -1
  29. package/dist/controllers/file_dropzone_controller.d.ts +123 -29
  30. package/dist/controllers/file_dropzone_controller.js +386 -63
  31. package/dist/controllers/file_dropzone_controller.js.map +1 -1
  32. package/dist/controllers/filter_controller.d.ts +15 -3
  33. package/dist/controllers/filter_controller.js +32 -1
  34. package/dist/controllers/filter_controller.js.map +1 -1
  35. package/dist/controllers/flash_controller.d.ts +3 -1
  36. package/dist/controllers/flash_controller.js +3 -1
  37. package/dist/controllers/flash_controller.js.map +1 -1
  38. package/dist/controllers/frame_loading_controller.d.ts +4 -2
  39. package/dist/controllers/frame_loading_controller.js +2 -1
  40. package/dist/controllers/frame_loading_controller.js.map +1 -1
  41. package/dist/controllers/input_mask_controller.d.ts +42 -16
  42. package/dist/controllers/input_mask_controller.js +251 -76
  43. package/dist/controllers/input_mask_controller.js.map +1 -1
  44. package/dist/controllers/masonry_controller.d.ts +34 -5
  45. package/dist/controllers/masonry_controller.js +129 -17
  46. package/dist/controllers/masonry_controller.js.map +1 -1
  47. package/dist/controllers/nested_form_controller.d.ts +51 -14
  48. package/dist/controllers/nested_form_controller.js +450 -42
  49. package/dist/controllers/nested_form_controller.js.map +1 -1
  50. package/dist/controllers/otp_controller.d.ts +64 -25
  51. package/dist/controllers/otp_controller.js +485 -114
  52. package/dist/controllers/otp_controller.js.map +1 -1
  53. package/dist/controllers/reset_before_cache_controller.d.ts +25 -2
  54. package/dist/controllers/reset_before_cache_controller.js +51 -5
  55. package/dist/controllers/reset_before_cache_controller.js.map +1 -1
  56. package/dist/controllers/resizable_controller.d.ts +23 -7
  57. package/dist/controllers/resizable_controller.js +128 -55
  58. package/dist/controllers/resizable_controller.js.map +1 -1
  59. package/dist/controllers/spinner_controller.d.ts +3 -2
  60. package/dist/controllers/spinner_controller.js +7 -5
  61. package/dist/controllers/spinner_controller.js.map +1 -1
  62. package/dist/controllers/submit_once_controller.d.ts +4 -1
  63. package/dist/controllers/submit_once_controller.js +3 -1
  64. package/dist/controllers/submit_once_controller.js.map +1 -1
  65. package/dist/controllers/textarea_autosize_controller.d.ts +30 -10
  66. package/dist/controllers/textarea_autosize_controller.js +131 -3
  67. package/dist/controllers/textarea_autosize_controller.js.map +1 -1
  68. package/dist/index.js +2764 -923
  69. package/dist/index.js.map +1 -1
  70. package/dist/inspector/cli.d.ts +8 -1
  71. package/dist/inspector/cli.js +5 -1
  72. package/dist/inspector/cli.js.map +1 -1
  73. package/dist/inspector/cli_bin.js +5 -1
  74. package/dist/inspector/cli_bin.js.map +1 -1
  75. package/dist/inspector/examples.json +24 -24
  76. package/dist/inspector/manifest.json +193 -30
  77. package/package.json +2 -2
@@ -1,72 +1,189 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
+ // src/controllers/bulk_select_controller.ts
4
+
5
+ // src/utils/announce.ts
6
+ function announce(message, options = {}) {
7
+ const text = message.trim();
8
+ if (text.length === 0) return;
9
+ window.dispatchEvent(
10
+ new CustomEvent("stimeo--announcer:announce", {
11
+ detail: { message: text, assertive: options.assertive === true }
12
+ })
13
+ );
14
+ }
15
+ function fillTemplate(template, values) {
16
+ return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
17
+ const replacement = values[name];
18
+ return replacement === void 0 ? match : String(replacement);
19
+ });
20
+ }
21
+
22
+ // src/utils/microtask_coalescer.ts
23
+ var MicrotaskCoalescer = class {
24
+ #run;
25
+ #queued = false;
26
+ #active = false;
27
+ #generation = 0;
28
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
29
+ constructor(run) {
30
+ this.#run = run;
31
+ }
32
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
33
+ activate() {
34
+ this.#active = true;
35
+ }
36
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
37
+ cancel() {
38
+ this.#active = false;
39
+ this.#queued = false;
40
+ this.#generation += 1;
41
+ }
42
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
43
+ schedule() {
44
+ if (!this.#active || this.#queued) return;
45
+ this.#queued = true;
46
+ const generation = this.#generation;
47
+ queueMicrotask(() => {
48
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
49
+ this.#queued = false;
50
+ this.#run();
51
+ });
52
+ }
53
+ };
54
+
3
55
  // src/controllers/bulk_select_controller.ts
4
56
  var BulkSelectController = class extends Controller {
5
57
  static targets = ["all", "item", "bar", "count", "selectAllPages"];
6
58
  static values = {
7
59
  totalCount: { type: Number, default: 0 },
8
- announce: { type: Boolean, default: true }
60
+ announceText: { type: String, default: "" }
9
61
  };
10
62
  static actions = ["clear", "selectAllPages"];
11
- static events = ["change"];
12
- /** All-pages mode is a transient UI state; mirrored to `data-all-pages` so it
13
- * survives a Turbo swap and `connect()` can rehydrate it. */
63
+ static events = ["change", "reconcile"];
64
+ /** All-pages mode is a transient UI state, mirrored to `data-all-pages` so a
65
+ * `connect()` over markup that already carries the attribute rehydrates the
66
+ * mode — a morph, a Turbo Stream, or a server that renders it back. A restore
67
+ * visit serves the server's markup instead, so the mode does not survive one. */
14
68
  #allPagesMode = false;
15
- /** Last emitted figures, so a recompute dispatches `change` only on real change. */
69
+ /** Last emitted figures, so a recompute reports only on a real change. */
16
70
  #lastCount = -1;
17
71
  #lastAllPages = false;
72
+ /** Collapses every signal from one DOM or Value update into one repair. */
73
+ #reconcile = new MicrotaskCoalescer(() => this.#reconcileNow());
18
74
  /** Delegated `change` handler covering the select-all box and every row. */
19
75
  #onChange = (event) => {
20
76
  const target = event.target;
21
77
  if (!target) return;
22
78
  if (this.hasAllTarget && target === this.allTarget) {
23
79
  this.#applyAll();
24
- } else if (target.matches('[data-stimeo--bulk-select-target="item"]')) {
80
+ } else if (this.itemTargets.some((item) => item === target)) {
25
81
  this.#exitAllPages();
26
- this.#recompute(true);
82
+ this.#reportChange(this.#recompute());
27
83
  }
28
84
  };
29
85
  connect() {
30
86
  this.#allPagesMode = this.element.dataset.allPages === "true";
87
+ this.#reconcile.activate();
31
88
  this.element.addEventListener("change", this.#onChange);
32
- this.#recompute(false);
89
+ this.#recompute();
33
90
  }
34
91
  disconnect() {
35
92
  this.element.removeEventListener("change", this.#onChange);
93
+ this.#reconcile.cancel();
94
+ }
95
+ /** Repairs the figures for a row that arrived at runtime. */
96
+ itemTargetConnected() {
97
+ this.#reconcile.schedule();
98
+ }
99
+ /** Repairs the figures after a row leaves, so a removed selection stops counting. */
100
+ itemTargetDisconnected() {
101
+ this.#reconcile.schedule();
102
+ }
103
+ /** Reflects the current selection onto a select-all box added at runtime. */
104
+ allTargetConnected() {
105
+ this.#reconcile.schedule();
106
+ }
107
+ /** Repairs the figures after the select-all box leaves. */
108
+ allTargetDisconnected() {
109
+ this.#reconcile.schedule();
110
+ }
111
+ /** Repaints the count for a total that changed at runtime, rejecting non-finite ones. */
112
+ totalCountValueChanged() {
113
+ if (!Number.isFinite(this.totalCountValue)) {
114
+ this.totalCountValue = 0;
115
+ return;
116
+ }
117
+ this.#reconcile.schedule();
118
+ }
119
+ /** Repaints so wording changed at runtime is used by the next announcement. */
120
+ announceTextValueChanged() {
121
+ this.#reconcile.schedule();
36
122
  }
37
123
  /** Clears every selection (rows + select-all) and exits all-pages mode. */
38
124
  clear() {
39
- for (const item of this.#items) item.checked = false;
125
+ for (const item of this.itemTargets) item.checked = false;
40
126
  if (this.hasAllTarget) {
41
127
  this.allTarget.checked = false;
42
128
  this.allTarget.indeterminate = false;
43
129
  }
44
130
  this.#exitAllPages();
45
- this.#recompute(true);
131
+ this.#reportChange(this.#recompute());
46
132
  }
47
- /** Enters "select all across pages" mode (count shows `totalCount`). */
133
+ /**
134
+ * Enters "select all across pages" mode: the count shows `totalCount`, and every
135
+ * row on this page is checked.
136
+ *
137
+ * The mode's claim is that the whole set is selected, so leaving a visible row
138
+ * unchecked would put the page and the count in open disagreement.
139
+ */
48
140
  selectAllPages() {
49
141
  this.#allPagesMode = true;
50
- this.#recompute(true);
142
+ this.#checkEveryRow();
143
+ this.#reportChange(this.#recompute());
144
+ }
145
+ /** Marks every row on this page selected. */
146
+ #checkEveryRow() {
147
+ for (const item of this.itemTargets) item.checked = true;
51
148
  }
52
149
  /** Mirrors the select-all box to every row, then recomputes. */
53
150
  #applyAll() {
54
151
  if (!this.hasAllTarget) return;
55
152
  const { checked } = this.allTarget;
56
- for (const item of this.#items) item.checked = checked;
153
+ for (const item of this.itemTargets) item.checked = checked;
57
154
  this.#exitAllPages();
58
- this.#recompute(true);
155
+ this.#reportChange(this.#recompute());
59
156
  }
60
157
  #exitAllPages() {
61
158
  this.#allPagesMode = false;
62
159
  }
160
+ /** Repairs the derived state after the page moved rows or a render input. */
161
+ #reconcileNow() {
162
+ if (this.#allPagesMode) this.#checkEveryRow();
163
+ const detail = this.#recompute();
164
+ if (!detail) return;
165
+ this.dispatch("reconcile", { detail });
166
+ this.#announce(detail);
167
+ }
168
+ /** Reports a selection the user moved. */
169
+ #reportChange(detail) {
170
+ if (!detail) return;
171
+ this.dispatch("change", { detail });
172
+ this.#announce(detail);
173
+ }
174
+ /** Hands the count to the shared announcer, worded by the consumer. */
175
+ #announce(detail) {
176
+ announce(fillTemplate(this.announceTextValue, { count: detail.count }));
177
+ }
63
178
  /**
64
179
  * Recomputes the count, the select-all checked/indeterminate state, and the bar
65
- * visibility from the current DOM. Dispatches `change` (when `notify`) only if
66
- * the emitted count or all-pages flag actually changed.
180
+ * visibility from the current DOM. Returns the figures when the emitted count or
181
+ * all-pages flag actually moved, and `null` when they did not.
182
+ *
183
+ * @stimeoRenderRoot
67
184
  */
68
- #recompute(notify) {
69
- const items = this.#items;
185
+ #recompute() {
186
+ const items = this.itemTargets;
70
187
  const total = items.length;
71
188
  const checked = items.filter((item) => item.checked).length;
72
189
  const allPages = this.#allPagesMode;
@@ -77,8 +194,10 @@ var BulkSelectController = class extends Controller {
77
194
  const count = allPages ? this.totalCountValue : checked;
78
195
  const show = allPages || checked > 0;
79
196
  if (this.hasBarTarget) {
197
+ if (!show && this.hasAllTarget && this.barTarget.contains(document.activeElement)) {
198
+ this.allTarget.focus();
199
+ }
80
200
  this.barTarget.hidden = !show;
81
- this.barTarget.setAttribute("aria-live", this.announceValue ? "polite" : "off");
82
201
  }
83
202
  if (this.hasCountTarget) this.countTarget.textContent = String(count);
84
203
  this.element.setAttribute("data-selected-count", String(checked));
@@ -87,15 +206,7 @@ var BulkSelectController = class extends Controller {
87
206
  const changed = count !== this.#lastCount || allPages !== this.#lastAllPages;
88
207
  this.#lastCount = count;
89
208
  this.#lastAllPages = allPages;
90
- if (notify && changed) {
91
- this.dispatch("change", { detail: { count, allPages } });
92
- }
93
- }
94
- /** Live list of row checkboxes, queried from the DOM so dynamic rows count. */
95
- get #items() {
96
- return Array.from(
97
- this.element.querySelectorAll('[data-stimeo--bulk-select-target="item"]')
98
- );
209
+ return changed ? { count, allPages } : null;
99
210
  }
100
211
  };
101
212
 
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/controllers/bulk_select_controller.ts"],"names":[],"mappings":"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","file":"bulk_select_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless **bulk select / batch action bar** (no dedicated APG pattern — a\n * composition of a checkbox group and a toolbar). Mirrors the Gmail/admin UX where\n * checking one or more rows reveals a sticky action bar with the selected count,\n * select-all, and clear. Data Grid owns per-row `aria-selected`; this is the\n * contextual action-bar layer on top.\n *\n * Markup contract (identifier: `stimeo--bulk-select`):\n * <div data-controller=\"stimeo--bulk-select\"\n * data-stimeo--bulk-select-total-count-value=\"128\">\n * <input type=\"checkbox\" data-stimeo--bulk-select-target=\"all\">\n * <!-- rows (may be added dynamically; handled via event delegation) -->\n * <input type=\"checkbox\" data-stimeo--bulk-select-target=\"item\">\n * <input type=\"checkbox\" data-stimeo--bulk-select-target=\"item\">\n * <div data-stimeo--bulk-select-target=\"bar\" hidden role=\"toolbar\" aria-live=\"polite\">\n * <span data-stimeo--bulk-select-target=\"count\"></span> selected\n * <button data-stimeo--bulk-select-target=\"selectAllPages\"\n * data-action=\"click->stimeo--bulk-select#selectAllPages\">Select all</button>\n * <button data-action=\"click->stimeo--bulk-select#clear\">Clear</button>\n * </div>\n * </div>\n *\n * `change` dispatches `{ count, allPages }`.\n *\n * @remarks\n * Behavior only — it never runs the batch action (that is the consumer's\n * form/Turbo) nor fetches/pages rows. Selection lives **only** in each checkbox's\n * `checked` (no module-scope set), so `connect()` recomputes idempotently from the\n * DOM after a Turbo swap. Row `change` is handled by **delegation** on the\n * container, so dynamically-added rows work without per-row `data-action`.\n * Showing the bar never steals focus\n * (WCAG 2.2 2.4.3); the count rides the bar's own `aria-live` region (WCAG 2.2\n * 4.1.3) — the bar is revealed *before* the count text is written so the change is\n * observed and announced. The delegated listener is removed on `disconnect()`.\n */\nexport class BulkSelectController extends Controller<HTMLElement> {\n static override targets = [\"all\", \"item\", \"bar\", \"count\", \"selectAllPages\"];\n static override values = {\n totalCount: { type: Number, default: 0 },\n announce: { type: Boolean, default: true },\n };\n static actions = [\"clear\", \"selectAllPages\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly allTarget: HTMLInputElement;\n declare readonly barTarget: HTMLElement;\n declare readonly countTarget: HTMLElement;\n declare readonly hasAllTarget: boolean;\n declare readonly hasBarTarget: boolean;\n declare readonly hasCountTarget: boolean;\n\n declare totalCountValue: number;\n declare announceValue: boolean;\n\n /** All-pages mode is a transient UI state; mirrored to `data-all-pages` so it\n * survives a Turbo swap and `connect()` can rehydrate it. */\n #allPagesMode = false;\n /** Last emitted figures, so a recompute dispatches `change` only on real change. */\n #lastCount = -1;\n #lastAllPages = false;\n\n /** Delegated `change` handler covering the select-all box and every row. */\n readonly #onChange = (event: Event): void => {\n const target = event.target as HTMLElement | null;\n if (!target) return;\n if (this.hasAllTarget && target === this.allTarget) {\n this.#applyAll();\n } else if (target.matches('[data-stimeo--bulk-select-target=\"item\"]')) {\n this.#exitAllPages();\n this.#recompute(true);\n }\n };\n\n override connect(): void {\n this.#allPagesMode = this.element.dataset.allPages === \"true\";\n this.element.addEventListener(\"change\", this.#onChange);\n this.#recompute(false);\n }\n\n override disconnect(): void {\n this.element.removeEventListener(\"change\", this.#onChange);\n }\n\n /** Clears every selection (rows + select-all) and exits all-pages mode. */\n clear(): void {\n for (const item of this.#items) item.checked = false;\n if (this.hasAllTarget) {\n this.allTarget.checked = false;\n this.allTarget.indeterminate = false;\n }\n this.#exitAllPages();\n this.#recompute(true);\n }\n\n /** Enters \"select all across pages\" mode (count shows `totalCount`). */\n selectAllPages(): void {\n this.#allPagesMode = true;\n this.#recompute(true);\n }\n\n /** Mirrors the select-all box to every row, then recomputes. */\n #applyAll(): void {\n if (!this.hasAllTarget) return;\n const { checked } = this.allTarget;\n for (const item of this.#items) item.checked = checked;\n this.#exitAllPages();\n this.#recompute(true);\n }\n\n #exitAllPages(): void {\n this.#allPagesMode = false;\n }\n\n /**\n * Recomputes the count, the select-all checked/indeterminate state, and the bar\n * visibility from the current DOM. Dispatches `change` (when `notify`) only if\n * the emitted count or all-pages flag actually changed.\n */\n #recompute(notify: boolean): void {\n const items = this.#items;\n const total = items.length;\n const checked = items.filter((item) => item.checked).length;\n const allPages = this.#allPagesMode;\n\n if (this.hasAllTarget) {\n this.allTarget.checked = total > 0 && checked === total;\n this.allTarget.indeterminate = checked > 0 && checked < total;\n }\n\n const count = allPages ? this.totalCountValue : checked;\n const show = allPages || checked > 0;\n\n // Reveal the bar BEFORE writing the count so its aria-live region observes the\n // text change (a region revealed after its content changed may not announce).\n if (this.hasBarTarget) {\n this.barTarget.hidden = !show;\n this.barTarget.setAttribute(\"aria-live\", this.announceValue ? \"polite\" : \"off\");\n }\n if (this.hasCountTarget) this.countTarget.textContent = String(count);\n\n this.element.setAttribute(\"data-selected-count\", String(checked));\n if (allPages) this.element.setAttribute(\"data-all-pages\", \"true\");\n else this.element.removeAttribute(\"data-all-pages\");\n\n const changed = count !== this.#lastCount || allPages !== this.#lastAllPages;\n this.#lastCount = count;\n this.#lastAllPages = allPages;\n if (notify && changed) {\n this.dispatch(\"change\", { detail: { count, allPages } });\n }\n }\n\n /** Live list of row checkboxes, queried from the DOM so dynamic rows count. */\n get #items(): HTMLInputElement[] {\n return Array.from(\n this.element.querySelectorAll<HTMLInputElement>('[data-stimeo--bulk-select-target=\"item\"]'),\n );\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/announce.ts","../../src/utils/microtask_coalescer.ts","../../src/controllers/bulk_select_controller.ts"],"names":[],"mappings":"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","file":"bulk_select_controller.js","sourcesContent":["/**\n * Sends one message to the page's shared `stimeo--announcer`.\n *\n * A component that has to reach assistive tech does not carry a live region of its\n * own: a region only announces what changes *after* assistive tech already knows\n * about it, which a region that appears (or is un-hidden) with its message cannot\n * satisfy. The one region that can is the announcer sitting in the page from the\n * start, so state changes are handed to it as an event and it does the reading.\n *\n * The event goes to `window` because the announcer is usually a sibling high in the\n * document rather than an ancestor of the component dispatching it.\n *\n * Wording comes from the consumer — the library ships no English strings — so an\n * empty message is silently dropped and nothing is announced.\n *\n * @example\n * ```ts\n * announce(this.announceTextValue, { assertive: false });\n * ```\n */\nexport function announce(message: string, options: { assertive?: boolean } = {}): void {\n const text = message.trim();\n if (text.length === 0) return;\n window.dispatchEvent(\n new CustomEvent(\"stimeo--announcer:announce\", {\n detail: { message: text, assertive: options.assertive === true },\n }),\n );\n}\n\n/**\n * Fills `{name}` placeholders in an announcement template from `values`.\n *\n * The same substitution the value-text templates use, so a consumer writes\n * `\"{percent}% complete\"` in one attribute and gets the same rules everywhere. A\n * placeholder with no matching entry is left as authored rather than blanked, which\n * keeps a typo visible instead of silently swallowing the word.\n */\nexport function fillTemplate(template: string, values: Record<string, string | number>): string {\n return template.replace(/\\{([a-zA-Z][a-zA-Z0-9]*)\\}/g, (match, name: string) => {\n const replacement = values[name];\n return replacement === undefined ? match : String(replacement);\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","import { Controller } from \"@hotwired/stimulus\";\nimport { announce, fillTemplate } from \"../utils/announce\";\nimport { MicrotaskCoalescer } from \"../utils/microtask_coalescer\";\n\n/** The selection figures every report carries. */\ntype SelectionDetail = { count: number; allPages: boolean };\n\n/**\n * Headless **bulk select / batch action bar** (no dedicated APG pattern — a\n * composition of a checkbox group and a toolbar). Mirrors the Gmail/admin UX where\n * checking one or more rows reveals a sticky action bar with the selected count,\n * select-all, and clear. Data Grid owns per-row `aria-selected`; this is the\n * contextual action-bar layer on top.\n *\n * Markup contract (identifier: `stimeo--bulk-select`):\n * <div data-controller=\"stimeo--bulk-select\"\n * data-stimeo--bulk-select-total-count-value=\"128\"\n * data-stimeo--bulk-select-announce-text-value=\"{count} selected\">\n * <input type=\"checkbox\" data-stimeo--bulk-select-target=\"all\">\n * <!-- rows (may be added dynamically; handled via event delegation) -->\n * <input type=\"checkbox\" data-stimeo--bulk-select-target=\"item\">\n * <input type=\"checkbox\" data-stimeo--bulk-select-target=\"item\">\n * <div data-stimeo--bulk-select-target=\"bar\" hidden role=\"toolbar\"\n * data-controller=\"stimeo--toolbar\" aria-label=\"Bulk actions\">\n * <span data-stimeo--bulk-select-target=\"count\"></span> selected\n * <button data-stimeo--bulk-select-target=\"selectAllPages\"\n * data-stimeo--toolbar-target=\"control\"\n * data-action=\"click->stimeo--bulk-select#selectAllPages\">Select all</button>\n * <button data-stimeo--toolbar-target=\"control\"\n * data-action=\"click->stimeo--bulk-select#clear\">Clear</button>\n * </div>\n * </div>\n *\n * The bar carries `role=\"toolbar\"`, so it is composed with `stimeo--toolbar` to get\n * the arrow-key movement and single tab stop that role calls for.\n *\n * `change` and `reconcile` both dispatch `{ count, allPages }`.\n *\n * Selection is two-stage. The select-all box covers the rows on this page; the\n * optional `selectAllPages` control extends the selection to `totalCount` rows\n * across every page, checking each row here as it does so. Touching any single row\n * leaves that mode, because the selection is no longer the whole set.\n *\n * @remarks\n * Behavior only — it never runs the batch action (that is the consumer's\n * form/Turbo) nor fetches/pages rows. Selection lives **only** in each checkbox's\n * `checked` (no module-scope set), so `connect()` recomputes idempotently from the\n * DOM after a Turbo swap. Row `change` is handled by **delegation** on the\n * container, so dynamically-added rows work without per-row `data-action`, while\n * rows arriving or leaving on their own are picked up by the target callbacks and\n * coalesced into one repair per batch.\n *\n * `change` is dispatched when the user moves the selection; `reconcile` when the\n * controller repairs the figures itself — a row added or removed by the page, or a\n * render input changing at runtime. Both carry the same detail.\n *\n * The count reaches assistive tech through the page's shared `stimeo--announcer`,\n * worded by the consumer via `announceText` (`{count}` expands to the figure being\n * shown; an empty template stays silent, keeping announcements opt-in and\n * i18n-neutral).\n *\n * Showing the bar never steals focus (WCAG 2.2 2.4.3). Hiding it hands focus to\n * the select-all box first when the bar holds it, so the keyboard user keeps a Tab\n * position instead of falling back to the document.\n *\n * A non-finite `totalCount` falls back to the Value's default and is written back\n * to the attribute, so the count never renders as `NaN`.\n *\n * The delegated listener and the pending repair are both released on `disconnect()`.\n */\nexport class BulkSelectController extends Controller<HTMLElement> {\n static override targets = [\"all\", \"item\", \"bar\", \"count\", \"selectAllPages\"];\n static override values = {\n totalCount: { type: Number, default: 0 },\n announceText: { type: String, default: \"\" },\n };\n static actions = [\"clear\", \"selectAllPages\"] as const;\n static events = [\"change\", \"reconcile\"] as const;\n\n declare readonly allTarget: HTMLInputElement;\n declare readonly barTarget: HTMLElement;\n declare readonly countTarget: HTMLElement;\n declare readonly itemTargets: HTMLInputElement[];\n declare readonly hasAllTarget: boolean;\n declare readonly hasBarTarget: boolean;\n declare readonly hasCountTarget: boolean;\n\n declare totalCountValue: number;\n declare announceTextValue: string;\n\n /** All-pages mode is a transient UI state, mirrored to `data-all-pages` so a\n * `connect()` over markup that already carries the attribute rehydrates the\n * mode — a morph, a Turbo Stream, or a server that renders it back. A restore\n * visit serves the server's markup instead, so the mode does not survive one. */\n #allPagesMode = false;\n /** Last emitted figures, so a recompute reports only on a real change. */\n #lastCount = -1;\n #lastAllPages = false;\n\n /** Collapses every signal from one DOM or Value update into one repair. */\n readonly #reconcile = new MicrotaskCoalescer(() => this.#reconcileNow());\n\n /** Delegated `change` handler covering the select-all box and every row. */\n readonly #onChange = (event: Event): void => {\n const target = event.target as HTMLElement | null;\n if (!target) return;\n if (this.hasAllTarget && target === this.allTarget) {\n this.#applyAll();\n } else if (this.itemTargets.some((item) => item === target)) {\n this.#exitAllPages();\n this.#reportChange(this.#recompute());\n }\n };\n\n override connect(): void {\n this.#allPagesMode = this.element.dataset.allPages === \"true\";\n this.#reconcile.activate();\n this.element.addEventListener(\"change\", this.#onChange);\n this.#recompute();\n }\n\n override disconnect(): void {\n this.element.removeEventListener(\"change\", this.#onChange);\n this.#reconcile.cancel();\n }\n\n /** Repairs the figures for a row that arrived at runtime. */\n itemTargetConnected(): void {\n this.#reconcile.schedule();\n }\n\n /** Repairs the figures after a row leaves, so a removed selection stops counting. */\n itemTargetDisconnected(): void {\n this.#reconcile.schedule();\n }\n\n /** Reflects the current selection onto a select-all box added at runtime. */\n allTargetConnected(): void {\n this.#reconcile.schedule();\n }\n\n /** Repairs the figures after the select-all box leaves. */\n allTargetDisconnected(): void {\n this.#reconcile.schedule();\n }\n\n /** Repaints the count for a total that changed at runtime, rejecting non-finite ones. */\n totalCountValueChanged(): void {\n if (!Number.isFinite(this.totalCountValue)) {\n this.totalCountValue = 0;\n return;\n }\n this.#reconcile.schedule();\n }\n\n /** Repaints so wording changed at runtime is used by the next announcement. */\n announceTextValueChanged(): void {\n this.#reconcile.schedule();\n }\n\n /** Clears every selection (rows + select-all) and exits all-pages mode. */\n clear(): void {\n for (const item of this.itemTargets) item.checked = false;\n if (this.hasAllTarget) {\n this.allTarget.checked = false;\n this.allTarget.indeterminate = false;\n }\n this.#exitAllPages();\n this.#reportChange(this.#recompute());\n }\n\n /**\n * Enters \"select all across pages\" mode: the count shows `totalCount`, and every\n * row on this page is checked.\n *\n * The mode's claim is that the whole set is selected, so leaving a visible row\n * unchecked would put the page and the count in open disagreement.\n */\n selectAllPages(): void {\n this.#allPagesMode = true;\n this.#checkEveryRow();\n this.#reportChange(this.#recompute());\n }\n\n /** Marks every row on this page selected. */\n #checkEveryRow(): void {\n for (const item of this.itemTargets) item.checked = true;\n }\n\n /** Mirrors the select-all box to every row, then recomputes. */\n #applyAll(): void {\n if (!this.hasAllTarget) return;\n const { checked } = this.allTarget;\n for (const item of this.itemTargets) item.checked = checked;\n this.#exitAllPages();\n this.#reportChange(this.#recompute());\n }\n\n #exitAllPages(): void {\n this.#allPagesMode = false;\n }\n\n /** Repairs the derived state after the page moved rows or a render input. */\n #reconcileNow(): void {\n // A row that arrives while the whole set is selected is one of the rows the\n // mode already claims, so it lands checked instead of contradicting the count.\n if (this.#allPagesMode) this.#checkEveryRow();\n const detail = this.#recompute();\n if (!detail) return;\n this.dispatch(\"reconcile\", { detail });\n this.#announce(detail);\n }\n\n /** Reports a selection the user moved. */\n #reportChange(detail: SelectionDetail | null): void {\n if (!detail) return;\n this.dispatch(\"change\", { detail });\n this.#announce(detail);\n }\n\n /** Hands the count to the shared announcer, worded by the consumer. */\n #announce(detail: SelectionDetail): void {\n announce(fillTemplate(this.announceTextValue, { count: detail.count }));\n }\n\n /**\n * Recomputes the count, the select-all checked/indeterminate state, and the bar\n * visibility from the current DOM. Returns the figures when the emitted count or\n * all-pages flag actually moved, and `null` when they did not.\n *\n * @stimeoRenderRoot\n */\n #recompute(): SelectionDetail | null {\n const items = this.itemTargets;\n const total = items.length;\n const checked = items.filter((item) => item.checked).length;\n const allPages = this.#allPagesMode;\n\n if (this.hasAllTarget) {\n this.allTarget.checked = total > 0 && checked === total;\n this.allTarget.indeterminate = checked > 0 && checked < total;\n }\n\n const count = allPages ? this.totalCountValue : checked;\n const show = allPages || checked > 0;\n\n if (this.hasBarTarget) {\n // Hand the Tab position over before the bar leaves, or focus falls to the\n // document and the keyboard user loses their place.\n if (!show && this.hasAllTarget && this.barTarget.contains(document.activeElement)) {\n this.allTarget.focus();\n }\n this.barTarget.hidden = !show;\n }\n if (this.hasCountTarget) this.countTarget.textContent = String(count);\n\n this.element.setAttribute(\"data-selected-count\", String(checked));\n if (allPages) this.element.setAttribute(\"data-all-pages\", \"true\");\n else this.element.removeAttribute(\"data-all-pages\");\n\n const changed = count !== this.#lastCount || allPages !== this.#lastAllPages;\n this.#lastCount = count;\n this.#lastAllPages = allPages;\n return changed ? { count, allPages } : null;\n }\n}\n"]}
@@ -8,43 +8,56 @@ import { Controller } from '@hotwired/stimulus';
8
8
  * aria-label="Featured"
9
9
  * data-stimeo--carousel-autoplay-value="false"
10
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>
11
+ * data-stimeo--carousel-loop-value="true">
12
+ * <button data-stimeo--carousel-target="playToggle">…</button>
18
13
  * <div data-stimeo--carousel-target="viewport">
19
14
  * <div role="tabpanel" data-stimeo--carousel-target="slide">…</div>
20
- * <div role="tabpanel" data-stimeo--carousel-target="slide" hidden>…</div>
15
+ * <div role="tabpanel" data-stimeo--carousel-target="slide" hidden inert>…</div>
21
16
  * </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>
17
+ * <button data-stimeo--carousel-target="prev">‹</button>
18
+ * <button data-stimeo--carousel-target="next">›</button>
24
19
  * <div role="tablist">
25
- * <button role="tab" data-stimeo--carousel-target="picker"
26
- * data-action="stimeo--carousel#goto
27
- * keydown->stimeo--carousel#onPickerKeydown"></button>
20
+ * <button role="tab" data-stimeo--carousel-target="picker"></button>
28
21
  * </div>
29
22
  * </section>
30
23
  *
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.
24
+ * Implements the WAI-ARIA APG **Carousel** pattern. With pickers it is the tabbed
25
+ * variant: each picker is a `tab` carrying `aria-selected` and the single roving
26
+ * `tabindex`, and each slide is its `tabpanel`. Without pickers the slides are
27
+ * `group`s and `prev`/`next` drive them. The current slide is exposed through
28
+ * `data-state` (`active`/`inactive`); the rest carry `hidden` **and** `inert`, so
29
+ * they stay out of the focus order and the accessibility tree even when consumer
30
+ * CSS overrides `display` to lay the slides out as a track.
36
31
  *
37
32
  * @remarks
38
33
  * 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. 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.
34
+ *
35
+ * **The controller owns its own wiring.** Clicks, picker keys, hover, and focus
36
+ * are delegated from the controller element, so no `data-action` is required and
37
+ * a target added at runtime works the moment it appears. Explicit `data-action`
38
+ * bindings to the declared actions still work and coexist with the delegated
39
+ * path: the binding runs first and marks the event, and the delegated listener
40
+ * stands down, so one interaction is never handled twice.
41
+ *
42
+ * **`autoplay` is the single source of truth for the rotation intent.** The
43
+ * toggle writes back to it, so the state survives a Turbo Drive cache restore
44
+ * without a second, competing signal; `aria-pressed` is a pure output the
45
+ * controller owns. Rotation is suspended — not cancelled — while the pointer
46
+ * rests on the carousel, while focus is inside it, and while the tab is hidden;
47
+ * each suspension lifts on its own (WCAG 2.2.2 is met by the toggle, which is
48
+ * the one control that stops rotation for good). A carousel with nothing left to
49
+ * advance to (one slide, or the last slide of a non-looping set) normalizes
50
+ * `autoplay` to `false` and marks the toggle `aria-disabled`. `prefers-reduced-motion`
51
+ * does the same at connect, leaving an explicit press free to start rotation.
52
+ *
53
+ * The interval is cleared on `disconnect()` (Turbo navigation included), and the
54
+ * leased ARIA is returned before Turbo caches the page. Picker arrow keys, `Home`,
55
+ * and `End` move focus only (manual activation); slide changes never steal focus
56
+ * from the control the user operated. A slide change the user drove — including an
57
+ * autoplay tick they started — is reported as `stimeo--carousel:change` with
58
+ * `{ index, total }`; the same detail arrives as `stimeo--carousel:reconcile` when
59
+ * the controller re-derives the position itself, whether a target came or went or
60
+ * a retained element's state attributes were rewritten in place.
48
61
  */
49
62
  declare class CarouselController extends Controller<HTMLElement> {
50
63
  #private;
@@ -67,18 +80,27 @@ declare class CarouselController extends Controller<HTMLElement> {
67
80
  static events: readonly ["change", "pause", "play", "reconcile"];
68
81
  readonly slideTargets: HTMLElement[];
69
82
  readonly pickerTargets: HTMLElement[];
70
- readonly playToggleTarget: HTMLElement;
71
- readonly hasPlayToggleTarget: boolean;
83
+ readonly viewportTarget: HTMLElement;
84
+ readonly hasViewportTarget: boolean;
85
+ readonly prevTargets: HTMLElement[];
86
+ readonly nextTargets: HTMLElement[];
87
+ readonly playToggleTargets: HTMLElement[];
72
88
  autoplayValue: boolean;
73
89
  intervalValue: number;
74
90
  loopValue: boolean;
75
91
  /**
76
- * Renders the initial slide and starts autoplay when requested.
92
+ * Renders the initial slide, wires the delegated listeners, and starts autoplay
93
+ * when requested.
77
94
  *
78
- * `findIndex` makes the authored pre-selection first-wins when several pickers
79
- * are marked; `#render` then writes an explicit value onto every picker.
95
+ * Every suspension is re-derived from the environment rather than carried, so an
96
+ * in-page move which Stimulus delivers to the *same* controller instance as
97
+ * `disconnect()` then `connect()` — cannot strand the carousel in a suspension
98
+ * whose lifting event will never arrive. The attribute observer starts after the
99
+ * first render so the controller's own opening writes are not fed back to it.
80
100
  */
81
101
  connect(): void;
102
+ /** Releases every listener and observer, returns the leased ARIA, drops the suspensions. */
103
+ disconnect(): void;
82
104
  /**
83
105
  * Re-establishes the single selected picker when one is added after connect.
84
106
  *
@@ -95,29 +117,41 @@ declare class CarouselController extends Controller<HTMLElement> {
95
117
  slideTargetConnected(): void;
96
118
  /** Re-clamps the active index after a slide is removed. */
97
119
  slideTargetDisconnected(): void;
98
- /** Clears the autoplay interval so it never fires after teardown. */
99
- disconnect(): void;
100
- /** Advances to the next slide. Bound via `data-action`. */
101
- next(): void;
102
- /** Returns to the previous slide. Bound via `data-action`. */
103
- prev(): void;
120
+ /** Follows a rotation intent the page changed at runtime. */
121
+ autoplayValueChanged(): void;
122
+ /** Re-arms the live interval at the new delay without reporting a state change. */
123
+ intervalValueChanged(): void;
124
+ /** Re-publishes the step controls and re-evaluates the non-looping end. */
125
+ loopValueChanged(): void;
126
+ /** Advances to the next slide. Delegated; `data-action` wiring is optional. */
127
+ next(event?: Event): void;
128
+ /** Returns to the previous slide. Delegated; `data-action` wiring is optional. */
129
+ prev(event?: Event): void;
104
130
  /** Jumps to the slide whose picker was activated (click / Enter / Space). */
105
131
  goto(event: Event): void;
106
- /** Toggles autoplay on the user's explicit request and syncs the timer. */
107
- togglePlay(): void;
108
132
  /**
109
- * Suspends autoplay. Hover (`mouseenter`) is a temporary suspension that resumes
110
- * on leave; keyboard focus (`focusin`) is a hard stop that turns autoplay off so
111
- * it cannot resume without an explicit play (WCAG 2.2.2).
133
+ * Flips the rotation intent on the user's explicit request.
134
+ *
135
+ * The intent is written back to the `autoplay` Value, which is where every other
136
+ * path reads it from. A carousel with nothing to advance to has no intent to
137
+ * flip: the toggle is marked `aria-disabled` and does nothing.
138
+ */
139
+ togglePlay(event?: Event): void;
140
+ /**
141
+ * Suspends rotation. A `focus`-family event records that focus is inside the
142
+ * carousel; anything else — hover, or a bare programmatic call — records the
143
+ * pointer suspension. Both lift through the matching {@link resume}, and
144
+ * neither touches the rotation intent.
112
145
  */
113
146
  pause(event?: Event): void;
114
147
  /**
115
- * Lifts a hover suspension (`mouseleave`) and resumes autoplay if it is still
116
- * on. A `focusout` does nothing here: the focus pause was a hard stop, so the
117
- * user must press play to restart.
148
+ * Lifts the matching suspension. A `focusout` whose `relatedTarget` is still
149
+ * inside the carousel is focus moving between its own controls, which leaves the
150
+ * focus suspension in place releasing it there would stop and restart the
151
+ * interval on every Tab press.
118
152
  */
119
153
  resume(event?: Event): void;
120
- /** Picker roving: arrows move focus only; Home/End activate first/last slide. */
154
+ /** Picker roving for authored bindings; the delegated path is `#onKeydown`. */
121
155
  onPickerKeydown(event: KeyboardEvent): void;
122
156
  }
123
157