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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8e3f69e3521c7e5d2ffa5e4b948aabbb26dc9bf8429217fa6235c9cdcafb53b1
4
- data.tar.gz: d9722417ea447febdc5188eb50812a3725987f65a6920e49f98e852b5f0076ba
3
+ metadata.gz: 3a8ccbe681a75206e0bb8a8866faeb91e0f3878c6b05cba845d00a333bb87041
4
+ data.tar.gz: f037b2908d8f19d55f1b9c5823a80b0459f995837b396dcc686b6b9318ee58dc
5
5
  SHA512:
6
- metadata.gz: 1fe28608ac28813a4e12b448b438853d19e2acdd948fe2af5d287b615e83439a933023881a72bec2a50d579c384141be0c132310a640540b5d7eaee5554e2f28
7
- data.tar.gz: ddb5ffd05af127033055296b6a66e6e71344b89dbcc42f0c88a34bc7e88193cb2c48deb67f4f2250edd19a0bed15957914c5ee90afd137ecce1c11cfc818df5b
6
+ metadata.gz: 785070e68e5d1cad2c96d1ca1821096662d2612a42121c254d5d0b92400b2e4f04af759fb1ad8827c1100dad712a43e5c0d45032aa72449d54956d3bf606f982
7
+ data.tar.gz: 2e1632573e162636ffd659fb01b0cc0c5d5d22d0d521e6f9c39793a28023f0403eb3008fd5640d65f73b74d7d19ce210d01b7413b459db3fa5e6bbf8bcb58dce
data/CHANGELOG.md CHANGED
@@ -7,6 +7,155 @@ 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
+
98
+ ## [0.8.0] - 2026-08-22
99
+
100
+ Minor release with no new components. Eight existing ones are reworked —
101
+ auto-submit, carousel, currency-input, file-dropzone, input-mask, nested-form,
102
+ otp, and textarea-autosize — and of those, carousel, otp, and file-dropzone also
103
+ changed their markup contracts, so read Removed and Changed before upgrading. The
104
+ Inspector manifest stays on schema v12.
105
+
106
+ ### Removed
107
+
108
+ - otp: `length` — the number of `field` targets is the passcode length.
109
+ - file-dropzone: the `status` target, `dragLabel`, and the
110
+ `data-file-dropzone-slot` attributes.
111
+
112
+ ### Added
113
+
114
+ - `reconcile` on auto-submit, direct-upload, file-dropzone, flash, frame-loading,
115
+ input-mask, nested-form, otp, spinner, and submit-once — it also reports the
116
+ state a Turbo rewind discarded, carrying what that component lost
117
+ (`{ files }`, `{ ids }`, `{ forms }`, `{ removed }`, or `{}`).
118
+ - carousel: `data-state`, `inert` on inactive slides, `prefers-reduced-motion`,
119
+ and optional pickers. otp: `clear` / `onPointerDown`, and text carrying several
120
+ characters spread across the following fields. file-dropzone:
121
+ `allowDuplicates`, a `thumb` target, and the `announce*Text` wording.
122
+ - Characters an IME commits full-width are read as their half-width form in
123
+ input-mask, otp, and currency-input.
124
+ - Inspector: rules for the reworked markup — carousel's controller-owned ARIA,
125
+ the `playToggle` an `autoplay` carousel needs, the `prev` / `next` pair, a
126
+ finite positive `interval`, otp's group and field names, and the parts
127
+ file-dropzone's item template must contain.
128
+
129
+ ### Changed
130
+
131
+ - carousel: drop the `data-action` wiring and any authored `aria-pressed` —
132
+ clicks, picker keys, hover, and focus are delegated, and `aria-pressed` /
133
+ `aria-disabled` / `aria-live` / `aria-atomic` are controller-owned. Name the
134
+ toggle with `aria-label`, pair `prev` with `next`, and give an `autoplay`
135
+ carousel a `playToggle` (WCAG 2.2.2).
136
+ - otp: the root needs `role="group"` and a name, and each `field` its own.
137
+ `invalid` reports only input that filled no field.
138
+ - file-dropzone: put a `stimeo--announcer` on the page, set the `announce*Text`
139
+ wording, and use `name` / `thumb` / `remove` targets in the item template with
140
+ an authored `aria-label`. Validation runs `type` → `size` → `duplicate` →
141
+ `count`, and `change` precedes `reject`.
142
+ - input-mask: `change` fires only for a user edit; normalization and runtime
143
+ Value changes report `reconcile`. The manifest reports `tokens` as `String`
144
+ where it reported `Object`.
145
+ - textarea-autosize: `resize` no longer fires when a connect leaves the height
146
+ unchanged; width, font, and runtime `minRows` / `maxRows` changes are followed.
147
+ - currency-input keeps what was typed while typing and rounds on blur;
148
+ nested-form absorbs rows added or removed outside it; auto-submit follows a
149
+ swapped `form` target.
150
+
151
+ ### Fixed
152
+
153
+ - carousel: the play toggle stops rotation — the `focusin` before the click no
154
+ longer makes the press resume instead. otp: a full-width commit into one field
155
+ fills the following ones. currency-input: a locale with non-Latin digits
156
+ reparses its own output. nested-form: a discarded-but-visible row's remove
157
+ button no longer swallows the click.
158
+
10
159
  ## [0.7.0] - 2026-08-16
11
160
 
12
161
  Minor release reworking the form stack, separator, and scroll-area, with
@@ -634,6 +783,8 @@ Initial public alpha: 101 behavior-only, accessible Stimulus controllers driven
634
783
  by `data-*` attributes, shipping no CSS. Published to npm (with provenance) and
635
784
  RubyGems.
636
785
 
786
+ [0.9.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.9.0
787
+ [0.8.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.8.0
637
788
  [0.7.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.7.0
638
789
  [0.6.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.6.0
639
790
  [0.5.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.5.0
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/auto_submit_controller.ts
4
4
 
5
+ // src/utils/before_cache_reset.ts
6
+ var BeforeCacheReset = class _BeforeCacheReset {
7
+ /** Every subscribed instance, iterated by the one shared document listener. */
8
+ static #subscribers = /* @__PURE__ */ new Set();
9
+ /** The shared listener; installed while at least one instance is subscribed. */
10
+ static #onBeforeCache = () => {
11
+ for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
12
+ };
13
+ #rewind;
14
+ /** @param rewind - the pass that returns this controller's state to its initial form. */
15
+ constructor(rewind) {
16
+ this.#rewind = rewind;
17
+ }
18
+ /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
19
+ activate() {
20
+ const first = _BeforeCacheReset.#subscribers.size === 0;
21
+ _BeforeCacheReset.#subscribers.add(this);
22
+ if (first) {
23
+ document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
24
+ }
25
+ }
26
+ /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
27
+ deactivate() {
28
+ _BeforeCacheReset.#subscribers.delete(this);
29
+ if (_BeforeCacheReset.#subscribers.size > 0) return;
30
+ document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
31
+ }
32
+ };
33
+
5
34
  // src/utils/composition_tracker.ts
6
35
  var CompositionTracker = class {
7
36
  #observedTargets = /* @__PURE__ */ new Set();
@@ -112,14 +141,18 @@ var AutoSubmitController = class extends Controller {
112
141
  message: { type: String, default: "" }
113
142
  };
114
143
  static actions = ["submit"];
115
- static events = ["submit", "done"];
144
+ static events = ["submit", "done", "reconcile"];
116
145
  /** Debounce timer registry; one `clearAll()` in disconnect tears it down. */
117
146
  #timers = new SafeTimeout();
118
147
  /** Id of the pending debounce timer, so a new keystroke can reset it. */
119
148
  #pendingId = 0;
149
+ /** The form the listeners are attached to; target callbacks rebind it. */
150
+ #boundForm = null;
151
+ /** Rewinds the transient state hooks just before Turbo snapshots the page. */
152
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
120
153
  /** Clears `aria-busy` and emits completion once Turbo finishes the submit. */
121
154
  #onSubmitEnd = () => {
122
- this.#form.removeAttribute("aria-busy");
155
+ this.#boundForm?.removeAttribute("aria-busy");
123
156
  const message = this.messageValue;
124
157
  this.dispatch("done", { detail: { message: message || void 0 } });
125
158
  if (this.announceValue && message) {
@@ -133,15 +166,23 @@ var AutoSubmitController = class extends Controller {
133
166
  }
134
167
  });
135
168
  connect() {
136
- this.#form.addEventListener("turbo:submit-end", this.#onSubmitEnd);
137
- this.#composition.observe(this.#form);
169
+ this.#beforeCache.activate();
170
+ this.#bindForm(this.#resolveForm());
138
171
  }
139
172
  disconnect() {
173
+ this.#beforeCache.deactivate();
174
+ this.#bindForm(null);
175
+ this.#composition.disconnect();
140
176
  this.#timers.clearAll();
141
177
  this.#pendingId = 0;
142
- this.#composition.disconnect();
143
- this.#form.removeAttribute("data-auto-submit-pending");
144
- this.#form.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
178
+ }
179
+ /** Follows a `form` target added (or swapped in) at runtime. */
180
+ formTargetConnected() {
181
+ this.#bindForm(this.#resolveForm());
182
+ }
183
+ /** Follows a `form` target removed (or swapped out) at runtime. */
184
+ formTargetDisconnected() {
185
+ this.#bindForm(this.#resolveForm());
145
186
  }
146
187
  /**
147
188
  * Schedules a debounced submit. Wired to `input`/`change`; the `on` value is an
@@ -155,21 +196,58 @@ var AutoSubmitController = class extends Controller {
155
196
  }
156
197
  /** Schedules (and coalesces) the debounced submit for the given trigger. */
157
198
  #schedule(trigger) {
158
- this.#form.setAttribute("data-auto-submit-pending", "true");
159
- if (this.#pendingId) this.#timers.clear(this.#pendingId);
199
+ const form = this.#boundForm;
200
+ if (!form) return;
201
+ form.setAttribute("data-auto-submit-pending", "true");
202
+ this.#cancelPending();
160
203
  this.#pendingId = this.#timers.set(() => {
161
204
  this.#pendingId = 0;
162
- this.#form.removeAttribute("data-auto-submit-pending");
205
+ form.removeAttribute("data-auto-submit-pending");
163
206
  this.dispatch("submit", { detail: { trigger } });
164
- if (this.#form.checkValidity()) {
165
- this.#form.setAttribute("aria-busy", "true");
207
+ if (form.checkValidity()) {
208
+ form.setAttribute("aria-busy", "true");
166
209
  }
167
- this.#form.requestSubmit();
210
+ form.requestSubmit();
168
211
  }, this.debounceValue);
169
212
  }
170
- /** Resolves the form element (explicit `form` target, else the controller root). */
171
- get #form() {
172
- return this.hasFormTarget ? this.formTarget : this.element;
213
+ /** Cancels the pending debounced submit, if any (`clear` no-ops on unknown ids). */
214
+ #cancelPending() {
215
+ this.#timers.clear(this.#pendingId);
216
+ this.#pendingId = 0;
217
+ }
218
+ /**
219
+ * Replaces the subscribed form symmetrically. The outgoing form loses the
220
+ * `turbo:submit-end`/composition listeners, its pending hook, and any pending
221
+ * debounced submit (which described the outgoing form). An in-flight `aria-busy`
222
+ * is left for `turbo:submit-end` or the pre-cache rewind — removing it here
223
+ * would wipe a legitimately in-progress submission.
224
+ */
225
+ #bindForm(form) {
226
+ if (form === this.#boundForm) return;
227
+ const previous = this.#boundForm;
228
+ if (previous) {
229
+ this.#cancelPending();
230
+ previous.removeAttribute("data-auto-submit-pending");
231
+ previous.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
232
+ this.#composition.unobserve(previous);
233
+ }
234
+ this.#boundForm = form;
235
+ if (!form) return;
236
+ form.addEventListener("turbo:submit-end", this.#onSubmitEnd);
237
+ this.#composition.observe(form);
238
+ }
239
+ /** Resolves the form: the explicit `form` target, else a `<form>` root, else null. */
240
+ #resolveForm() {
241
+ if (this.hasFormTarget) return this.formTarget;
242
+ return this.element instanceof HTMLFormElement ? this.element : null;
243
+ }
244
+ /** Returns the transient state hooks to their initial (absent) state. */
245
+ #rewindForCache() {
246
+ const inProgress = this.#pendingId !== 0 || this.#boundForm?.hasAttribute("aria-busy") === true;
247
+ this.#cancelPending();
248
+ this.#boundForm?.removeAttribute("data-auto-submit-pending");
249
+ this.#boundForm?.removeAttribute("aria-busy");
250
+ if (inProgress) this.dispatch("reconcile", { detail: {} });
173
251
  }
174
252
  /** Whether `type` is one of the whitespace-separated event types in `on`. */
175
253
  #triggers(type) {
@@ -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