stimeo-ui 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +184 -0
  3. data/dist/controllers/aspect_ratio_controller.js +19 -11
  4. data/dist/controllers/avatar_controller.js +195 -40
  5. data/dist/controllers/breadcrumb_controller.js +5 -1
  6. data/dist/controllers/carousel_controller.js +90 -10
  7. data/dist/controllers/checkbox_controller.js +136 -25
  8. data/dist/controllers/clipboard_controller.js +8 -3
  9. data/dist/controllers/collapsible_controller.js +4 -1
  10. data/dist/controllers/color_picker_controller.js +41 -11
  11. data/dist/controllers/context_menu_controller.js +2 -2
  12. data/dist/controllers/countdown_controller.js +5 -1
  13. data/dist/controllers/date_range_picker_controller.js +162 -31
  14. data/dist/controllers/direct_upload_controller.js +3 -3
  15. data/dist/controllers/empty_state_controller.js +107 -16
  16. data/dist/controllers/file_dropzone_controller.js +26 -3
  17. data/dist/controllers/flash_controller.js +161 -21
  18. data/dist/controllers/form_validation_controller.js +8 -2
  19. data/dist/controllers/frame_loading_controller.js +94 -22
  20. data/dist/controllers/highlight_controller.js +38 -1
  21. data/dist/controllers/idle_controller.js +39 -6
  22. data/dist/controllers/local_time_controller.js +2 -0
  23. data/dist/controllers/masonry_controller.js +1 -1
  24. data/dist/controllers/menubar_controller.js +5 -3
  25. data/dist/controllers/meter_controller.js +3 -1
  26. data/dist/controllers/multi_select_controller.js +460 -151
  27. data/dist/controllers/network_status_controller.js +1 -3
  28. data/dist/controllers/number_input_controller.js +455 -64
  29. data/dist/controllers/overflow_menu_controller.js +5 -1
  30. data/dist/controllers/pagination_controller.js +38 -1
  31. data/dist/controllers/password_strength_controller.js +21 -3
  32. data/dist/controllers/persist_controller.js +24 -5
  33. data/dist/controllers/pointer_drag_controller.js +10 -0
  34. data/dist/controllers/portal_controller.js +10 -0
  35. data/dist/controllers/progress_controller.js +7 -3
  36. data/dist/controllers/radio_group_controller.js +540 -56
  37. data/dist/controllers/range_slider_controller.js +385 -94
  38. data/dist/controllers/rating_controller.js +274 -89
  39. data/dist/controllers/relative_time_controller.js +2 -0
  40. data/dist/controllers/resizable_controller.js +33 -0
  41. data/dist/controllers/roving_controller.js +60 -5
  42. data/dist/controllers/scroll_area_controller.js +155 -23
  43. data/dist/controllers/scroll_visibility_controller.js +33 -0
  44. data/dist/controllers/separator_controller.js +13 -17
  45. data/dist/controllers/skeleton_controller.js +71 -3
  46. data/dist/controllers/slider_controller.js +325 -48
  47. data/dist/controllers/spinner_controller.js +18 -3
  48. data/dist/controllers/step_indicator_controller.js +3 -1
  49. data/dist/controllers/stepper_controller.js +2 -0
  50. data/dist/controllers/switch_controller.js +162 -18
  51. data/dist/controllers/tags_input_controller.js +356 -120
  52. data/dist/controllers/textarea_autosize_controller.js +1 -1
  53. data/dist/controllers/time_picker_controller.js +296 -104
  54. data/dist/controllers/toggle_group_controller.js +378 -55
  55. data/dist/controllers/toolbar_controller.js +5 -3
  56. data/dist/controllers/tree_view_controller.js +24 -4
  57. data/dist/index.js +3811 -1048
  58. data/lib/stimeo/ui/version.rb +1 -1
  59. metadata +2 -2
@@ -20,6 +20,67 @@ function isReservedArrowChord(event, allow = []) {
20
20
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
21
21
  }
22
22
 
23
+ // src/utils/focus_candidate.ts
24
+ function inheritsFieldsetDisabled(control) {
25
+ let fieldset = control.closest("fieldset[disabled]");
26
+ while (fieldset) {
27
+ const legend = Array.from(fieldset.children).find((child) => child.tagName === "LEGEND");
28
+ if (!legend?.contains(control)) return true;
29
+ fieldset = fieldset.parentElement?.closest("fieldset[disabled]") ?? null;
30
+ }
31
+ return false;
32
+ }
33
+
34
+ // src/utils/interactive_host.ts
35
+ var INTERACTIVE_HOST_SELECTOR = "button, input, select, textarea, label, a[href], area[href], summary, details, audio[controls], video[controls], iframe, object, embed";
36
+ function isInteractiveHost(element) {
37
+ if (element.matches(INTERACTIVE_HOST_SELECTOR)) return true;
38
+ let current = element;
39
+ while (current) {
40
+ const raw = current.getAttribute("contenteditable");
41
+ if (raw !== null) {
42
+ const value = raw.trim().toLowerCase();
43
+ if (value === "false") return false;
44
+ if (value === "" || value === "true" || value === "plaintext-only") return true;
45
+ }
46
+ current = current.parentElement;
47
+ }
48
+ return false;
49
+ }
50
+
51
+ // src/utils/microtask_coalescer.ts
52
+ var MicrotaskCoalescer = class {
53
+ #run;
54
+ #queued = false;
55
+ #active = false;
56
+ #generation = 0;
57
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
58
+ constructor(run) {
59
+ this.#run = run;
60
+ }
61
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
62
+ activate() {
63
+ this.#active = true;
64
+ }
65
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
66
+ cancel() {
67
+ this.#active = false;
68
+ this.#queued = false;
69
+ this.#generation += 1;
70
+ }
71
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
72
+ schedule() {
73
+ if (!this.#active || this.#queued) return;
74
+ this.#queued = true;
75
+ const generation = this.#generation;
76
+ queueMicrotask(() => {
77
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
78
+ this.#queued = false;
79
+ this.#run();
80
+ });
81
+ }
82
+ };
83
+
23
84
  // src/utils/roving_tabindex.ts
24
85
  var RovingTabindex = class {
25
86
  /** Returns the current ordered item elements; called on every operation. */
@@ -42,10 +103,12 @@ var RovingTabindex = class {
42
103
  * "nothing is currently tabbable".
43
104
  *
44
105
  * @param index - Position of the item to make tabbable.
45
- * @param options - Pass `{ focus: true }` to also move DOM focus to that item.
106
+ * @param options - Pass `{ focus: true }` to also move DOM focus to that item,
107
+ * and `items` to reuse an event-scoped collection snapshot.
46
108
  */
47
- setActive(index, { focus = false } = {}) {
48
- const items = this.#getItems();
109
+ setActive(index, options = {}) {
110
+ const { focus = false } = options;
111
+ const items = options.items ?? this.#getItems();
49
112
  items.forEach((item, i) => {
50
113
  item.tabIndex = i === index ? 0 : -1;
51
114
  });
@@ -59,79 +122,500 @@ function rovingMove(current, length, delta, wrap) {
59
122
  }
60
123
 
61
124
  // src/controllers/radio_group_controller.ts
125
+ var OBSERVED_ATTRIBUTES = [
126
+ "aria-checked",
127
+ "aria-disabled",
128
+ "contenteditable",
129
+ "controls",
130
+ "data-value",
131
+ "disabled",
132
+ "hidden",
133
+ "href",
134
+ "tabindex",
135
+ "type",
136
+ "value"
137
+ ];
138
+ var hasModifier = (event) => event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
62
139
  var RadioGroupController = class extends Controller {
63
140
  static targets = ["radio", "field"];
64
141
  static actions = ["onKeydown", "select"];
65
- static events = ["change"];
66
- #roving = new RovingTabindex(() => this.radioTargets);
67
- /** Establishes the roving entry point and reflects the initial selection. */
142
+ static events = ["change", "reconcile"];
143
+ #roving = new RovingTabindex(() => this.#managedTargets);
144
+ #reconcile = new MicrotaskCoalescer(() => this.#reconcileDom());
145
+ #handledEvents = /* @__PURE__ */ new WeakSet();
146
+ #managedRadios = /* @__PURE__ */ new Set();
147
+ #originalTabindex = /* @__PURE__ */ new Map();
148
+ #ownedChecked = /* @__PURE__ */ new Set();
149
+ #internalCheckedValues = /* @__PURE__ */ new Map();
150
+ #internalTabindexValues = /* @__PURE__ */ new Map();
151
+ #observer = null;
152
+ #committedRadio = null;
153
+ #connected = false;
154
+ #lastOrder = [];
155
+ #focusedRadio = null;
156
+ #pendingFocusIndex = null;
157
+ #preferChecked = false;
158
+ /**
159
+ * Normalizes authored selection, establishes the APG Tab entry point, and
160
+ * reflects derived form state without reporting a user edit.
161
+ */
68
162
  connect() {
69
- const selected = this.#selectedIndex();
70
- this.#roving.setActive(selected === -1 ? 0 : selected);
71
- if (selected !== -1) this.#reflectField(this.radioTargets[selected], { silent: true });
163
+ const currentRadios = new Set(this.radioTargets);
164
+ for (const radio of this.#managedRadios) {
165
+ if (!currentRadios.has(radio)) this.#releaseRadio(radio);
166
+ }
167
+ for (const radio of this.radioTargets) this.#reconcileHost(radio, false);
168
+ this.#normalizeSelection();
169
+ this.#ensureTabStop(true);
170
+ this.#reflectField(this.#selectedRadio, { silent: true });
171
+ this.#committedRadio = this.#selectedRadio ?? null;
172
+ this.#lastOrder = this.#managedTargets;
173
+ this.element.addEventListener("click", this.#onClickCapture, true);
174
+ this.element.addEventListener("keydown", this.#onKeydownCapture, true);
175
+ this.element.addEventListener("click", this.#onClick);
176
+ this.element.addEventListener("keydown", this.#onKeydown);
177
+ this.element.addEventListener("focusin", this.#onFocusin);
178
+ this.element.addEventListener("focusout", this.#onFocusout);
179
+ this.#connected = true;
180
+ this.#preferChecked = false;
181
+ this.#internalCheckedValues.clear();
182
+ this.#internalTabindexValues.clear();
183
+ this.#reconcile.activate();
184
+ this.#observeMutations();
185
+ }
186
+ /** Releases every listener, observer, and queued pass while retaining live DOM state. */
187
+ disconnect() {
188
+ this.#connected = false;
189
+ this.#reconcile.cancel();
190
+ this.element.removeEventListener("click", this.#onClickCapture, true);
191
+ this.element.removeEventListener("keydown", this.#onKeydownCapture, true);
192
+ this.element.removeEventListener("click", this.#onClick);
193
+ this.element.removeEventListener("keydown", this.#onKeydown);
194
+ this.element.removeEventListener("focusin", this.#onFocusin);
195
+ this.element.removeEventListener("focusout", this.#onFocusout);
196
+ this.#observer?.disconnect();
197
+ this.#observer = null;
198
+ this.#internalCheckedValues.clear();
199
+ this.#internalTabindexValues.clear();
200
+ this.#focusedRadio = null;
201
+ this.#pendingFocusIndex = null;
202
+ this.#committedRadio = null;
203
+ this.#preferChecked = false;
204
+ }
205
+ /** Removes a newly connected radio from the Tab sequence before batch reconciliation. */
206
+ radioTargetConnected(radio) {
207
+ if (this.#connected === false) return;
208
+ this.#reconcileHost(radio, true);
209
+ this.#preferChecked = true;
210
+ this.#reconcile.schedule();
211
+ }
212
+ /** Releases target-only attributes and repairs selection, form state, focus, and roving. */
213
+ radioTargetDisconnected(radio) {
214
+ if (!this.#connected) return;
215
+ if (this.#focusedRadio === radio) {
216
+ this.#pendingFocusIndex = this.#lastOrder.indexOf(radio);
217
+ this.#focusedRadio = null;
218
+ }
219
+ this.#releaseRadio(radio);
220
+ this.#preferChecked = true;
221
+ this.#reconcile.schedule();
72
222
  }
73
- /** Selects the clicked radio. Bound via `data-action` (click). */
223
+ /** Reflects the current selection into a field added or replaced at runtime. */
224
+ fieldTargetConnected() {
225
+ this.#reconcile.schedule();
226
+ }
227
+ /** Reconciles after a field target is removed from a retained group. */
228
+ fieldTargetDisconnected() {
229
+ this.#reconcile.schedule();
230
+ }
231
+ /** Selects the action's radio. Per-radio action wiring is optional. */
74
232
  select(event) {
75
- const index = this.radioTargets.indexOf(event.currentTarget);
76
- if (index !== -1) this.#selectIndex(index, { focus: false });
233
+ if (event.defaultPrevented || !this.#ownsEventTarget(event.target)) return;
234
+ const radio = event.currentTarget;
235
+ if (!radio || !this.radioTargets.includes(radio)) return;
236
+ this.#handledEvents.add(event);
237
+ this.#selectRadio(radio, { focus: false });
77
238
  }
78
- /** Arrow/Home/End/Space navigation with selection-follows-focus. */
239
+ /** Applies the APG key map to the action's radio. Per-radio action wiring is optional. */
79
240
  onKeydown(event) {
80
- if (event.defaultPrevented) return;
81
- if (isReservedArrowChord(event)) return;
82
- const current = this.radioTargets.indexOf(event.currentTarget);
83
- if (current === -1) return;
84
- let next = null;
85
- const step = logicalArrowStep(event.key, this.element);
86
- switch (event.key) {
87
- case "ArrowDown":
88
- case "ArrowRight":
89
- case "ArrowUp":
90
- case "ArrowLeft":
91
- next = rovingMove(current, this.radioTargets.length, step);
92
- break;
93
- case "Home":
94
- next = 0;
95
- break;
96
- case "End":
97
- next = this.radioTargets.length - 1;
98
- break;
99
- case " ":
100
- next = current;
101
- break;
102
- default:
241
+ const radio = event.currentTarget;
242
+ if (!radio || !this.radioTargets.includes(radio)) return;
243
+ this.#handleKeydown(event, radio);
244
+ }
245
+ /** Blocks disabled pointer activation before per-radio and consumer handlers run. */
246
+ #onClickCapture = (event) => {
247
+ const radio = this.#radioForEventTarget(event.target);
248
+ if (!radio || !this.#isSupportedHost(radio) || !this.#isActivationDisabled(radio)) return;
249
+ event.preventDefault();
250
+ event.stopImmediatePropagation();
251
+ };
252
+ /** Blocks disabled activation and suppresses a button's non-APG Enter activation. */
253
+ #onKeydownCapture = (event) => {
254
+ if (event.key !== " " && event.key !== "Enter") return;
255
+ const radio = this.#radioForEventTarget(event.target);
256
+ if (!radio || !this.#isSupportedHost(radio)) return;
257
+ if (this.#isActivationDisabled(radio) || event.key === "Enter" && radio instanceof HTMLButtonElement) {
258
+ event.preventDefault();
259
+ event.stopImmediatePropagation();
260
+ }
261
+ };
262
+ /** Delegated click path for static and runtime-added radios without actions. */
263
+ #onClick = (event) => {
264
+ if (this.#handledEvents.delete(event) || event.defaultPrevented) return;
265
+ const radio = this.#radioForEventTarget(event.target);
266
+ if (radio) this.#selectRadio(radio, { focus: false });
267
+ };
268
+ /** Delegated keydown path for static and runtime-added radios without actions. */
269
+ #onKeydown = (event) => {
270
+ const radio = this.#radioForEventTarget(event.target);
271
+ if (radio) this.#handleKeydown(event, radio);
272
+ };
273
+ /** Keeps programmatic and pointer focus as the group's current roving position. */
274
+ #onFocusin = (event) => {
275
+ const radio = this.#radioForEventTarget(event.target);
276
+ if (!radio || !this.#isNavigable(radio)) return;
277
+ this.#focusedRadio = radio;
278
+ this.#setActive(radio);
279
+ };
280
+ /** Distinguishes ordinary focus departure from focus lost because its radio was removed. */
281
+ #onFocusout = (event) => {
282
+ const radio = this.#radioForEventTarget(event.target);
283
+ if (!radio) return;
284
+ queueMicrotask(() => {
285
+ if (!this.#connected || this.#focusedRadio !== radio || !radio.isConnected) return;
286
+ const active = document.activeElement;
287
+ if (!(active instanceof Node) || !radio.contains(active)) this.#focusedRadio = null;
288
+ });
289
+ };
290
+ /** Applies navigation and Space activation to one supported radio. */
291
+ #handleKeydown(event, radio) {
292
+ if (event.defaultPrevented || !this.#isSupportedHost(radio)) return;
293
+ if (event.isComposing || isReservedArrowChord(event)) return;
294
+ if ((event.key === "Home" || event.key === "End") && hasModifier(event)) return;
295
+ if (event.key === " ") {
296
+ if (this.#isActivationDisabled(radio)) {
297
+ event.preventDefault();
298
+ event.stopImmediatePropagation();
103
299
  return;
300
+ }
301
+ if (radio instanceof HTMLButtonElement) {
302
+ if (event.repeat) event.preventDefault();
303
+ return;
304
+ }
305
+ event.preventDefault();
306
+ if (!event.repeat) this.#selectRadio(radio, { focus: true });
307
+ return;
308
+ }
309
+ const items = this.#managedTargets;
310
+ const current = items.indexOf(radio);
311
+ const step = logicalArrowStep(event.key, this.element);
312
+ let destination;
313
+ if (step !== 0) {
314
+ destination = this.#nextNavigable(current, step);
315
+ } else if (event.key === "Home") {
316
+ destination = this.#navigableRadios[0];
317
+ } else if (event.key === "End") {
318
+ const navigable = this.#navigableRadios;
319
+ destination = navigable[navigable.length - 1];
320
+ } else {
321
+ return;
104
322
  }
105
323
  event.preventDefault();
106
- this.#selectIndex(next, { focus: true });
324
+ if (!destination) return;
325
+ if (this.#isActivationDisabled(destination)) this.#setActive(destination, true);
326
+ else this.#selectRadio(destination, { focus: true });
327
+ }
328
+ /** Applies one user selection and emits only when selected identity changes. */
329
+ #selectRadio(radio, { focus }) {
330
+ if (!this.#isSupportedHost(radio) || this.#isActivationDisabled(radio)) return;
331
+ const previous = this.#selectedRadio;
332
+ const changed = previous !== radio;
333
+ if (changed) {
334
+ for (const item of this.#managedTargets) this.#setChecked(item, item === radio);
335
+ }
336
+ this.#setActive(radio, focus);
337
+ this.#reflectField(radio, { silent: !changed });
338
+ this.#committedRadio = radio;
339
+ if (changed) {
340
+ this.dispatch("change", { detail: { value: this.#radioValue(radio), radio } });
341
+ }
342
+ }
343
+ /** Reconciles target ownership, selection, roving, focus continuity, and form state. */
344
+ #reconcileDom() {
345
+ const observer = this.#observer;
346
+ observer?.disconnect();
347
+ for (const radio of this.radioTargets) this.#reconcileHost(radio, true);
348
+ this.#normalizeSelection();
349
+ const pendingFocusIndex = this.#pendingFocusIndex;
350
+ this.#pendingFocusIndex = null;
351
+ if (pendingFocusIndex !== null) {
352
+ const destination = this.#nearestNavigable(pendingFocusIndex);
353
+ this.#setActive(destination, destination !== null);
354
+ } else {
355
+ this.#ensureTabStop(this.#preferChecked);
356
+ }
357
+ this.#reflectField(this.#selectedRadio, { silent: true });
358
+ this.#lastOrder = this.#managedTargets;
359
+ this.#preferChecked = false;
360
+ this.#internalCheckedValues.clear();
361
+ this.#internalTabindexValues.clear();
362
+ if (this.#connected && observer) this.#observeWith(observer);
363
+ this.#reportReconciledSelection();
107
364
  }
108
365
  /**
109
- * Checks the radio at `index`, clears the rest, updates the roving Tab stop and
110
- * the hidden field, and dispatches `change`.
366
+ * Announces a selection this pass decided rather than the user. Dispatched after
367
+ * observation resumes so a consumer's own DOM edits are seen by the next pass.
111
368
  */
112
- #selectIndex(index, { focus }) {
113
- const radio = this.radioTargets[index];
114
- if (!radio) return;
115
- this.radioTargets.forEach((item, i) => {
116
- item.setAttribute("aria-checked", i === index ? "true" : "false");
369
+ #reportReconciledSelection() {
370
+ const settled = this.#selectedRadio ?? null;
371
+ if (settled === this.#committedRadio) return;
372
+ this.#committedRadio = settled;
373
+ this.dispatch("reconcile", {
374
+ detail: { value: settled ? this.#radioValue(settled) : "", radio: settled }
117
375
  });
118
- this.#roving.setActive(index, { focus });
119
- this.#reflectField(radio);
120
- this.dispatch("change", { detail: { value: this.#radioValue(radio), radio } });
121
376
  }
122
- /** Index of the currently checked radio, or `-1` if none. */
123
- #selectedIndex() {
124
- return this.radioTargets.findIndex((radio) => radio.getAttribute("aria-checked") === "true");
377
+ /** Begins or ends ownership according to a radio's current host semantics. */
378
+ #reconcileHost(radio, dropFromTabSequence) {
379
+ if (!this.#isSupportedHost(radio)) {
380
+ this.#releaseRadio(radio);
381
+ return;
382
+ }
383
+ if (!this.#managedRadios.has(radio)) {
384
+ this.#managedRadios.add(radio);
385
+ this.#originalTabindex.set(radio, radio.getAttribute("tabindex"));
386
+ if (dropFromTabSequence) this.#writeTabindex(radio, -1);
387
+ }
388
+ this.#normalizeChecked(radio);
389
+ }
390
+ /** Restores only defaults owned while an element belonged to this group. */
391
+ #releaseRadio(radio) {
392
+ this.#managedRadios.delete(radio);
393
+ const original = this.#originalTabindex.get(radio);
394
+ if (original === null) {
395
+ this.#markInternal(this.#internalTabindexValues, radio, null);
396
+ radio.removeAttribute("tabindex");
397
+ } else if (original !== void 0) {
398
+ this.#markInternal(this.#internalTabindexValues, radio, original);
399
+ radio.setAttribute("tabindex", original);
400
+ }
401
+ this.#originalTabindex.delete(radio);
402
+ if (this.#ownedChecked.delete(radio)) {
403
+ this.#markInternal(this.#internalCheckedValues, radio, null);
404
+ radio.removeAttribute("aria-checked");
405
+ }
406
+ }
407
+ /** Supplies a missing checked state and normalizes invalid ARIA tokens. */
408
+ #normalizeChecked(radio) {
409
+ const value = radio.getAttribute("aria-checked");
410
+ if (value === null) {
411
+ this.#ownedChecked.add(radio);
412
+ this.#setChecked(radio, false);
413
+ } else if (value !== "true" && value !== "false") {
414
+ this.#setChecked(radio, false);
415
+ }
416
+ }
417
+ /** Makes the first DOM-ordered checked radio the sole checked radio. */
418
+ #normalizeSelection() {
419
+ let found = false;
420
+ for (const radio of this.#managedTargets) {
421
+ this.#normalizeChecked(radio);
422
+ if (!this.#isChecked(radio)) continue;
423
+ if (!found) found = true;
424
+ else this.#setChecked(radio, false);
425
+ }
426
+ }
427
+ /** Keeps one navigable Tab stop, preferring checked state when selection changed externally. */
428
+ #ensureTabStop(preferChecked) {
429
+ const navigable = this.#navigableRadios;
430
+ let active;
431
+ if (!preferChecked) {
432
+ const current = this.#managedTargets.filter(
433
+ (radio) => radio.tabIndex === 0 && this.#isNavigable(radio)
434
+ );
435
+ if (current.length === 1) active = current[0];
436
+ }
437
+ const selected = this.#selectedRadio;
438
+ if (!active && selected && this.#isNavigable(selected)) active = selected;
439
+ active ??= navigable[0];
440
+ this.#setActive(active ?? null);
125
441
  }
126
442
  /**
127
- * Mirrors a radio's `data-value` onto the hidden field, when present. Fires a
128
- * native bubbling `change` on a real value change (matching `listbox`, so
129
- * `auto-submit` and other native-`change` consumers react) — unless `silent`
130
- * (the initial connect reflection, which is not a user edit).
443
+ * The surviving navigable radio closest to a position in the order captured
444
+ * before the removal: the first one at or after it, else the last one before it.
445
+ * The saved position and this search read the same population, so a `hidden` or
446
+ * natively disabled sibling never shifts the destination past its neighbor.
131
447
  */
448
+ #nearestNavigable(index) {
449
+ const navigable = new Set(this.#navigableRadios);
450
+ for (let i = index; i < this.#lastOrder.length; i++) {
451
+ const radio = this.#lastOrder[i];
452
+ if (radio && navigable.has(radio)) return radio;
453
+ }
454
+ for (let i = index - 1; i >= 0; i--) {
455
+ const radio = this.#lastOrder[i];
456
+ if (radio && navigable.has(radio)) return radio;
457
+ }
458
+ return this.#navigableRadios[0] ?? null;
459
+ }
460
+ /** Moves through full DOM order until a navigable radio is found. */
461
+ #nextNavigable(fromIndex, delta) {
462
+ const items = this.#managedTargets;
463
+ let index = fromIndex;
464
+ for (let step = 0; step < items.length; step++) {
465
+ index = rovingMove(index, items.length, delta);
466
+ const candidate = items[index];
467
+ if (candidate && this.#isNavigable(candidate)) return candidate;
468
+ }
469
+ return void 0;
470
+ }
471
+ /** Assigns the single roving Tab stop and optionally moves DOM focus. */
472
+ #setActive(radio, focus = false) {
473
+ const items = this.#managedTargets;
474
+ const index = items.indexOf(radio);
475
+ this.#roving.setActive(index, { focus });
476
+ for (const item of items) {
477
+ this.#markInternal(this.#internalTabindexValues, item, item.getAttribute("tabindex"));
478
+ }
479
+ }
480
+ /** Finds this group's radio containing an event target, excluding nested groups. */
481
+ #radioForEventTarget(target) {
482
+ if (!this.#ownsEventTarget(target)) return void 0;
483
+ const node = target;
484
+ return this.radioTargets.find((radio) => radio === node || radio.contains(node));
485
+ }
486
+ /** Whether the closest Radio Group scope around a target is this instance. */
487
+ #ownsEventTarget(target) {
488
+ return target instanceof Element && target.closest('[data-controller~="stimeo--radio-group"]') === this.element;
489
+ }
490
+ /** Hosts whose activation model can be owned without conflicting native behavior. */
491
+ #isSupportedHost(radio) {
492
+ if (radio instanceof HTMLButtonElement) return radio.type === "button";
493
+ return !isInteractiveHost(radio);
494
+ }
495
+ /** Radios eligible for roving focus; ARIA-disabled deliberately remains eligible. */
496
+ #isNavigable(radio) {
497
+ if (!this.#isSupportedHost(radio) || this.#isHidden(radio)) return false;
498
+ if (!(radio instanceof HTMLButtonElement)) return true;
499
+ return !radio.disabled && !inheritsFieldsetDisabled(radio);
500
+ }
501
+ /** Whether hidden applies on the path from a radio up to the group root. */
502
+ #isHidden(radio) {
503
+ let current = radio;
504
+ while (current && current !== this.element) {
505
+ if (current.hasAttribute("hidden")) return true;
506
+ current = current.parentElement;
507
+ }
508
+ return false;
509
+ }
510
+ /** Whether ARIA, visibility, or native HTML semantics suppress selection. */
511
+ #isActivationDisabled(radio) {
512
+ if (!this.#isNavigable(radio)) return true;
513
+ let current = radio;
514
+ while (current) {
515
+ if (current.getAttribute("aria-disabled") === "true") return true;
516
+ current = current.parentElement;
517
+ }
518
+ return false;
519
+ }
520
+ /** Distinguishes external retained-element changes from this controller's own writes. */
521
+ #handleMutationRecords(records) {
522
+ let external = false;
523
+ for (const record of records) {
524
+ const target = record.target;
525
+ const attribute = record.attributeName;
526
+ if (attribute === "aria-checked") {
527
+ const value = target.getAttribute(attribute);
528
+ if (this.#matchesInternal(this.#internalCheckedValues, target, value)) continue;
529
+ this.#ownedChecked.delete(target);
530
+ this.#preferChecked = true;
531
+ external = true;
532
+ } else if (attribute === "tabindex") {
533
+ const value = target.getAttribute(attribute);
534
+ if (this.#matchesInternal(this.#internalTabindexValues, target, value)) continue;
535
+ if (this.#managedRadios.has(target)) this.#originalTabindex.set(target, value);
536
+ external = true;
537
+ } else {
538
+ external = true;
539
+ }
540
+ }
541
+ this.#internalCheckedValues.clear();
542
+ this.#internalTabindexValues.clear();
543
+ if (external) this.#reconcile.schedule();
544
+ }
545
+ /** Records an attribute write this pass made, keeping one claim per record it will produce. */
546
+ #markInternal(values, target, value) {
547
+ const pending = values.get(target);
548
+ if (pending) {
549
+ pending.value = value;
550
+ pending.count += 1;
551
+ } else {
552
+ values.set(target, { value, count: 1 });
553
+ }
554
+ }
555
+ /** Consumes one claim on a record whose value this pass wrote. */
556
+ #matchesInternal(values, target, value) {
557
+ const pending = values.get(target);
558
+ if (!pending || pending.value !== value) return false;
559
+ pending.count -= 1;
560
+ if (pending.count === 0) values.delete(target);
561
+ return true;
562
+ }
563
+ /** Watches target membership, retained state/hosts, form state, and fieldset ancestry. */
564
+ #observeMutations() {
565
+ const observer = new MutationObserver((records) => this.#handleMutationRecords(records));
566
+ this.#observer = observer;
567
+ this.#observeWith(observer);
568
+ }
569
+ /** Registers root and ancestor observation on one observer instance. */
570
+ #observeWith(observer) {
571
+ observer.observe(this.element, {
572
+ subtree: true,
573
+ childList: true,
574
+ attributes: true,
575
+ attributeFilter: OBSERVED_ATTRIBUTES
576
+ });
577
+ let ancestor = this.element.parentElement;
578
+ while (ancestor) {
579
+ observer.observe(ancestor, {
580
+ attributes: true,
581
+ attributeFilter: ["contenteditable", "disabled"]
582
+ });
583
+ ancestor = ancestor.parentElement;
584
+ }
585
+ }
586
+ /** Supported targets currently managed by the roving primitive. */
587
+ get #managedTargets() {
588
+ return this.radioTargets.filter((radio) => this.#managedRadios.has(radio));
589
+ }
590
+ /** Managed radios eligible for the roving Tab stop. */
591
+ get #navigableRadios() {
592
+ return this.#managedTargets.filter((radio) => this.#isNavigable(radio));
593
+ }
594
+ /** The currently checked managed radio, if any. */
595
+ get #selectedRadio() {
596
+ return this.#managedTargets.find((radio) => this.#isChecked(radio));
597
+ }
598
+ /** Whether a radio is currently checked. */
599
+ #isChecked(radio) {
600
+ return radio.getAttribute("aria-checked") === "true";
601
+ }
602
+ /** Reflects checked state while distinguishing controller writes from authored morphs. */
603
+ #setChecked(radio, checked) {
604
+ const value = checked ? "true" : "false";
605
+ if (radio.getAttribute("aria-checked") === value) return;
606
+ this.#markInternal(this.#internalCheckedValues, radio, value);
607
+ radio.setAttribute("aria-checked", value);
608
+ }
609
+ /** Writes a roving value before the radio is visible to the shared primitive. */
610
+ #writeTabindex(radio, value) {
611
+ const serialized = String(value);
612
+ this.#markInternal(this.#internalTabindexValues, radio, serialized);
613
+ radio.tabIndex = value;
614
+ }
615
+ /** Mirrors the selected value or the empty state to the optional hidden field. */
132
616
  #reflectField(radio, { silent = false } = {}) {
133
- if (!this.hasFieldTarget || !radio) return;
134
- const value = this.#radioValue(radio);
617
+ if (!this.hasFieldTarget) return;
618
+ const value = radio ? this.#radioValue(radio) : "";
135
619
  if (this.fieldTarget.value === value) return;
136
620
  this.fieldTarget.value = value;
137
621
  if (!silent) this.fieldTarget.dispatchEvent(new Event("change", { bubbles: true }));