stimeo-ui 0.8.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 (40) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/dist/controllers/bulk_select_controller.d.ts +62 -15
  3. package/dist/controllers/bulk_select_controller.js +139 -28
  4. package/dist/controllers/bulk_select_controller.js.map +1 -1
  5. package/dist/controllers/clipboard_controller.d.ts +48 -15
  6. package/dist/controllers/clipboard_controller.js +102 -20
  7. package/dist/controllers/clipboard_controller.js.map +1 -1
  8. package/dist/controllers/color_picker_controller.d.ts +37 -6
  9. package/dist/controllers/color_picker_controller.js +180 -43
  10. package/dist/controllers/color_picker_controller.js.map +1 -1
  11. package/dist/controllers/data_grid_controller.d.ts +25 -9
  12. package/dist/controllers/data_grid_controller.js +150 -24
  13. package/dist/controllers/data_grid_controller.js.map +1 -1
  14. package/dist/controllers/editable_controller.d.ts +34 -9
  15. package/dist/controllers/editable_controller.js +83 -30
  16. package/dist/controllers/editable_controller.js.map +1 -1
  17. package/dist/controllers/filter_controller.d.ts +15 -3
  18. package/dist/controllers/filter_controller.js +32 -1
  19. package/dist/controllers/filter_controller.js.map +1 -1
  20. package/dist/controllers/masonry_controller.d.ts +34 -5
  21. package/dist/controllers/masonry_controller.js +129 -17
  22. package/dist/controllers/masonry_controller.js.map +1 -1
  23. package/dist/controllers/otp_controller.d.ts +4 -1
  24. package/dist/controllers/otp_controller.js +29 -16
  25. package/dist/controllers/otp_controller.js.map +1 -1
  26. package/dist/controllers/reset_before_cache_controller.d.ts +25 -2
  27. package/dist/controllers/reset_before_cache_controller.js +51 -5
  28. package/dist/controllers/reset_before_cache_controller.js.map +1 -1
  29. package/dist/controllers/resizable_controller.d.ts +23 -7
  30. package/dist/controllers/resizable_controller.js +128 -55
  31. package/dist/controllers/resizable_controller.js.map +1 -1
  32. package/dist/index.js +860 -339
  33. package/dist/index.js.map +1 -1
  34. package/dist/inspector/cli.js +2 -0
  35. package/dist/inspector/cli.js.map +1 -1
  36. package/dist/inspector/cli_bin.js +2 -0
  37. package/dist/inspector/cli_bin.js.map +1 -1
  38. package/dist/inspector/examples.json +22 -22
  39. package/dist/inspector/manifest.json +12 -8
  40. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,94 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
  While the version is `0.x`, the public API (the `stimeo--*` data attributes) may
8
8
  change between releases.
9
9
 
10
+ ## [0.9.0] - 2026-08-28
11
+
12
+ Minor release with no new components. Ten existing ones are reworked —
13
+ bulk-select, clipboard, color-picker, data-grid, editable, filter, masonry, otp,
14
+ reset-before-cache, and resizable — and of those, bulk-select, clipboard,
15
+ data-grid, and editable also changed their markup contracts, so read Removed and
16
+ Changed before upgrading. The Inspector manifest stays on schema v12.
17
+
18
+ ### Removed
19
+
20
+ - bulk-select: `announce`, and the `aria-live` on the `bar` target that went with
21
+ it — the count goes through the page's `stimeo--announcer` now.
22
+ - clipboard: the live-region requirement on the `feedback` target. Drop the
23
+ `role="status"` and `aria-live`; the slot is a plain visible label.
24
+ - editable: the `onBlur` action. Drop `blur->stimeo--editable#onBlur` — the
25
+ departure is watched on the controller element.
26
+
27
+ ### Added
28
+
29
+ - bulk-select: `announceText`, carrying `{count}`, and a `reconcile` event.
30
+ - clipboard: `announceCopiedText` and `announceErrorText`. Like `announceText`
31
+ they default to empty, so seat a `stimeo--announcer` on the page and write the
32
+ wording you want read.
33
+ - editable: `save` / `cancel` / `revert` actions, and `data-value` on the
34
+ `display` target for a display that renders the value rather than being it.
35
+ - color-picker: `data-value-text`, an `aria-valuetext` template whose `{value}`
36
+ carries the channel value, for pages that are not in English.
37
+ - Declarations read once are followed when they change — filter's `match`,
38
+ masonry's `minColumnWidth` / `gap`, resizable's `min` / `max` — as are targets
39
+ swapped in later by color-picker, editable, masonry, and resizable, and masonry
40
+ also relays out when a descendant resource finishes loading.
41
+
42
+ ### Changed
43
+
44
+ - bulk-select: `selectAllPages` checks the rows on the page too, so a row that
45
+ arrives while the mode is on lands checked. Hiding the bar hands focus to the
46
+ select-all box, and a `bar` carrying `role="toolbar"` wants `stimeo--toolbar`
47
+ alongside it for the arrow keys and the single tab stop.
48
+ - clipboard: `copied` and `error` are transient `data-state` values — a
49
+ `connect()` that finds either returns to `idle`, and the state is rewound
50
+ before Turbo caches the page. Any other authored `data-state` is left alone.
51
+ - color-picker: `aria-valuenow` / `aria-valuetext` / `aria-valuemin` /
52
+ `aria-valuemax` are controller-owned, so the markup supplies `role="slider"`
53
+ and a name; an omitted range resolves to the channel default (hue `0`–`360`,
54
+ the rest `0`–`100`). Only a primary-button press drags, and an `alpha` slider
55
+ is inert unless `alpha` is on.
56
+ - data-grid: a keystroke or click that reached a control inside a cell, and one
57
+ an IME is composing, belongs to that control — no move, no sort, no selection.
58
+ The exception is a click on a sortable header's own `<button>`, which needs
59
+ `tabindex="-1"`; host `gridcell` / `columnheader` on `td` / `th`.
60
+ - editable: the mode is read back from the DOM and the controller writes no ARIA.
61
+ Focus landing inside the component has not left the edit, so a Save or Cancel
62
+ button beside the input keeps it open, and the value is trimmed once at the save.
63
+ - reset-before-cache: `data-reset-value` returns a field to what the markup
64
+ declares — `defaultValue`, `defaultChecked`, the `selected` option — instead of
65
+ emptying it; a file input empties and `hidden` / `submit` / `reset` / `button` /
66
+ `image` are left alone.
67
+ - resizable: a separator with no `aria-orientation` reads as `horizontal`, and
68
+ `toggle` reopens at the position the pane collapsed from. Pointer capture and
69
+ the drag listeners sit on the `separator` target, not on `event.target` and
70
+ `document`.
71
+ - masonry: `layout` fires when the result moves, not only on a column count
72
+ change, and every relayout after the first is deferred to a microtask. otp:
73
+ `reconcile` reports a move in the pair (joined value, `data-state`).
74
+ - A declaration that cannot be read falls back to its default instead of reaching
75
+ ARIA or CSS as `NaN` — masonry's `minColumnWidth` / `gap`, resizable's `min` /
76
+ `max` / `value` / `step`, bulk-select's `totalCount`, and reset-before-cache's
77
+ `scope`, which no longer aborts the whole sweep.
78
+ - Inspector: clipboard's `feedback` is no longer required, a color-picker
79
+ `slider` is checked for `role` and a name only, and bulk-select and clipboard
80
+ join the components that need a `stimeo--announcer`.
81
+
82
+ ### Fixed
83
+
84
+ - data-grid: `Enter` and `Space` on a `<button>` nested in a cell reach it, so a
85
+ row-action button is keyboard-reachable, and rows coming and going at runtime
86
+ re-establish exactly one tab stop. editable: tabbing past a Save or Cancel
87
+ button no longer strands the editor open, and a display removed mid-edit no
88
+ longer throws. bulk-select: a nested bulk-select's rows stay out of the outer
89
+ container's count.
90
+ - clipboard: a copy that resolved after disconnect wrote to the detached element
91
+ and armed a timer past the teardown, and a repeat inside the feedback window is
92
+ read out again. In color-picker a drag belongs to the pointer that started it,
93
+ in masonry an item removed and re-appended in one batch keeps its layout hooks,
94
+ and in resizable `data-dragging` no longer rides a Turbo snapshot.
95
+ - reset-before-cache: `data-reset-value` wrote an empty string into a checkbox's
96
+ `value` attribute, and could leave a select with nothing selected at all.
97
+
10
98
  ## [0.8.0] - 2026-08-22
11
99
 
12
100
  Minor release with no new components. Eight existing ones are reworked —
@@ -695,6 +783,7 @@ Initial public alpha: 101 behavior-only, accessible Stimulus controllers driven
695
783
  by `data-*` attributes, shipping no CSS. Published to npm (with provenance) and
696
784
  RubyGems.
697
785
 
786
+ [0.9.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.9.0
698
787
  [0.8.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.8.0
699
788
  [0.7.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.7.0
700
789
  [0.6.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.6.0
@@ -9,31 +9,59 @@ import { Controller } from '@hotwired/stimulus';
9
9
  *
10
10
  * Markup contract (identifier: `stimeo--bulk-select`):
11
11
  * <div data-controller="stimeo--bulk-select"
12
- * data-stimeo--bulk-select-total-count-value="128">
12
+ * data-stimeo--bulk-select-total-count-value="128"
13
+ * data-stimeo--bulk-select-announce-text-value="{count} selected">
13
14
  * <input type="checkbox" data-stimeo--bulk-select-target="all">
14
15
  * <!-- rows (may be added dynamically; handled via event delegation) -->
15
16
  * <input type="checkbox" data-stimeo--bulk-select-target="item">
16
17
  * <input type="checkbox" data-stimeo--bulk-select-target="item">
17
- * <div data-stimeo--bulk-select-target="bar" hidden role="toolbar" aria-live="polite">
18
+ * <div data-stimeo--bulk-select-target="bar" hidden role="toolbar"
19
+ * data-controller="stimeo--toolbar" aria-label="Bulk actions">
18
20
  * <span data-stimeo--bulk-select-target="count"></span> selected
19
21
  * <button data-stimeo--bulk-select-target="selectAllPages"
22
+ * data-stimeo--toolbar-target="control"
20
23
  * data-action="click->stimeo--bulk-select#selectAllPages">Select all</button>
21
- * <button data-action="click->stimeo--bulk-select#clear">Clear</button>
24
+ * <button data-stimeo--toolbar-target="control"
25
+ * data-action="click->stimeo--bulk-select#clear">Clear</button>
22
26
  * </div>
23
27
  * </div>
24
28
  *
25
- * `change` dispatches `{ count, allPages }`.
29
+ * The bar carries `role="toolbar"`, so it is composed with `stimeo--toolbar` to get
30
+ * the arrow-key movement and single tab stop that role calls for.
31
+ *
32
+ * `change` and `reconcile` both dispatch `{ count, allPages }`.
33
+ *
34
+ * Selection is two-stage. The select-all box covers the rows on this page; the
35
+ * optional `selectAllPages` control extends the selection to `totalCount` rows
36
+ * across every page, checking each row here as it does so. Touching any single row
37
+ * leaves that mode, because the selection is no longer the whole set.
26
38
  *
27
39
  * @remarks
28
40
  * Behavior only — it never runs the batch action (that is the consumer's
29
41
  * form/Turbo) nor fetches/pages rows. Selection lives **only** in each checkbox's
30
42
  * `checked` (no module-scope set), so `connect()` recomputes idempotently from the
31
43
  * DOM after a Turbo swap. Row `change` is handled by **delegation** on the
32
- * container, so dynamically-added rows work without per-row `data-action`.
33
- * Showing the bar never steals focus
34
- * (WCAG 2.2 2.4.3); the count rides the bar's own `aria-live` region (WCAG 2.2
35
- * 4.1.3) — the bar is revealed *before* the count text is written so the change is
36
- * observed and announced. The delegated listener is removed on `disconnect()`.
44
+ * container, so dynamically-added rows work without per-row `data-action`, while
45
+ * rows arriving or leaving on their own are picked up by the target callbacks and
46
+ * coalesced into one repair per batch.
47
+ *
48
+ * `change` is dispatched when the user moves the selection; `reconcile` when the
49
+ * controller repairs the figures itself — a row added or removed by the page, or a
50
+ * render input changing at runtime. Both carry the same detail.
51
+ *
52
+ * The count reaches assistive tech through the page's shared `stimeo--announcer`,
53
+ * worded by the consumer via `announceText` (`{count}` expands to the figure being
54
+ * shown; an empty template stays silent, keeping announcements opt-in and
55
+ * i18n-neutral).
56
+ *
57
+ * Showing the bar never steals focus (WCAG 2.2 2.4.3). Hiding it hands focus to
58
+ * the select-all box first when the bar holds it, so the keyboard user keeps a Tab
59
+ * position instead of falling back to the document.
60
+ *
61
+ * A non-finite `totalCount` falls back to the Value's default and is written back
62
+ * to the attribute, so the count never renders as `NaN`.
63
+ *
64
+ * The delegated listener and the pending repair are both released on `disconnect()`.
37
65
  */
38
66
  declare class BulkSelectController extends Controller<HTMLElement> {
39
67
  #private;
@@ -43,26 +71,45 @@ declare class BulkSelectController extends Controller<HTMLElement> {
43
71
  type: NumberConstructor;
44
72
  default: number;
45
73
  };
46
- announce: {
47
- type: BooleanConstructor;
48
- default: boolean;
74
+ announceText: {
75
+ type: StringConstructor;
76
+ default: string;
49
77
  };
50
78
  };
51
79
  static actions: readonly ["clear", "selectAllPages"];
52
- static events: readonly ["change"];
80
+ static events: readonly ["change", "reconcile"];
53
81
  readonly allTarget: HTMLInputElement;
54
82
  readonly barTarget: HTMLElement;
55
83
  readonly countTarget: HTMLElement;
84
+ readonly itemTargets: HTMLInputElement[];
56
85
  readonly hasAllTarget: boolean;
57
86
  readonly hasBarTarget: boolean;
58
87
  readonly hasCountTarget: boolean;
59
88
  totalCountValue: number;
60
- announceValue: boolean;
89
+ announceTextValue: string;
61
90
  connect(): void;
62
91
  disconnect(): void;
92
+ /** Repairs the figures for a row that arrived at runtime. */
93
+ itemTargetConnected(): void;
94
+ /** Repairs the figures after a row leaves, so a removed selection stops counting. */
95
+ itemTargetDisconnected(): void;
96
+ /** Reflects the current selection onto a select-all box added at runtime. */
97
+ allTargetConnected(): void;
98
+ /** Repairs the figures after the select-all box leaves. */
99
+ allTargetDisconnected(): void;
100
+ /** Repaints the count for a total that changed at runtime, rejecting non-finite ones. */
101
+ totalCountValueChanged(): void;
102
+ /** Repaints so wording changed at runtime is used by the next announcement. */
103
+ announceTextValueChanged(): void;
63
104
  /** Clears every selection (rows + select-all) and exits all-pages mode. */
64
105
  clear(): void;
65
- /** Enters "select all across pages" mode (count shows `totalCount`). */
106
+ /**
107
+ * Enters "select all across pages" mode: the count shows `totalCount`, and every
108
+ * row on this page is checked.
109
+ *
110
+ * The mode's claim is that the whole set is selected, so leaving a visible row
111
+ * unchecked would put the page and the count in open disagreement.
112
+ */
66
113
  selectAllPages(): void;
67
114
  }
68
115
 
@@ -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":";;;AAqCO,IAAM,oBAAA,GAAN,cAAmC,UAAA,CAAwB;AAAA,EAChE,OAAgB,OAAA,GAAU,CAAC,OAAO,MAAA,EAAQ,KAAA,EAAO,SAAS,gBAAgB,CAAA;AAAA,EAC1E,OAAgB,MAAA,GAAS;AAAA,IACvB,UAAA,EAAY,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,CAAA,EAAE;AAAA,IACvC,QAAA,EAAU,EAAE,IAAA,EAAM,OAAA,EAAS,SAAS,IAAA;AAAK,GAC3C;AAAA,EACA,OAAO,OAAA,GAAU,CAAC,OAAA,EAAS,gBAAgB,CAAA;AAAA,EAC3C,OAAO,MAAA,GAAS,CAAC,QAAQ,CAAA;AAAA;AAAA;AAAA,EAczB,aAAA,GAAgB,KAAA;AAAA;AAAA,EAEhB,UAAA,GAAa,EAAA;AAAA,EACb,aAAA,GAAgB,KAAA;AAAA;AAAA,EAGP,SAAA,GAAY,CAAC,KAAA,KAAuB;AAC3C,IAAA,MAAM,SAAS,KAAA,CAAM,MAAA;AACrB,IAAA,IAAI,CAAC,MAAA,EAAQ;AACb,IAAA,IAAI,IAAA,CAAK,YAAA,IAAgB,MAAA,KAAW,IAAA,CAAK,SAAA,EAAW;AAClD,MAAA,IAAA,CAAK,SAAA,EAAU;AAAA,IACjB,CAAA,MAAA,IAAW,MAAA,CAAO,OAAA,CAAQ,0CAA0C,CAAA,EAAG;AACrE,MAAA,IAAA,CAAK,aAAA,EAAc;AACnB,MAAA,IAAA,CAAK,WAAW,IAAI,CAAA;AAAA,IACtB;AAAA,EACF,CAAA;AAAA,EAES,OAAA,GAAgB;AACvB,IAAA,IAAA,CAAK,aAAA,GAAgB,IAAA,CAAK,OAAA,CAAQ,OAAA,CAAQ,QAAA,KAAa,MAAA;AACvD,IAAA,IAAA,CAAK,OAAA,CAAQ,gBAAA,CAAiB,QAAA,EAAU,IAAA,CAAK,SAAS,CAAA;AACtD,IAAA,IAAA,CAAK,WAAW,KAAK,CAAA;AAAA,EACvB;AAAA,EAES,UAAA,GAAmB;AAC1B,IAAA,IAAA,CAAK,OAAA,CAAQ,mBAAA,CAAoB,QAAA,EAAU,IAAA,CAAK,SAAS,CAAA;AAAA,EAC3D;AAAA;AAAA,EAGA,KAAA,GAAc;AACZ,IAAA,KAAA,MAAW,IAAA,IAAQ,IAAA,CAAK,MAAA,EAAQ,IAAA,CAAK,OAAA,GAAU,KAAA;AAC/C,IAAA,IAAI,KAAK,YAAA,EAAc;AACrB,MAAA,IAAA,CAAK,UAAU,OAAA,GAAU,KAAA;AACzB,MAAA,IAAA,CAAK,UAAU,aAAA,GAAgB,KAAA;AAAA,IACjC;AACA,IAAA,IAAA,CAAK,aAAA,EAAc;AACnB,IAAA,IAAA,CAAK,WAAW,IAAI,CAAA;AAAA,EACtB;AAAA;AAAA,EAGA,cAAA,GAAuB;AACrB,IAAA,IAAA,CAAK,aAAA,GAAgB,IAAA;AACrB,IAAA,IAAA,CAAK,WAAW,IAAI,CAAA;AAAA,EACtB;AAAA;AAAA,EAGA,SAAA,GAAkB;AAChB,IAAA,IAAI,CAAC,KAAK,YAAA,EAAc;AACxB,IAAA,MAAM,EAAE,OAAA,EAAQ,GAAI,IAAA,CAAK,SAAA;AACzB,IAAA,KAAA,MAAW,IAAA,IAAQ,IAAA,CAAK,MAAA,EAAQ,IAAA,CAAK,OAAA,GAAU,OAAA;AAC/C,IAAA,IAAA,CAAK,aAAA,EAAc;AACnB,IAAA,IAAA,CAAK,WAAW,IAAI,CAAA;AAAA,EACtB;AAAA,EAEA,aAAA,GAAsB;AACpB,IAAA,IAAA,CAAK,aAAA,GAAgB,KAAA;AAAA,EACvB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,WAAW,MAAA,EAAuB;AAChC,IAAA,MAAM,QAAQ,IAAA,CAAK,MAAA;AACnB,IAAA,MAAM,QAAQ,KAAA,CAAM,MAAA;AACpB,IAAA,MAAM,UAAU,KAAA,CAAM,MAAA,CAAO,CAAC,IAAA,KAAS,IAAA,CAAK,OAAO,CAAA,CAAE,MAAA;AACrD,IAAA,MAAM,WAAW,IAAA,CAAK,aAAA;AAEtB,IAAA,IAAI,KAAK,YAAA,EAAc;AACrB,MAAA,IAAA,CAAK,SAAA,CAAU,OAAA,GAAU,KAAA,GAAQ,CAAA,IAAK,OAAA,KAAY,KAAA;AAClD,MAAA,IAAA,CAAK,SAAA,CAAU,aAAA,GAAgB,OAAA,GAAU,CAAA,IAAK,OAAA,GAAU,KAAA;AAAA,IAC1D;AAEA,IAAA,MAAM,KAAA,GAAQ,QAAA,GAAW,IAAA,CAAK,eAAA,GAAkB,OAAA;AAChD,IAAA,MAAM,IAAA,GAAO,YAAY,OAAA,GAAU,CAAA;AAInC,IAAA,IAAI,KAAK,YAAA,EAAc;AACrB,MAAA,IAAA,CAAK,SAAA,CAAU,SAAS,CAAC,IAAA;AACzB,MAAA,IAAA,CAAK,UAAU,YAAA,CAAa,WAAA,EAAa,IAAA,CAAK,aAAA,GAAgB,WAAW,KAAK,CAAA;AAAA,IAChF;AACA,IAAA,IAAI,KAAK,cAAA,EAAgB,IAAA,CAAK,WAAA,CAAY,WAAA,GAAc,OAAO,KAAK,CAAA;AAEpE,IAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,qBAAA,EAAuB,MAAA,CAAO,OAAO,CAAC,CAAA;AAChE,IAAA,IAAI,QAAA,EAAU,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,kBAAkB,MAAM,CAAA;AAAA,SAC3D,IAAA,CAAK,OAAA,CAAQ,eAAA,CAAgB,gBAAgB,CAAA;AAElD,IAAA,MAAM,OAAA,GAAU,KAAA,KAAU,IAAA,CAAK,UAAA,IAAc,aAAa,IAAA,CAAK,aAAA;AAC/D,IAAA,IAAA,CAAK,UAAA,GAAa,KAAA;AAClB,IAAA,IAAA,CAAK,aAAA,GAAgB,QAAA;AACrB,IAAA,IAAI,UAAU,OAAA,EAAS;AACrB,MAAA,IAAA,CAAK,QAAA,CAAS,UAAU,EAAE,MAAA,EAAQ,EAAE,KAAA,EAAO,QAAA,IAAY,CAAA;AAAA,IACzD;AAAA,EACF;AAAA;AAAA,EAGA,IAAI,MAAA,GAA6B;AAC/B,IAAA,OAAO,KAAA,CAAM,IAAA;AAAA,MACX,IAAA,CAAK,OAAA,CAAQ,gBAAA,CAAmC,0CAA0C;AAAA,KAC5F;AAAA,EACF;AACF","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"]}
@@ -1,30 +1,52 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  /**
4
- * Headless copy-to-clipboard behavior with a live-region completion notice.
4
+ * Headless copy-to-clipboard behavior with a completion slot and a screen-reader
5
+ * announcement.
5
6
  *
6
7
  * Markup contract (identifier: `stimeo--clipboard`):
7
8
  * <div data-controller="stimeo--clipboard"
8
- * data-stimeo--clipboard-feedback-duration-value="2000">
9
+ * data-stimeo--clipboard-feedback-duration-value="2000"
10
+ * data-stimeo--clipboard-announce-copied-text-value="Copied to clipboard"
11
+ * data-stimeo--clipboard-announce-error-text-value="Copy failed">
9
12
  * <input type="text" value="https://example.com" readonly
10
13
  * data-stimeo--clipboard-target="source">
11
14
  * <button type="button" data-stimeo--clipboard-target="button"
12
15
  * data-action="stimeo--clipboard#copy">Copy</button>
13
- * <span role="status" aria-live="polite"
14
- * data-stimeo--clipboard-target="feedback"></span>
16
+ * <span data-stimeo--clipboard-target="feedback"></span>
15
17
  * </div>
16
18
  *
17
- * No dedicated APG pattern; this follows the Button + live-region practice. The
18
- * copy uses the standard `navigator.clipboard` API (no extra dependency); when
19
- * it is unavailable or rejects, the failure is surfaced rather than silently
20
- * swallowed, and never communicated by icon alone the `role="status"` region
21
- * carries text so screen readers announce the outcome.
19
+ * No dedicated APG pattern; this follows the Button practice plus a status message
20
+ * ({@link https://www.w3.org/WAI/WCAG22/Understanding/status-changes.html | WCAG 2.2 SC 4.1.3}).
21
+ * The copy uses the standard `navigator.clipboard` API (no extra dependency); every
22
+ * failure mode an API the browser withholds outside a secure context as much as a
23
+ * rejected permission settles the same way, as `data-state="error"` plus the
24
+ * `copy` event's `success: false`.
25
+ *
26
+ * Targets: `source` (the text to copy, when `text` is not set), `button` (the
27
+ * control that triggers `copy`), `feedback` (the **visible** completion slot).
28
+ *
29
+ * Values: `text` (copy this instead of reading `source`), `feedbackDuration` (ms the
30
+ * completion state is held; `0` or less arms no timer, so it stands until the next
31
+ * copy — a reconnect and the before-cache rewind still clear it), `copiedLabel` /
32
+ * `errorLabel` (what the visible slot shows), `announceCopiedText` /
33
+ * `announceErrorText` (what assistive tech hears; empty announces nothing).
22
34
  *
23
35
  * @remarks
24
- * Behavior only — icon swaps and styling are the consumer's, keyed off
25
- * `data-state` (`idle` / `copied` / `error`). The completion notice clears
26
- * itself after `feedbackDuration`; that timer is torn down on disconnect (Turbo)
27
- * via {@link SafeTimeout}.
36
+ * Behavior only — icon swaps and styling are the consumer's, keyed off `data-state`
37
+ * (`idle` / `copied` / `error`).
38
+ *
39
+ * **The `feedback` slot must not carry live-region semantics.** Announcing is the
40
+ * page's shared `stimeo--announcer` job: it is seated before the change it reads,
41
+ * which a slot filled on demand cannot be, and it already collapses a repeat of the
42
+ * same wording so a second copy is still heard. A `role="status"` on the slot as
43
+ * well would say everything twice.
44
+ *
45
+ * `copied` and `error` are transient: the timer that clears them belongs to one
46
+ * connection, so a fresh `connect()` that finds either — a restored snapshot, an
47
+ * in-page move — returns to `idle`, and the state is rewound before Turbo caches
48
+ * the page ({@link BeforeCacheReset}). The rewind is silent: it discards nothing a
49
+ * reconnect does not derive again.
28
50
  */
29
51
  declare class ClipboardController extends Controller<HTMLElement> {
30
52
  #private;
@@ -46,6 +68,14 @@ declare class ClipboardController extends Controller<HTMLElement> {
46
68
  type: StringConstructor;
47
69
  default: string;
48
70
  };
71
+ announceCopiedText: {
72
+ type: StringConstructor;
73
+ default: string;
74
+ };
75
+ announceErrorText: {
76
+ type: StringConstructor;
77
+ default: string;
78
+ };
49
79
  };
50
80
  static actions: readonly ["copy"];
51
81
  static events: readonly ["copy"];
@@ -59,12 +89,15 @@ declare class ClipboardController extends Controller<HTMLElement> {
59
89
  feedbackDurationValue: number;
60
90
  copiedLabelValue: string;
61
91
  errorLabelValue: string;
92
+ announceCopiedTextValue: string;
93
+ announceErrorTextValue: string;
62
94
  connect(): void;
63
95
  disconnect(): void;
64
96
  /**
65
97
  * Copies the resolved text and reports the outcome. Bound via `data-action`
66
- * (click). Always dispatches `stimeo--clipboard:copy` with `{ success, text }`
67
- * — including on failure — so consumers can react either way.
98
+ * (click). Dispatches `stimeo--clipboard:copy` with `{ success, text }` once per
99
+ * completed attempt — including on failure — so consumers can react either way.
100
+ * An attempt whose connection ended while it was in flight reports nothing.
68
101
  */
69
102
  copy(): Promise<void>;
70
103
  }