stimeo-ui 0.16.0 → 0.17.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +8 -1
  4. data/dist/cable/index.js +363 -436
  5. data/dist/controllers/accordion_controller.js +166 -55
  6. data/dist/controllers/alert_dialog_controller.js +862 -221
  7. data/dist/controllers/announcer_controller.js +135 -198
  8. data/dist/controllers/aspect_ratio_controller.js +0 -10
  9. data/dist/controllers/auto_submit_controller.js +271 -127
  10. data/dist/controllers/avatar_controller.js +171 -105
  11. data/dist/controllers/breadcrumb_controller.js +0 -130
  12. data/dist/controllers/bulk_select_controller.js +262 -52
  13. data/dist/controllers/calendar_controller.js +175 -209
  14. data/dist/controllers/carousel_controller.js +461 -399
  15. data/dist/controllers/character_counter_controller.js +246 -156
  16. data/dist/controllers/checkbox_controller.js +32 -74
  17. data/dist/controllers/clipboard_controller.js +168 -147
  18. data/dist/controllers/collapsible_controller.js +301 -120
  19. data/dist/controllers/color_picker_controller.js +149 -139
  20. data/dist/controllers/combobox_controller.js +360 -133
  21. data/dist/controllers/command_palette_controller.js +970 -358
  22. data/dist/controllers/conditional_fields_controller.js +166 -124
  23. data/dist/controllers/confirm_controller.js +939 -248
  24. data/dist/controllers/context_menu_controller.js +256 -128
  25. data/dist/controllers/count_up_controller.js +73 -21
  26. data/dist/controllers/countdown_controller.js +183 -148
  27. data/dist/controllers/currency_input_controller.js +134 -158
  28. data/dist/controllers/data_grid_controller.js +114 -186
  29. data/dist/controllers/date_range_picker_controller.js +288 -206
  30. data/dist/controllers/dialog_controller.js +858 -223
  31. data/dist/controllers/direct_upload_controller.js +73 -153
  32. data/dist/controllers/dirty_form_controller.js +14 -48
  33. data/dist/controllers/dismissible_controller.js +175 -21
  34. data/dist/controllers/drawer_controller.js +933 -341
  35. data/dist/controllers/dropdown_controller.js +312 -82
  36. data/dist/controllers/editable_controller.js +158 -68
  37. data/dist/controllers/empty_state_controller.js +67 -53
  38. data/dist/controllers/file_dropzone_controller.js +311 -207
  39. data/dist/controllers/filter_controller.js +67 -39
  40. data/dist/controllers/flash_controller.js +172 -314
  41. data/dist/controllers/focus_controller.js +654 -240
  42. data/dist/controllers/form_field_controller.js +147 -132
  43. data/dist/controllers/form_validation_controller.js +13 -86
  44. data/dist/controllers/frame_loading_controller.js +264 -213
  45. data/dist/controllers/highlight_controller.js +107 -75
  46. data/dist/controllers/hover_card_controller.js +309 -118
  47. data/dist/controllers/idle_controller.js +267 -101
  48. data/dist/controllers/input_mask_controller.js +71 -72
  49. data/dist/controllers/intersection_controller.js +147 -106
  50. data/dist/controllers/lazy_frame_controller.js +9 -65
  51. data/dist/controllers/listbox_controller.js +306 -204
  52. data/dist/controllers/local_time_controller.js +54 -64
  53. data/dist/controllers/masonry_controller.js +70 -93
  54. data/dist/controllers/menu_controller.js +214 -156
  55. data/dist/controllers/menubar_controller.js +170 -329
  56. data/dist/controllers/meter_controller.js +151 -67
  57. data/dist/controllers/multi_select_controller.js +593 -358
  58. data/dist/controllers/navigation_menu_controller.js +124 -232
  59. data/dist/controllers/nested_form_controller.js +352 -182
  60. data/dist/controllers/network_status_controller.js +242 -54
  61. data/dist/controllers/number_input_controller.js +320 -263
  62. data/dist/controllers/optimistic_controller.js +16 -76
  63. data/dist/controllers/otp_controller.js +198 -193
  64. data/dist/controllers/overflow_indicator_controller.js +116 -113
  65. data/dist/controllers/overflow_menu_controller.js +343 -283
  66. data/dist/controllers/pagination_controller.js +295 -119
  67. data/dist/controllers/password_reveal_controller.js +389 -121
  68. data/dist/controllers/password_strength_controller.js +99 -153
  69. data/dist/controllers/persist_controller.js +237 -103
  70. data/dist/controllers/pointer_drag_controller.js +185 -165
  71. data/dist/controllers/popover_controller.js +236 -97
  72. data/dist/controllers/portal_controller.js +209 -76
  73. data/dist/controllers/preview_guard_controller.js +232 -116
  74. data/dist/controllers/progress_controller.js +125 -56
  75. data/dist/controllers/radio_group_controller.js +117 -137
  76. data/dist/controllers/range_slider_controller.js +192 -105
  77. data/dist/controllers/rating_controller.js +418 -207
  78. data/dist/controllers/read_more_controller.js +134 -63
  79. data/dist/controllers/reading_progress_controller.js +147 -135
  80. data/dist/controllers/relative_time_controller.js +145 -110
  81. data/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +97 -38
  82. data/dist/controllers/resizable_controller.js +255 -139
  83. data/dist/controllers/roving_controller.js +39 -84
  84. data/dist/controllers/scroll_area_controller.js +428 -225
  85. data/dist/controllers/scroll_restore_controller.js +0 -53
  86. data/dist/controllers/scroll_visibility_controller.js +395 -145
  87. data/dist/controllers/scrollspy_controller.js +181 -242
  88. data/dist/controllers/separator_controller.js +267 -140
  89. data/dist/controllers/sidebar_controller.js +1023 -386
  90. data/dist/controllers/skeleton_controller.js +248 -97
  91. data/dist/controllers/slider_controller.js +159 -79
  92. data/dist/controllers/smart_sticky_header_controller.js +162 -63
  93. data/dist/controllers/sortable_controller.js +0 -112
  94. data/dist/controllers/spinner_controller.js +277 -169
  95. data/dist/controllers/step_indicator_controller.js +99 -46
  96. data/dist/controllers/stepper_controller.js +114 -73
  97. data/dist/controllers/stick_to_bottom_controller.js +262 -138
  98. data/dist/controllers/sticky_observer_controller.js +214 -49
  99. data/dist/controllers/submit_once_controller.js +294 -205
  100. data/dist/controllers/switch_controller.js +33 -60
  101. data/dist/controllers/tabs_controller.js +35 -29
  102. data/dist/controllers/tags_input_controller.js +136 -145
  103. data/dist/controllers/textarea_autosize_controller.js +71 -58
  104. data/dist/controllers/theme_controller.js +130 -112
  105. data/dist/controllers/time_picker_controller.js +146 -80
  106. data/dist/controllers/toast_controller.js +186 -297
  107. data/dist/controllers/toggle_group_controller.js +95 -136
  108. data/dist/controllers/toolbar_controller.js +68 -98
  109. data/dist/controllers/tooltip_controller.js +158 -124
  110. data/dist/controllers/transition_controller.js +123 -119
  111. data/dist/controllers/tree_view_controller.js +150 -345
  112. data/dist/index.js +6370 -9346
  113. data/dist/positioning/index.js +148 -47
  114. data/lib/stimeo/ui/version.rb +1 -1
  115. metadata +3 -3
@@ -1,13 +1,31 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/radio_group_controller.ts
3
+ var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
4
+ var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
5
+ function stateReasonFor(event) {
6
+ if (!event) return "api";
7
+ if (FOCUS_EVENTS.has(event.type)) return "focus";
8
+ if (POINTER_EVENTS.has(event.type)) return "pointer";
9
+ return "user";
10
+ }
11
+
12
+ function actionSource(source) {
13
+ if (source instanceof HTMLElement) {
14
+ return { event: null, host: source, origin: source, reason: "api" };
15
+ }
16
+ const event = source ?? null;
17
+ return {
18
+ event,
19
+ host: event?.currentTarget instanceof Element ? event.currentTarget : null,
20
+ origin: event?.target instanceof Element ? event.target : null,
21
+ reason: stateReasonFor(event)
22
+ };
23
+ }
4
24
 
5
- // src/utils/logical_scroll.ts
6
25
  function isRtl(element) {
7
26
  return window.getComputedStyle(element).direction === "rtl";
8
27
  }
9
28
 
10
- // src/utils/arrow_step.ts
11
29
  function logicalArrowStep(key, element) {
12
30
  if (key === "ArrowDown") return 1;
13
31
  if (key === "ArrowUp") return -1;
@@ -20,7 +38,6 @@ function isReservedArrowChord(event, allow = []) {
20
38
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
21
39
  }
22
40
 
23
- // src/utils/event_owner.ts
24
41
  function ownerIndex(candidates, node) {
25
42
  if (!(node instanceof Node)) return -1;
26
43
  return candidates.findIndex((candidate) => candidate.contains(node));
@@ -29,7 +46,6 @@ function ownerOf(candidates, node) {
29
46
  return candidates[ownerIndex(candidates, node)] ?? null;
30
47
  }
31
48
 
32
- // src/utils/field_mirror.ts
33
49
  function writeField(field, value) {
34
50
  if (field.value === value) return false;
35
51
  field.value = value;
@@ -39,7 +55,6 @@ function commitField(target) {
39
55
  target.dispatchEvent(new Event("change", { bubbles: true }));
40
56
  }
41
57
 
42
- // src/utils/focus_candidate.ts
43
58
  function inheritsFieldsetDisabled(control) {
44
59
  let fieldset = control.closest("fieldset[disabled]");
45
60
  while (fieldset) {
@@ -50,7 +65,6 @@ function inheritsFieldsetDisabled(control) {
50
65
  return false;
51
66
  }
52
67
 
53
- // src/utils/interactive_host.ts
54
68
  var INTERACTIVE_HOST_SELECTOR = "button, input, select, textarea, label, a[href], area[href], summary, details, audio[controls], video[controls], iframe, object, embed";
55
69
  function isInteractiveHost(element) {
56
70
  if (element.matches(INTERACTIVE_HOST_SELECTOR)) return true;
@@ -67,27 +81,33 @@ function isInteractiveHost(element) {
67
81
  return false;
68
82
  }
69
83
 
70
- // src/utils/microtask_coalescer.ts
84
+ var ListenerSet = class {
85
+ #abort = new AbortController();
86
+ add(target, type, handler, options) {
87
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
88
+ }
89
+ dispose() {
90
+ this.#abort.abort();
91
+ this.#abort = new AbortController();
92
+ }
93
+ };
94
+
71
95
  var MicrotaskCoalescer = class {
72
96
  #run;
73
97
  #queued = false;
74
98
  #active = false;
75
99
  #generation = 0;
76
- /** @param run - the single reconciliation pass, invoked at most once per batch. */
77
100
  constructor(run) {
78
101
  this.#run = run;
79
102
  }
80
- /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
81
103
  activate() {
82
104
  this.#active = true;
83
105
  }
84
- /** Closes the window and drops any pending pass; call from `disconnect()`. */
85
106
  cancel() {
86
107
  this.#active = false;
87
108
  this.#queued = false;
88
109
  this.#generation += 1;
89
110
  }
90
- /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
91
111
  schedule() {
92
112
  if (!this.#active || this.#queued) return;
93
113
  this.#queued = true;
@@ -100,31 +120,50 @@ var MicrotaskCoalescer = class {
100
120
  }
101
121
  };
102
122
 
103
- // src/utils/roving_tabindex.ts
123
+ var MorphRenderWatcher = class {
124
+ #listeners = new ListenerSet();
125
+ #pass;
126
+ #element = null;
127
+ #onMorph = () => this.#pass.schedule();
128
+ constructor(run) {
129
+ this.#pass = new MicrotaskCoalescer(run);
130
+ }
131
+ observe(element) {
132
+ if (this.#element === element) return;
133
+ this.disconnect();
134
+ this.#element = element;
135
+ this.#pass.activate();
136
+ this.#listeners.add(element, "turbo:morph-element", this.#onMorph);
137
+ }
138
+ schedule() {
139
+ this.#pass.schedule();
140
+ }
141
+ disconnect() {
142
+ this.#listeners.dispose();
143
+ this.#pass.cancel();
144
+ this.#element = null;
145
+ }
146
+ };
147
+
148
+ var MoveCounter = class {
149
+ #sequence = 0;
150
+ record() {
151
+ this.#sequence += 1;
152
+ return this.#sequence;
153
+ }
154
+ isLatest(token) {
155
+ return token === this.#sequence;
156
+ }
157
+ };
158
+
104
159
  var RovingTabindex = class {
105
- /** Returns the current ordered item elements; called on every operation. */
106
160
  #getItems;
107
- /**
108
- * @param getItems - Returns the current ordered item elements. Called on every
109
- * operation so the live target list is always used.
110
- */
111
161
  constructor(getItems) {
112
162
  this.#getItems = getItems;
113
163
  }
114
- /** Index of the currently tabbable item (`tabindex="0"`), or `-1` if none. */
115
164
  get activeIndex() {
116
165
  return this.#getItems().findIndex((item) => item.tabIndex === 0);
117
166
  }
118
- /**
119
- * Makes exactly the item at `index` tabbable (`tabindex="0"`) and removes every
120
- * other item from the Tab sequence (`tabindex="-1"`). An out-of-range `index`
121
- * (e.g. `-1`) leaves all items at `-1`, which a controller can use to express
122
- * "nothing is currently tabbable".
123
- *
124
- * @param index - Position of the item to make tabbable.
125
- * @param options - Pass `{ focus: true }` to also move DOM focus to that item,
126
- * and `items` to reuse an event-scoped collection snapshot.
127
- */
128
167
  setActive(index, options = {}) {
129
168
  const { focus = false } = options;
130
169
  const items = options.items ?? this.#getItems();
@@ -140,7 +179,10 @@ function rovingMove(current, length, delta, wrap) {
140
179
  return (next + length) % length;
141
180
  }
142
181
 
143
- // src/controllers/radio_group_controller.ts
182
+ function targetSelector(identifier, name) {
183
+ return `[data-${identifier}-target~="${name}"]`;
184
+ }
185
+
144
186
  var NO_SELECTION = { radio: null, value: "" };
145
187
  var OBSERVED_ATTRIBUTES = [
146
188
  "aria-checked",
@@ -157,11 +199,13 @@ var OBSERVED_ATTRIBUTES = [
157
199
  ];
158
200
  var hasModifier = (event) => event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
159
201
  var RadioGroupController = class extends Controller {
202
+ #moves = new MoveCounter();
203
+ #moveToken = 0;
160
204
  static targets = ["radio", "field"];
161
205
  static actions = ["onKeydown", "select"];
162
206
  static events = ["change", "reconcile"];
163
207
  #roving = new RovingTabindex(() => this.#managedTargets);
164
- #reconcile = new MicrotaskCoalescer(() => this.#reconcileDom());
208
+ #reconcile = new MorphRenderWatcher(() => this.#reconcileDom());
165
209
  #handledEvents = /* @__PURE__ */ new WeakSet();
166
210
  #managedRadios = /* @__PURE__ */ new Set();
167
211
  #originalTabindex = /* @__PURE__ */ new Map();
@@ -169,17 +213,12 @@ var RadioGroupController = class extends Controller {
169
213
  #internalCheckedValues = /* @__PURE__ */ new Map();
170
214
  #internalTabindexValues = /* @__PURE__ */ new Map();
171
215
  #observer = null;
172
- /** The selection last published: read on connect, then committed or reported. */
173
216
  #committed = NO_SELECTION;
174
217
  #connected = false;
175
218
  #lastOrder = [];
176
219
  #focusedRadio = null;
177
220
  #pendingFocusIndex = null;
178
221
  #preferChecked = false;
179
- /**
180
- * Normalizes authored selection, establishes the APG Tab entry point, and
181
- * reflects derived form state without reporting a user edit.
182
- */
183
222
  connect() {
184
223
  const currentRadios = new Set(this.radioTargets);
185
224
  for (const radio of this.#managedRadios) {
@@ -188,7 +227,7 @@ var RadioGroupController = class extends Controller {
188
227
  for (const radio of this.radioTargets) this.#reconcileHost(radio, false);
189
228
  this.#normalizeSelection();
190
229
  this.#ensureTabStop(true);
191
- this.#reflectField(this.#selectedRadio, { silent: true });
230
+ this.#reflectField(this.#selectedRadio);
192
231
  this.#committed = this.#settled;
193
232
  this.#lastOrder = this.#managedTargets;
194
233
  this.element.addEventListener("click", this.#onClickCapture, true);
@@ -201,13 +240,12 @@ var RadioGroupController = class extends Controller {
201
240
  this.#preferChecked = false;
202
241
  this.#internalCheckedValues.clear();
203
242
  this.#internalTabindexValues.clear();
204
- this.#reconcile.activate();
243
+ this.#reconcile.observe(this.element);
205
244
  this.#observeMutations();
206
245
  }
207
- /** Releases every listener, observer, and queued pass while retaining live DOM state. */
208
246
  disconnect() {
209
247
  this.#connected = false;
210
- this.#reconcile.cancel();
248
+ this.#reconcile.disconnect();
211
249
  this.element.removeEventListener("click", this.#onClickCapture, true);
212
250
  this.element.removeEventListener("keydown", this.#onKeydownCapture, true);
213
251
  this.element.removeEventListener("click", this.#onClick);
@@ -216,21 +254,17 @@ var RadioGroupController = class extends Controller {
216
254
  this.element.removeEventListener("focusout", this.#onFocusout);
217
255
  this.#observer?.disconnect();
218
256
  this.#observer = null;
219
- this.#internalCheckedValues.clear();
220
- this.#internalTabindexValues.clear();
221
257
  this.#focusedRadio = null;
222
258
  this.#pendingFocusIndex = null;
223
259
  this.#committed = NO_SELECTION;
224
260
  this.#preferChecked = false;
225
261
  }
226
- /** Removes a newly connected radio from the Tab sequence before batch reconciliation. */
227
262
  radioTargetConnected(radio) {
228
263
  if (this.#connected === false) return;
229
264
  this.#reconcileHost(radio, true);
230
265
  this.#preferChecked = true;
231
266
  this.#reconcile.schedule();
232
267
  }
233
- /** Releases target-only attributes and repairs selection, form state, focus, and roving. */
234
268
  radioTargetDisconnected(radio) {
235
269
  if (!this.#connected) return;
236
270
  if (this.#focusedRadio === radio) {
@@ -241,36 +275,33 @@ var RadioGroupController = class extends Controller {
241
275
  this.#preferChecked = true;
242
276
  this.#reconcile.schedule();
243
277
  }
244
- /** Reflects the current selection into a field added or replaced at runtime. */
245
278
  fieldTargetConnected() {
246
279
  this.#reconcile.schedule();
247
280
  }
248
- /** Reconciles after a field target is removed from a retained group. */
249
281
  fieldTargetDisconnected() {
250
282
  this.#reconcile.schedule();
251
283
  }
252
- /** Selects the action's radio. Per-radio action wiring is optional. */
253
- select(event) {
254
- if (event.defaultPrevented || !this.#ownsEventTarget(event.target)) return;
255
- const radio = event.currentTarget;
284
+ select(source) {
285
+ const { event, host, origin, reason } = actionSource(source);
286
+ const radio = host?.closest(targetSelector(this.identifier, "radio"));
256
287
  if (!radio || !this.radioTargets.includes(radio)) return;
257
- this.#handledEvents.add(event);
258
- this.#selectRadio(radio, { focus: false });
288
+ if (origin && origin.closest(`[data-controller~="${this.identifier}"]`) !== this.element)
289
+ return;
290
+ if (event?.defaultPrevented || event && !this.#ownsEventTarget(origin)) return;
291
+ if (event) this.#handledEvents.add(event);
292
+ this.#selectRadio(radio, { focus: false }, reason);
259
293
  }
260
- /** Applies the APG key map to the action's radio. Per-radio action wiring is optional. */
261
294
  onKeydown(event) {
262
295
  const radio = event.currentTarget;
263
296
  if (!radio || !this.radioTargets.includes(radio)) return;
264
297
  this.#handleKeydown(event, radio);
265
298
  }
266
- /** Blocks disabled pointer activation before per-radio and consumer handlers run. */
267
299
  #onClickCapture = (event) => {
268
300
  const radio = this.#radioForEventTarget(event.target);
269
301
  if (!radio || !this.#isSupportedHost(radio) || !this.#isActivationDisabled(radio)) return;
270
302
  event.preventDefault();
271
303
  event.stopImmediatePropagation();
272
304
  };
273
- /** Blocks disabled activation and suppresses a button's non-APG Enter activation. */
274
305
  #onKeydownCapture = (event) => {
275
306
  if (event.key !== " " && event.key !== "Enter") return;
276
307
  const radio = this.#radioForEventTarget(event.target);
@@ -280,25 +311,21 @@ var RadioGroupController = class extends Controller {
280
311
  event.stopImmediatePropagation();
281
312
  }
282
313
  };
283
- /** Delegated click path for static and runtime-added radios without actions. */
284
314
  #onClick = (event) => {
285
315
  if (this.#handledEvents.delete(event) || event.defaultPrevented) return;
286
316
  const radio = this.#radioForEventTarget(event.target);
287
317
  if (radio) this.#selectRadio(radio, { focus: false });
288
318
  };
289
- /** Delegated keydown path for static and runtime-added radios without actions. */
290
319
  #onKeydown = (event) => {
291
320
  const radio = this.#radioForEventTarget(event.target);
292
321
  if (radio) this.#handleKeydown(event, radio);
293
322
  };
294
- /** Keeps programmatic and pointer focus as the group's current roving position. */
295
323
  #onFocusin = (event) => {
296
324
  const radio = this.#radioForEventTarget(event.target);
297
325
  if (!radio || !this.#isNavigable(radio)) return;
298
326
  this.#focusedRadio = radio;
299
327
  this.#setActive(radio);
300
328
  };
301
- /** Distinguishes ordinary focus departure from focus lost because its radio was removed. */
302
329
  #onFocusout = (event) => {
303
330
  const radio = this.#radioForEventTarget(event.target);
304
331
  if (!radio) return;
@@ -308,7 +335,6 @@ var RadioGroupController = class extends Controller {
308
335
  if (!(active instanceof Node) || !radio.contains(active)) this.#focusedRadio = null;
309
336
  });
310
337
  };
311
- /** Applies navigation and Space activation to one supported radio. */
312
338
  #handleKeydown(event, radio) {
313
339
  if (event.defaultPrevented || !this.#isSupportedHost(radio)) return;
314
340
  if (event.isComposing || isReservedArrowChord(event)) return;
@@ -346,65 +372,54 @@ var RadioGroupController = class extends Controller {
346
372
  if (this.#isActivationDisabled(destination)) this.#setActive(destination, true);
347
373
  else this.#selectRadio(destination, { focus: true });
348
374
  }
349
- /**
350
- * Applies one user selection and emits only when selected identity changes. A
351
- * new selection is taken as published before either report goes out, so a
352
- * listener that selects again is measured from it. Activating the radio that is
353
- * already checked publishes nothing: if the page checked it, the next pass
354
- * reports that move.
355
- */
356
- #selectRadio(radio, { focus }) {
375
+ #selectRadio(radio, { focus }, reason = "user") {
357
376
  if (!this.#isSupportedHost(radio) || this.#isActivationDisabled(radio)) return;
358
- const previous = this.#selectedRadio;
359
- const changed = previous !== radio;
360
- if (changed) {
361
- for (const item of this.#managedTargets) this.#setChecked(item, item === radio);
362
- this.#committed = { radio, value: this.#radioValue(radio) };
363
- }
364
- this.#setActive(radio, focus);
365
- this.#reflectField(radio, { silent: !changed });
366
- if (changed) {
367
- this.dispatch("change", { detail: { value: this.#radioValue(radio), radio } });
377
+ const value = this.#radioValue(radio);
378
+ const changed = this.#committed.radio !== radio || this.#committed.value !== value;
379
+ for (const item of this.#managedTargets) this.#setChecked(item, item === radio);
380
+ this.#committed = { radio, value };
381
+ if (changed) this.#moveToken = this.#moves.record();
382
+ const token = this.#moveToken;
383
+ this.#setActive(radio);
384
+ const fieldChanged = this.hasFieldTarget && writeField(this.fieldTarget, value);
385
+ if (focus) radio.focus();
386
+ if (!this.#connected || !this.#moves.isLatest(token)) return;
387
+ if (changed && fieldChanged) commitField(this.fieldTarget);
388
+ if (changed && this.#moves.isLatest(token)) {
389
+ this.dispatch("change", { detail: { value, radio, reason } });
368
390
  }
369
391
  }
370
- /** Reconciles target ownership, selection, roving, focus continuity, and form state. */
371
392
  #reconcileDom() {
372
393
  const observer = this.#observer;
373
- observer?.disconnect();
394
+ this.#handleMutationRecords(observer?.takeRecords() ?? []);
374
395
  for (const radio of this.radioTargets) this.#reconcileHost(radio, true);
375
396
  this.#normalizeSelection();
376
397
  const pendingFocusIndex = this.#pendingFocusIndex;
377
398
  this.#pendingFocusIndex = null;
399
+ let destination = null;
378
400
  if (pendingFocusIndex !== null) {
379
- const destination = this.#nearestNavigable(pendingFocusIndex);
380
- this.#setActive(destination, destination !== null);
401
+ destination = this.#nearestNavigable(pendingFocusIndex);
402
+ this.#setActive(destination);
381
403
  } else {
382
404
  this.#ensureTabStop(this.#preferChecked);
383
405
  }
384
- this.#reflectField(this.#selectedRadio, { silent: true });
406
+ this.#reflectField(this.#selectedRadio);
385
407
  this.#lastOrder = this.#managedTargets;
386
408
  this.#preferChecked = false;
409
+ observer?.takeRecords();
387
410
  this.#internalCheckedValues.clear();
388
411
  this.#internalTabindexValues.clear();
389
- if (this.#connected && observer) this.#observeWith(observer);
390
- this.#reportReconciledSelection();
391
- }
392
- /**
393
- * Announces a selection that moved since the one this group last published —
394
- * another radio, or the same radio submitting another value. Dispatched after
395
- * observation resumes so a consumer's own DOM edits are seen by the next pass,
396
- * and only while connected: moving focus inside the pass runs listeners that
397
- * can disconnect the group.
398
- */
399
- #reportReconciledSelection() {
400
- if (!this.#connected) return;
401
412
  const settled = this.#settled;
402
413
  const committed = this.#committed;
403
- if (settled.radio === committed.radio && settled.value === committed.value) return;
414
+ const changed = settled.radio !== committed.radio || settled.value !== committed.value;
404
415
  this.#committed = settled;
405
- this.dispatch("reconcile", { detail: { value: settled.value, radio: settled.radio } });
416
+ const token = this.#moveToken;
417
+ destination?.focus();
418
+ if (!this.#connected || !this.#moves.isLatest(token)) return;
419
+ if (changed) {
420
+ this.dispatch("reconcile", { detail: { value: settled.value, radio: settled.radio } });
421
+ }
406
422
  }
407
- /** Begins or ends ownership according to a radio's current host semantics. */
408
423
  #reconcileHost(radio, dropFromTabSequence) {
409
424
  if (!this.#isSupportedHost(radio)) {
410
425
  this.#releaseRadio(radio);
@@ -417,7 +432,6 @@ var RadioGroupController = class extends Controller {
417
432
  }
418
433
  this.#normalizeChecked(radio);
419
434
  }
420
- /** Restores only defaults owned while an element belonged to this group. */
421
435
  #releaseRadio(radio) {
422
436
  this.#managedRadios.delete(radio);
423
437
  const original = this.#originalTabindex.get(radio);
@@ -434,7 +448,6 @@ var RadioGroupController = class extends Controller {
434
448
  radio.removeAttribute("aria-checked");
435
449
  }
436
450
  }
437
- /** Supplies a missing checked state and normalizes invalid ARIA tokens. */
438
451
  #normalizeChecked(radio) {
439
452
  const value = radio.getAttribute("aria-checked");
440
453
  if (value === null) {
@@ -444,17 +457,14 @@ var RadioGroupController = class extends Controller {
444
457
  this.#setChecked(radio, false);
445
458
  }
446
459
  }
447
- /** Makes the first DOM-ordered checked radio the sole checked radio. */
448
460
  #normalizeSelection() {
449
461
  let found = false;
450
462
  for (const radio of this.#managedTargets) {
451
- this.#normalizeChecked(radio);
452
463
  if (!this.#isChecked(radio)) continue;
453
464
  if (!found) found = true;
454
465
  else this.#setChecked(radio, false);
455
466
  }
456
467
  }
457
- /** Keeps one navigable Tab stop, preferring checked state when selection changed externally. */
458
468
  #ensureTabStop(preferChecked) {
459
469
  const navigable = this.#navigableRadios;
460
470
  let active;
@@ -469,12 +479,6 @@ var RadioGroupController = class extends Controller {
469
479
  active ??= navigable[0];
470
480
  this.#setActive(active ?? null);
471
481
  }
472
- /**
473
- * The surviving navigable radio closest to a position in the order captured
474
- * before the removal: the first one at or after it, else the last one before it.
475
- * The saved position and this search read the same population, so a `hidden` or
476
- * natively disabled sibling never shifts the destination past its neighbor.
477
- */
478
482
  #nearestNavigable(index) {
479
483
  const navigable = new Set(this.#navigableRadios);
480
484
  for (let i = index; i < this.#lastOrder.length; i++) {
@@ -487,7 +491,6 @@ var RadioGroupController = class extends Controller {
487
491
  }
488
492
  return this.#navigableRadios[0] ?? null;
489
493
  }
490
- /** Moves through full DOM order until a navigable radio is found. */
491
494
  #nextNavigable(fromIndex, delta) {
492
495
  const items = this.#managedTargets;
493
496
  let index = fromIndex;
@@ -498,36 +501,31 @@ var RadioGroupController = class extends Controller {
498
501
  }
499
502
  return void 0;
500
503
  }
501
- /** Assigns the single roving Tab stop and optionally moves DOM focus. */
502
504
  #setActive(radio, focus = false) {
503
505
  const items = this.#managedTargets;
504
506
  const index = items.indexOf(radio);
505
- this.#roving.setActive(index, { focus });
507
+ this.#roving.setActive(index);
506
508
  for (const item of items) {
507
509
  this.#markInternal(this.#internalTabindexValues, item, item.getAttribute("tabindex"));
508
510
  }
511
+ if (focus) radio?.focus();
509
512
  }
510
- /** Finds this group's radio containing an event target, excluding nested groups. */
511
513
  #radioForEventTarget(target) {
512
514
  if (!this.#ownsEventTarget(target)) return null;
513
515
  return ownerOf(this.radioTargets, target);
514
516
  }
515
- /** Whether the closest Radio Group scope around a target is this instance. */
516
517
  #ownsEventTarget(target) {
517
518
  return target instanceof Element && target.closest(`[data-controller~="${this.identifier}"]`) === this.element;
518
519
  }
519
- /** Hosts whose activation model can be owned without conflicting native behavior. */
520
520
  #isSupportedHost(radio) {
521
521
  if (radio instanceof HTMLButtonElement) return radio.type === "button";
522
522
  return !isInteractiveHost(radio);
523
523
  }
524
- /** Radios eligible for roving focus; ARIA-disabled deliberately remains eligible. */
525
524
  #isNavigable(radio) {
526
525
  if (!this.#isSupportedHost(radio) || this.#isHidden(radio)) return false;
527
526
  if (!(radio instanceof HTMLButtonElement)) return true;
528
527
  return !radio.disabled && !inheritsFieldsetDisabled(radio);
529
528
  }
530
- /** Whether hidden applies on the path from a radio up to the group root. */
531
529
  #isHidden(radio) {
532
530
  let current = radio;
533
531
  while (current && current !== this.element) {
@@ -536,7 +534,6 @@ var RadioGroupController = class extends Controller {
536
534
  }
537
535
  return false;
538
536
  }
539
- /** Whether ARIA, visibility, or native HTML semantics suppress selection. */
540
537
  #isActivationDisabled(radio) {
541
538
  if (!this.#isNavigable(radio)) return true;
542
539
  let current = radio;
@@ -546,7 +543,6 @@ var RadioGroupController = class extends Controller {
546
543
  }
547
544
  return false;
548
545
  }
549
- /** Distinguishes external retained-element changes from this controller's own writes. */
550
546
  #handleMutationRecords(records) {
551
547
  let external = false;
552
548
  for (const record of records) {
@@ -567,11 +563,9 @@ var RadioGroupController = class extends Controller {
567
563
  external = true;
568
564
  }
569
565
  }
570
- this.#internalCheckedValues.clear();
571
566
  this.#internalTabindexValues.clear();
572
567
  if (external) this.#reconcile.schedule();
573
568
  }
574
- /** Records an attribute write this pass made, keeping one claim per record it will produce. */
575
569
  #markInternal(values, target, value) {
576
570
  const pending = values.get(target);
577
571
  if (pending) {
@@ -581,7 +575,6 @@ var RadioGroupController = class extends Controller {
581
575
  values.set(target, { value, count: 1 });
582
576
  }
583
577
  }
584
- /** Consumes one claim on a record whose value this pass wrote. */
585
578
  #matchesInternal(values, target, value) {
586
579
  const pending = values.get(target);
587
580
  if (!pending || pending.value !== value) return false;
@@ -589,13 +582,11 @@ var RadioGroupController = class extends Controller {
589
582
  if (pending.count === 0) values.delete(target);
590
583
  return true;
591
584
  }
592
- /** Watches target membership, retained state/hosts, form state, and fieldset ancestry. */
593
585
  #observeMutations() {
594
586
  const observer = new MutationObserver((records) => this.#handleMutationRecords(records));
595
587
  this.#observer = observer;
596
588
  this.#observeWith(observer);
597
589
  }
598
- /** Registers root and ancestor observation on one observer instance. */
599
590
  #observeWith(observer) {
600
591
  observer.observe(this.element, {
601
592
  subtree: true,
@@ -612,53 +603,42 @@ var RadioGroupController = class extends Controller {
612
603
  ancestor = ancestor.parentElement;
613
604
  }
614
605
  }
615
- /** Supported targets currently managed by the roving primitive. */
616
606
  get #managedTargets() {
617
607
  return this.radioTargets.filter((radio) => this.#managedRadios.has(radio));
618
608
  }
619
- /** Managed radios eligible for the roving Tab stop. */
620
609
  get #navigableRadios() {
621
610
  return this.#managedTargets.filter((radio) => this.#isNavigable(radio));
622
611
  }
623
- /** The currently checked managed radio, if any. */
624
612
  get #selectedRadio() {
625
613
  return this.#managedTargets.find((radio) => this.#isChecked(radio));
626
614
  }
627
- /** The current selection as the group publishes it. */
628
615
  get #settled() {
629
616
  const radio = this.#selectedRadio;
630
617
  return radio ? { radio, value: this.#radioValue(radio) } : NO_SELECTION;
631
618
  }
632
- /** Whether a radio is currently checked. */
633
619
  #isChecked(radio) {
634
620
  return radio.getAttribute("aria-checked") === "true";
635
621
  }
636
- /** Reflects checked state while distinguishing controller writes from authored morphs. */
637
622
  #setChecked(radio, checked) {
638
623
  const value = checked ? "true" : "false";
639
624
  if (radio.getAttribute("aria-checked") === value) return;
640
625
  this.#markInternal(this.#internalCheckedValues, radio, value);
641
626
  radio.setAttribute("aria-checked", value);
642
627
  }
643
- /** Writes a roving value before the radio is visible to the shared primitive. */
644
628
  #writeTabindex(radio, value) {
645
629
  const serialized = String(value);
646
630
  this.#markInternal(this.#internalTabindexValues, radio, serialized);
647
631
  radio.tabIndex = value;
648
632
  }
649
- /** Mirrors the selected value or the empty state to the optional hidden field. */
650
- #reflectField(radio, { silent = false } = {}) {
633
+ #reflectField(radio) {
651
634
  if (!this.hasFieldTarget) return;
652
635
  const value = radio ? this.#radioValue(radio) : "";
653
- if (!writeField(this.fieldTarget, value)) return;
654
- if (!silent) commitField(this.fieldTarget);
636
+ writeField(this.fieldTarget, value);
655
637
  }
656
- /** A radio's submitted value (`data-value`, defaulting to empty). */
657
638
  #radioValue(radio) {
658
639
  return radio.getAttribute("data-value") ?? "";
659
640
  }
660
641
  };
661
642
 
662
643
  export { RadioGroupController };
663
- //# sourceMappingURL=radio_group_controller.js.map
664
644
  //# sourceMappingURL=radio_group_controller.js.map