stimeo-ui 0.8.0 → 0.10.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +163 -0
  3. data/dist/controllers/alert_dialog_controller.js +17 -7
  4. data/dist/controllers/announcer_controller.js +1 -1
  5. data/dist/controllers/bulk_select_controller.js +139 -28
  6. data/dist/controllers/clipboard_controller.js +102 -20
  7. data/dist/controllers/collapsible_controller.js +1 -1
  8. data/dist/controllers/color_picker_controller.js +180 -43
  9. data/dist/controllers/command_palette_controller.js +15 -5
  10. data/dist/controllers/confirm_controller.js +15 -5
  11. data/dist/controllers/countdown_controller.js +1 -1
  12. data/dist/controllers/data_grid_controller.js +151 -25
  13. data/dist/controllers/dialog_controller.js +15 -5
  14. data/dist/controllers/direct_upload_controller.js +1 -5
  15. data/dist/controllers/drawer_controller.js +17 -7
  16. data/dist/controllers/editable_controller.js +83 -30
  17. data/dist/controllers/filter_controller.js +32 -1
  18. data/dist/controllers/focus_controller.js +45 -7
  19. data/dist/controllers/form_field_controller.js +1 -1
  20. data/dist/controllers/masonry_controller.js +129 -17
  21. data/dist/controllers/menubar_controller.js +2 -2
  22. data/dist/controllers/multi_select_controller.js +1 -1
  23. data/dist/controllers/otp_controller.js +29 -16
  24. data/dist/controllers/overflow_menu_controller.js +2 -2
  25. data/dist/controllers/password_reveal_controller.js +102 -3
  26. data/dist/controllers/password_strength_controller.js +287 -42
  27. data/dist/controllers/reset_before_cache_controller.js +51 -5
  28. data/dist/controllers/resizable_controller.js +128 -55
  29. data/dist/controllers/scroll_restore_controller.js +127 -27
  30. data/dist/controllers/scroll_visibility_controller.js +167 -14
  31. data/dist/controllers/sidebar_controller.js +15 -5
  32. data/dist/controllers/step_indicator_controller.js +0 -5
  33. data/dist/controllers/theme_controller.js +151 -40
  34. data/dist/controllers/toast_controller.js +2 -2
  35. data/dist/controllers/transition_controller.js +79 -15
  36. data/dist/index.js +1612 -529
  37. data/dist/positioning/index.js +84 -25
  38. data/lib/stimeo/ui/version.rb +1 -1
  39. metadata +2 -2
@@ -2,6 +2,61 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/scroll_visibility_controller.ts
4
4
 
5
+ // src/utils/blur_deferral.ts
6
+ var BlurDeferral = class {
7
+ /** Elements currently holding an update back, mapped to their `blur` listener. */
8
+ #pending = /* @__PURE__ */ new Map();
9
+ /** Called after a pending element blurs and has been detached. */
10
+ #onRelease;
11
+ /** @param onRelease - Invoked once `element` actually blurs; never on `release`. */
12
+ constructor(onRelease) {
13
+ this.#onRelease = onRelease;
14
+ }
15
+ /** Number of elements currently holding an update back. */
16
+ get size() {
17
+ return this.#pending.size;
18
+ }
19
+ /** Snapshot of the pending elements, safe to iterate while releasing them. */
20
+ get elements() {
21
+ return [...this.#pending.keys()];
22
+ }
23
+ /** Whether `element` is currently holding an update back. */
24
+ has(element) {
25
+ return this.#pending.has(element);
26
+ }
27
+ /** Holds an update back until `element` blurs. Idempotent (no stacked listeners). */
28
+ defer(element) {
29
+ if (this.#pending.has(element)) return;
30
+ const onBlur = () => {
31
+ this.#detach(element);
32
+ this.#onRelease(element);
33
+ };
34
+ this.#pending.set(element, onBlur);
35
+ element.addEventListener("blur", onBlur);
36
+ }
37
+ /** Defers `element` as the only pending entry, cancelling any others. */
38
+ deferOnly(element) {
39
+ for (const pending of this.elements) {
40
+ if (pending !== element) this.#detach(pending);
41
+ }
42
+ this.defer(element);
43
+ }
44
+ /** Cancels `element`'s deferral without completing it; no-ops when not pending. */
45
+ release(element) {
46
+ this.#detach(element);
47
+ }
48
+ /** Cancels every deferral without completing any of them. */
49
+ releaseAll() {
50
+ for (const element of this.elements) this.#detach(element);
51
+ }
52
+ /** Removes the `blur` listener for `element` and forgets it. */
53
+ #detach(element) {
54
+ const onBlur = this.#pending.get(element);
55
+ if (onBlur) element.removeEventListener("blur", onBlur);
56
+ this.#pending.delete(element);
57
+ }
58
+ };
59
+
5
60
  // src/utils/reduced_motion.ts
6
61
  function prefersReducedMotion() {
7
62
  return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
@@ -68,10 +123,11 @@ var TabindexLoan = class {
68
123
  };
69
124
 
70
125
  // src/controllers/scroll_visibility_controller.ts
126
+ var DEFAULT_OFFSET = 400;
71
127
  var ScrollVisibilityController = class extends Controller {
72
128
  static targets = ["element"];
73
129
  static values = {
74
- offset: { type: Number, default: 400 },
130
+ offset: { type: Number, default: DEFAULT_OFFSET },
75
131
  mode: { type: String, default: "offset" },
76
132
  focusSelector: { type: String, default: "" },
77
133
  root: { type: String, default: "" }
@@ -89,8 +145,32 @@ var ScrollVisibilityController = class extends Controller {
89
145
  * the window. Captured on connect so teardown detaches from the same source.
90
146
  */
91
147
  #scrollSource = window;
148
+ /**
149
+ * Gates the declaration callbacks to the connected window.
150
+ *
151
+ * Stimulus delivers a Value callback ahead of `connect()` and again for every
152
+ * runtime change; without the gate, merely connecting would evaluate — and
153
+ * announce — before `connect()` runs its own first reflection.
154
+ */
155
+ #connected = false;
156
+ /** Validated threshold; a non-finite declaration reads as the default. */
157
+ #offset = DEFAULT_OFFSET;
158
+ /** Validated `root` selector; an unparsable declaration reads as absent. */
159
+ #rootSelector = "";
160
+ /** Validated `focusSelector`; an unparsable declaration reads as absent. */
161
+ #focusSelector = "";
92
162
  /** Focus targets this instance lent a `tabindex` to. */
93
163
  #tabindex = new TabindexLoan();
164
+ /**
165
+ * The hide held back while the control itself owns focus.
166
+ *
167
+ * Completing the deferral re-runs the ordinary evaluation rather than applying
168
+ * the stale decision: by the time focus leaves, the scroll position may have
169
+ * moved back past the threshold.
170
+ */
171
+ #pendingHide = new BlurDeferral(() => {
172
+ if (this.#connected) this.#evaluate();
173
+ });
94
174
  #onScroll = () => {
95
175
  if (this.#rafId !== null) return;
96
176
  this.#rafId = requestAnimationFrame(() => {
@@ -102,61 +182,134 @@ var ScrollVisibilityController = class extends Controller {
102
182
  this.#scrollSource = this.#resolveScrollSource();
103
183
  this.#lastScrollY = this.#scrollY();
104
184
  this.#scrollSource.addEventListener("scroll", this.#onScroll, { passive: true });
105
- this.#evaluate();
185
+ this.#evaluate(false);
186
+ this.#connected = true;
106
187
  }
107
188
  disconnect() {
189
+ this.#connected = false;
108
190
  this.#scrollSource.removeEventListener("scroll", this.#onScroll);
109
191
  if (this.#rafId !== null) {
110
192
  cancelAnimationFrame(this.#rafId);
111
193
  this.#rafId = null;
112
194
  }
195
+ this.#pendingHide.releaseAll();
113
196
  this.#tabindex.returnAll();
114
197
  this.#visible = null;
115
198
  }
199
+ /** Writes the current visibility onto a control that arrives after connect. */
200
+ elementTargetConnected(element) {
201
+ if (this.#visible !== null) element.hidden = !this.#visible;
202
+ }
203
+ /** Drops a held-back hide together with the control it was waiting on. */
204
+ elementTargetDisconnected() {
205
+ this.#pendingHide.releaseAll();
206
+ }
207
+ /**
208
+ * Validates `offset` once, then re-renders.
209
+ *
210
+ * Re-renders when application code (or a Turbo morph) changes `offset` at
211
+ * runtime. A declaration that is not a finite number reads as the default, so
212
+ * the comparison path never sees `NaN` — which would answer `false` to every
213
+ * comparison and strand the element (in `direction` mode, even the guarantee
214
+ * that the very top always reveals).
215
+ */
216
+ offsetValueChanged() {
217
+ this.#offset = Number.isFinite(this.offsetValue) ? this.offsetValue : DEFAULT_OFFSET;
218
+ if (this.#connected) this.#evaluate();
219
+ }
220
+ /** Re-renders when application code (or a Turbo morph) changes `mode` at runtime. */
221
+ modeValueChanged() {
222
+ if (this.#connected) this.#evaluate();
223
+ }
224
+ /** Validates `root` once so connect never parses a selector that throws. */
225
+ rootValueChanged() {
226
+ this.#rootSelector = this.#validSelector(this.rootValue);
227
+ }
228
+ /** Validates `focusSelector` once so `toTop` never parses a selector that throws. */
229
+ focusSelectorValueChanged() {
230
+ this.#focusSelector = this.#validSelector(this.focusSelectorValue);
231
+ }
116
232
  /** Scrolls the source to the top and, optionally, moves focus to a safe target. */
117
233
  toTop() {
118
234
  const behavior = prefersReducedMotion() ? "instant" : "smooth";
119
235
  this.#scrollSource.scrollTo({ top: 0, behavior });
120
- if (this.focusSelectorValue) {
121
- const target = document.querySelector(this.focusSelectorValue);
236
+ if (this.#focusSelector) {
237
+ const target = document.querySelector(this.#focusSelector);
122
238
  if (target) {
123
239
  this.#tabindex.lend(target);
124
- target.focus();
240
+ target.focus({ preventScroll: true });
125
241
  }
126
242
  }
127
243
  }
128
- /** Decides the next visibility from the current scroll state and applies it. */
129
- #evaluate() {
244
+ /**
245
+ * Decides the next visibility from the current scroll state and applies it.
246
+ *
247
+ * @param notify - whether a transition announces itself. The reflection
248
+ * `connect()` performs is the current state, not a change.
249
+ *
250
+ * @stimeoRenderRoot
251
+ */
252
+ #evaluate(notify = true) {
130
253
  const y = this.#scrollY();
131
254
  let nextVisible;
132
255
  if (this.modeValue === "direction") {
133
- if (y <= this.offsetValue) {
256
+ if (y <= this.#offset) {
134
257
  nextVisible = true;
258
+ } else if (y === this.#lastScrollY) {
259
+ return;
135
260
  } else {
136
261
  nextVisible = y < this.#lastScrollY;
137
262
  }
138
263
  } else {
139
- nextVisible = y > this.offsetValue;
264
+ nextVisible = y > this.#offset;
140
265
  }
141
266
  this.#lastScrollY = y;
142
- this.#setVisible(nextVisible);
267
+ this.#setVisible(nextVisible, notify);
143
268
  }
144
269
  /** Applies visibility to the target, syncing `hidden`, `data-state`, `change`. */
145
- #setVisible(next) {
270
+ #setVisible(next, notify) {
146
271
  if (next === this.#visible) return;
272
+ const focused = !next && this.hasElementTarget ? this.#focusedWithin() : null;
273
+ if (focused) {
274
+ this.#pendingHide.deferOnly(focused);
275
+ return;
276
+ }
147
277
  this.#visible = next;
148
278
  if (this.hasElementTarget) this.elementTarget.hidden = !next;
149
279
  this.element.setAttribute("data-state", next ? "visible" : "hidden");
150
- this.dispatch("change", { detail: { visible: next } });
280
+ if (notify) this.dispatch("change", { detail: { visible: next } });
151
281
  }
152
282
  /** Resolves the scroll source from `root` (falling back to the window). */
153
283
  #resolveScrollSource() {
154
- if (this.rootValue) {
155
- const root = document.querySelector(this.rootValue);
284
+ if (this.#rootSelector) {
285
+ const root = document.querySelector(this.#rootSelector);
156
286
  if (root) return root;
157
287
  }
158
288
  return window;
159
289
  }
290
+ /**
291
+ * The focus owner inside the target, or `null` when focus is elsewhere.
292
+ *
293
+ * `blur` does not bubble, so the deferral has to ride the focused element
294
+ * itself: waiting on a container that never receives the event would hold the
295
+ * hide forever.
296
+ */
297
+ #focusedWithin() {
298
+ const focused = document.activeElement;
299
+ if (focused instanceof HTMLElement && this.elementTarget.contains(focused)) return focused;
300
+ return null;
301
+ }
302
+ /** Returns `declared` when it parses as a selector, and `""` when it does not. */
303
+ #validSelector(declared) {
304
+ if (declared.length > 0) {
305
+ try {
306
+ this.element.matches(declared);
307
+ return declared;
308
+ } catch {
309
+ }
310
+ }
311
+ return "";
312
+ }
160
313
  #scrollY() {
161
314
  if (this.#scrollSource === window) {
162
315
  return window.scrollY ?? window.pageYOffset ?? 0;
@@ -315,15 +315,25 @@ var FocusTrap = class {
315
315
  * content cannot be focused or reached by assistive technology, honoring the
316
316
  * `aria-modal="true"` contract. An element that was *already* `inert` is left
317
317
  * untracked so `#releaseBackground` does not wrongly clear it.
318
+ *
319
+ * The walk climbs from the container to `body` and inerts each ancestor's other
320
+ * children. Scanning only `body`'s children would skip the branch the container
321
+ * sits in — everything beside it inside that branch is background too, and a
322
+ * nested container is the ordinary case.
318
323
  */
319
324
  #isolateBackground() {
320
325
  const container = this.#getContainer();
321
326
  this.#inertedSiblings = [];
322
- for (const sibling of Array.from(document.body.children)) {
323
- if (!(sibling instanceof HTMLElement)) continue;
324
- if (sibling.contains(container) || sibling.inert) continue;
325
- sibling.inert = true;
326
- this.#inertedSiblings.push(sibling);
327
+ for (let node = container; node !== document.body; ) {
328
+ const parent = node.parentElement;
329
+ if (!parent) break;
330
+ for (const sibling of Array.from(parent.children)) {
331
+ if (!(sibling instanceof HTMLElement)) continue;
332
+ if (sibling === node || sibling.inert) continue;
333
+ sibling.inert = true;
334
+ this.#inertedSiblings.push(sibling);
335
+ }
336
+ node = parent;
327
337
  }
328
338
  }
329
339
  /** Reverts the `inert` flags applied by `#isolateBackground`. */
@@ -43,11 +43,6 @@ var StepIndicatorController = class extends Controller {
43
43
  };
44
44
  static actions = ["setCurrent"];
45
45
  static events = ["change"];
46
- /**
47
- * Whether the target callbacks may render. Stimulus reports the authored steps
48
- * as connected before `connect()` and the remaining ones as disconnected after
49
- * `disconnect()`, so this keeps a connect at one render pass, not one per step.
50
- */
51
46
  /**
52
47
  * Collapses a batch of step callbacks — and a morph that swaps `current` with
53
48
  * them — into one repaint. Replacing a list of N steps delivers N callbacks, and
@@ -19,6 +19,49 @@ function isReservedArrowChord(event, allow = []) {
19
19
  if (!event.key.startsWith("Arrow")) return false;
20
20
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
21
21
  }
22
+ function hasModifierChord(event) {
23
+ return event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
24
+ }
25
+
26
+ // src/utils/roving_tabindex.ts
27
+ var RovingTabindex = class {
28
+ /** Returns the current ordered item elements; called on every operation. */
29
+ #getItems;
30
+ /**
31
+ * @param getItems - Returns the current ordered item elements. Called on every
32
+ * operation so the live target list is always used.
33
+ */
34
+ constructor(getItems) {
35
+ this.#getItems = getItems;
36
+ }
37
+ /** Index of the currently tabbable item (`tabindex="0"`), or `-1` if none. */
38
+ get activeIndex() {
39
+ return this.#getItems().findIndex((item) => item.tabIndex === 0);
40
+ }
41
+ /**
42
+ * Makes exactly the item at `index` tabbable (`tabindex="0"`) and removes every
43
+ * other item from the Tab sequence (`tabindex="-1"`). An out-of-range `index`
44
+ * (e.g. `-1`) leaves all items at `-1`, which a controller can use to express
45
+ * "nothing is currently tabbable".
46
+ *
47
+ * @param index - Position of the item to make tabbable.
48
+ * @param options - Pass `{ focus: true }` to also move DOM focus to that item,
49
+ * and `items` to reuse an event-scoped collection snapshot.
50
+ */
51
+ setActive(index, options = {}) {
52
+ const { focus = false } = options;
53
+ const items = options.items ?? this.#getItems();
54
+ items.forEach((item, i) => {
55
+ item.tabIndex = i === index ? 0 : -1;
56
+ });
57
+ if (focus) items[index]?.focus();
58
+ }
59
+ };
60
+ function rovingMove(current, length, delta, wrap) {
61
+ if (length === 0) return -1;
62
+ const next = current + delta;
63
+ return (next + length) % length;
64
+ }
22
65
 
23
66
  // src/utils/safe_storage.ts
24
67
  function readLocalStorage(key) {
@@ -40,35 +83,48 @@ function writeLocalStorage(key, value) {
40
83
  // src/controllers/theme_controller.ts
41
84
  var MODES = ["light", "dark", "system"];
42
85
  var isMode = (value) => typeof value === "string" && MODES.includes(value);
86
+ var DEFAULT_MODE = "system";
87
+ var DEFAULT_TARGET = "html";
43
88
  var ThemeController = class extends Controller {
44
89
  static targets = ["option"];
45
90
  static values = {
46
- mode: { type: String, default: "system" },
91
+ mode: { type: String, default: DEFAULT_MODE },
47
92
  storageKey: { type: String, default: "stimeo-theme" },
48
- target: { type: String, default: "html" }
93
+ target: { type: String, default: DEFAULT_TARGET }
49
94
  };
50
95
  static actions = ["set", "toggle"];
51
96
  static events = ["change"];
52
97
  /** The OS dark-mode query, watched so `system` tracks live changes. */
53
98
  #media = null;
99
+ /** Gate for the target callbacks, which Stimulus runs before `connect()`. */
100
+ #connected = false;
101
+ /** The `target` declaration after validation; the default when unparsable. */
102
+ #targetSelector = DEFAULT_TARGET;
103
+ /** Owns the single Tab stop across the option set (APG radiogroup). */
104
+ #roving = new RovingTabindex(() => this.optionTargets);
105
+ /**
106
+ * The pair last reported, so a move can be told from a repeat. Neither side is
107
+ * readable after the fact — assigning the Value updates the mode before any
108
+ * comparison, and the OS query has already flipped by the time it notifies —
109
+ * so what was reported has to be kept rather than recomputed.
110
+ */
111
+ #published = {
112
+ mode: DEFAULT_MODE,
113
+ resolved: "light"
114
+ };
54
115
  /** Re-resolves while in `system` mode when the OS preference flips. */
55
116
  #onMediaChange = () => {
56
- if (this.modeValue === "system") {
57
- this.#applyTheme();
58
- this.#syncControls();
59
- this.#dispatchChange();
60
- }
117
+ if (this.#mode !== "system") return;
118
+ this.#commit();
61
119
  };
62
120
  /** Arrow/Home/End navigation for the radiogroup (APG radio pattern). */
63
121
  #onKeydown = (event) => {
64
122
  if (event.defaultPrevented) return;
65
123
  if (isReservedArrowChord(event)) return;
66
124
  const options = this.optionTargets;
67
- if (options.length === 0) return;
68
125
  const target = event.target;
69
126
  const current = options.indexOf(target);
70
127
  if (current === -1) return;
71
- const last = options.length - 1;
72
128
  let next = current;
73
129
  const step = logicalArrowStep(event.key, this.element);
74
130
  switch (event.key) {
@@ -76,13 +132,12 @@ var ThemeController = class extends Controller {
76
132
  case "ArrowRight":
77
133
  case "ArrowUp":
78
134
  case "ArrowLeft":
79
- next = step === 1 ? current === last ? 0 : current + 1 : current === 0 ? last : current - 1;
135
+ next = rovingMove(current, options.length, step);
80
136
  break;
81
137
  case "Home":
82
- next = 0;
83
- break;
84
138
  case "End":
85
- next = last;
139
+ if (hasModifierChord(event)) return;
140
+ next = event.key === "Home" ? 0 : options.length - 1;
86
141
  break;
87
142
  default:
88
143
  return;
@@ -91,25 +146,58 @@ var ThemeController = class extends Controller {
91
146
  const option = options[next];
92
147
  if (!option) return;
93
148
  option.focus();
94
- this.#setMode(this.#optionMode(option));
149
+ const mode = this.#optionMode(option);
150
+ if (mode) this.#setMode(mode);
95
151
  };
96
152
  connect() {
97
153
  const stored = this.#readStored();
98
154
  if (stored) this.modeValue = stored;
99
155
  this.#media = window.matchMedia?.("(prefers-color-scheme: dark)") ?? null;
100
156
  this.#media?.addEventListener("change", this.#onMediaChange);
101
- if (this.hasOptionTarget) this.element.addEventListener("keydown", this.#onKeydown);
157
+ this.element.addEventListener("keydown", this.#onKeydown);
158
+ this.#connected = true;
102
159
  this.#applyTheme();
103
160
  this.#syncControls();
161
+ this.#published = this.#current;
104
162
  }
105
163
  disconnect() {
164
+ this.#connected = false;
106
165
  this.#media?.removeEventListener("change", this.#onMediaChange);
107
166
  this.element.removeEventListener("keydown", this.#onKeydown);
108
167
  }
109
- /** Selects an explicit mode from the `mode` action param (radiogroup option). */
168
+ /** Validates the `target` declaration once, so the render path never parses. */
169
+ targetValueChanged() {
170
+ const selector = this.targetValue;
171
+ if (selector.length > 0) {
172
+ try {
173
+ this.element.matches(selector);
174
+ this.#targetSelector = selector;
175
+ return;
176
+ } catch {
177
+ }
178
+ }
179
+ this.#targetSelector = DEFAULT_TARGET;
180
+ }
181
+ /** Re-derives the single Tab stop and ARIA for an option set that changed. */
182
+ optionTargetConnected() {
183
+ if (this.#connected) this.#syncControls();
184
+ }
185
+ /** Re-derives them again when an option leaves, so a Tab stop always remains. */
186
+ optionTargetDisconnected() {
187
+ if (this.#connected) this.#syncControls();
188
+ }
189
+ /**
190
+ * Selects the mode the activated option declares.
191
+ *
192
+ * Read through {@link ThemeController.#optionMode}, the same lane that decides
193
+ * which option is checked, so the two can never disagree about what an option
194
+ * declares.
195
+ */
110
196
  set(event) {
111
- const mode = event.params?.mode;
112
- if (isMode(mode)) this.#setMode(mode);
197
+ const option = event.currentTarget;
198
+ if (!(option instanceof HTMLElement)) return;
199
+ const mode = this.#optionMode(option);
200
+ if (mode) this.#setMode(mode);
113
201
  }
114
202
  /** Toggles light↔dark for the 2-value single-button contract. */
115
203
  toggle() {
@@ -119,9 +207,26 @@ var ThemeController = class extends Controller {
119
207
  #setMode(mode) {
120
208
  this.modeValue = mode;
121
209
  this.#writeStored(mode);
210
+ this.#commit();
211
+ }
212
+ /**
213
+ * Applies the current mode and reports it, but reports only a real move: the
214
+ * event means "the selection or the effective theme moved", so re-choosing the
215
+ * option already chosen is not one.
216
+ */
217
+ #commit() {
122
218
  this.#applyTheme();
123
219
  this.#syncControls();
124
- this.#dispatchChange();
220
+ const next = this.#current;
221
+ const last = this.#published;
222
+ this.#published = next;
223
+ if (last.mode !== next.mode || last.resolved !== next.resolved) {
224
+ this.dispatch("change", { detail: { ...next } });
225
+ }
226
+ }
227
+ /** The pair the `change` detail carries, read from current state. */
228
+ get #current() {
229
+ return { mode: this.#mode, resolved: this.#resolved() };
125
230
  }
126
231
  /** Writes `data-theme` + `color-scheme` (the resolved theme) onto the target. */
127
232
  #applyTheme() {
@@ -135,39 +240,45 @@ var ThemeController = class extends Controller {
135
240
  #syncControls() {
136
241
  const options = this.optionTargets;
137
242
  if (options.length > 0) {
138
- let hasTabbable = false;
139
- for (const option of options) {
140
- const selected = this.#optionMode(option) === this.modeValue;
141
- option.setAttribute("aria-checked", String(selected));
142
- option.tabIndex = selected ? 0 : -1;
143
- hasTabbable ||= selected;
144
- }
145
- const first = options[0];
146
- if (!hasTabbable && first) first.tabIndex = 0;
243
+ const mode = this.#mode;
244
+ let selected = -1;
245
+ options.forEach((option, index) => {
246
+ const isSelected = this.#optionMode(option) === mode;
247
+ option.setAttribute("aria-checked", String(isSelected));
248
+ if (isSelected && selected === -1) selected = index;
249
+ });
250
+ this.#roving.setActive(selected === -1 ? 0 : selected, { items: options });
147
251
  return;
148
252
  }
149
- this.element.setAttribute("aria-pressed", String(this.#resolved() === "dark"));
253
+ if (this.#isToggleButton) {
254
+ this.element.setAttribute("aria-pressed", String(this.#resolved() === "dark"));
255
+ }
256
+ }
257
+ /** Whether the controller element is the button of the 2-value contract. */
258
+ get #isToggleButton() {
259
+ return this.element.tagName === "BUTTON" || this.element.getAttribute("role") === "button";
150
260
  }
151
- /** Emits `change` with the selected mode and the resolved theme. */
152
- #dispatchChange() {
153
- this.dispatch("change", { detail: { mode: this.modeValue, resolved: this.#resolved() } });
261
+ /** The selected mode after validation; an unreadable declaration is the default. */
262
+ get #mode() {
263
+ return isMode(this.modeValue) ? this.modeValue : DEFAULT_MODE;
154
264
  }
155
265
  /** The effective theme: the OS preference when `system`, else the mode itself. */
156
266
  #resolved() {
157
- if (this.modeValue === "dark") return "dark";
158
- if (this.modeValue === "light") return "light";
267
+ const mode = this.#mode;
268
+ if (mode === "dark") return "dark";
269
+ if (mode === "light") return "light";
159
270
  return this.#media?.matches ? "dark" : "light";
160
271
  }
161
- /** Reads an option's mode from its action param attribute. */
272
+ /** An option's mode from its `data-value`, or `null` when that is not one of the three. */
162
273
  #optionMode(option) {
163
- const mode = option.getAttribute("data-stimeo--theme-mode-param");
164
- return isMode(mode) ? mode : "system";
274
+ const mode = option.getAttribute("data-value");
275
+ return isMode(mode) ? mode : null;
165
276
  }
166
277
  /** Resolves the state-hook target (`<html>` by default). */
167
278
  #targetElement() {
168
- if (this.targetValue === "html" || this.targetValue === ":root")
169
- return document.documentElement;
170
- return document.querySelector(this.targetValue);
279
+ const selector = this.#targetSelector;
280
+ if (selector === DEFAULT_TARGET || selector === ":root") return document.documentElement;
281
+ return document.querySelector(selector);
171
282
  }
172
283
  /** Reads a persisted, validated mode from `localStorage` (null when absent/blocked). */
173
284
  #readStored() {
@@ -170,8 +170,8 @@ var ToastController = class extends Controller {
170
170
  }
171
171
  /**
172
172
  * Stimulus lifecycle callback triggered automatically when a new item target
173
- * enters the DOM. Perfectly handles dynamic client-side injections and server-side
174
- * Turbo Stream appends alike.
173
+ * enters the DOM, from a client-side injection or a Turbo Stream append alike. An
174
+ * item already leaving, or parented outside `list`, is skipped.
175
175
  */
176
176
  itemTargetConnected(element) {
177
177
  this.enforceMaxLimit();