stimeo-ui 0.15.0 → 0.16.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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +178 -0
  3. data/README.md +120 -0
  4. data/dist/cable/index.js +63 -15
  5. data/dist/controllers/accordion_controller.js +98 -9
  6. data/dist/controllers/announcer_controller.js +8 -1
  7. data/dist/controllers/auto_submit_controller.js +43 -2
  8. data/dist/controllers/avatar_controller.js +1 -1
  9. data/dist/controllers/breadcrumb_controller.js +9 -8
  10. data/dist/controllers/bulk_select_controller.js +7 -6
  11. data/dist/controllers/calendar_controller.js +323 -123
  12. data/dist/controllers/carousel_controller.js +263 -38
  13. data/dist/controllers/character_counter_controller.js +40 -2
  14. data/dist/controllers/checkbox_controller.js +54 -10
  15. data/dist/controllers/clipboard_controller.js +63 -5
  16. data/dist/controllers/collapsible_controller.js +99 -14
  17. data/dist/controllers/color_picker_controller.js +80 -34
  18. data/dist/controllers/combobox_controller.js +106 -16
  19. data/dist/controllers/command_palette_controller.js +35 -3
  20. data/dist/controllers/conditional_fields_controller.js +56 -11
  21. data/dist/controllers/confirm_controller.js +3 -0
  22. data/dist/controllers/context_menu_controller.js +32 -12
  23. data/dist/controllers/countdown_controller.js +129 -26
  24. data/dist/controllers/currency_input_controller.js +205 -58
  25. data/dist/controllers/data_grid_controller.js +195 -29
  26. data/dist/controllers/date_range_picker_controller.js +139 -27
  27. data/dist/controllers/dialog_controller.js +35 -8
  28. data/dist/controllers/direct_upload_controller.js +22 -4
  29. data/dist/controllers/dirty_form_controller.js +14 -1
  30. data/dist/controllers/dismissible_controller.js +1 -0
  31. data/dist/controllers/drawer_controller.js +54 -19
  32. data/dist/controllers/dropdown_controller.js +36 -9
  33. data/dist/controllers/editable_controller.js +34 -0
  34. data/dist/controllers/file_dropzone_controller.js +144 -51
  35. data/dist/controllers/filter_controller.js +20 -6
  36. data/dist/controllers/flash_controller.js +305 -46
  37. data/dist/controllers/focus_controller.js +1 -0
  38. data/dist/controllers/form_field_controller.js +7 -5
  39. data/dist/controllers/form_validation_controller.js +19 -13
  40. data/dist/controllers/frame_loading_controller.js +16 -2
  41. data/dist/controllers/highlight_controller.js +19 -2
  42. data/dist/controllers/hover_card_controller.js +40 -14
  43. data/dist/controllers/idle_controller.js +90 -5
  44. data/dist/controllers/input_mask_controller.js +65 -9
  45. data/dist/controllers/intersection_controller.js +3 -0
  46. data/dist/controllers/lazy_frame_controller.js +11 -2
  47. data/dist/controllers/listbox_controller.js +203 -45
  48. data/dist/controllers/masonry_controller.js +0 -2
  49. data/dist/controllers/menu_controller.js +45 -16
  50. data/dist/controllers/menubar_controller.js +58 -24
  51. data/dist/controllers/meter_controller.js +9 -5
  52. data/dist/controllers/multi_select_controller.js +278 -104
  53. data/dist/controllers/navigation_menu_controller.js +48 -15
  54. data/dist/controllers/nested_form_controller.js +37 -8
  55. data/dist/controllers/network_status_controller.js +9 -1
  56. data/dist/controllers/number_input_controller.js +124 -21
  57. data/dist/controllers/optimistic_controller.js +14 -1
  58. data/dist/controllers/otp_controller.js +198 -55
  59. data/dist/controllers/overflow_indicator_controller.js +84 -13
  60. data/dist/controllers/overflow_menu_controller.js +112 -41
  61. data/dist/controllers/pagination_controller.js +74 -28
  62. data/dist/controllers/password_reveal_controller.js +59 -2
  63. data/dist/controllers/persist_controller.js +30 -8
  64. data/dist/controllers/pointer_drag_controller.js +131 -52
  65. data/dist/controllers/popover_controller.js +45 -11
  66. data/dist/controllers/portal_controller.js +6 -2
  67. data/dist/controllers/preview_guard_controller.js +16 -1
  68. data/dist/controllers/progress_controller.js +8 -4
  69. data/dist/controllers/radio_group_controller.js +42 -17
  70. data/dist/controllers/range_slider_controller.js +88 -42
  71. data/dist/controllers/rating_controller.js +39 -15
  72. data/dist/controllers/read_more_controller.js +69 -2
  73. data/dist/controllers/resizable_controller.js +82 -22
  74. data/dist/controllers/scroll_area_controller.js +5 -1
  75. data/dist/controllers/scroll_visibility_controller.js +0 -1
  76. data/dist/controllers/scrollspy_controller.js +6 -0
  77. data/dist/controllers/separator_controller.js +66 -37
  78. data/dist/controllers/sidebar_controller.js +77 -18
  79. data/dist/controllers/skeleton_controller.js +6 -1
  80. data/dist/controllers/slider_controller.js +82 -47
  81. data/dist/controllers/smart_sticky_header_controller.js +11 -1
  82. data/dist/controllers/sortable_controller.js +17 -2
  83. data/dist/controllers/spinner_controller.js +10 -2
  84. data/dist/controllers/step_indicator_controller.js +18 -17
  85. data/dist/controllers/stepper_controller.js +101 -19
  86. data/dist/controllers/stick_to_bottom_controller.js +75 -5
  87. data/dist/controllers/submit_once_controller.js +16 -3
  88. data/dist/controllers/switch_controller.js +101 -10
  89. data/dist/controllers/tabs_controller.js +21 -2
  90. data/dist/controllers/tags_input_controller.js +209 -59
  91. data/dist/controllers/theme_controller.js +64 -14
  92. data/dist/controllers/time_picker_controller.js +23 -8
  93. data/dist/controllers/toast_controller.js +337 -54
  94. data/dist/controllers/toggle_group_controller.js +159 -23
  95. data/dist/controllers/toolbar_controller.js +32 -0
  96. data/dist/controllers/tooltip_controller.js +39 -13
  97. data/dist/controllers/transition_controller.js +4 -0
  98. data/dist/controllers/tree_view_controller.js +169 -16
  99. data/dist/index.js +4588 -1535
  100. data/dist/positioning/index.js +2 -0
  101. data/lib/stimeo/ui/version.rb +1 -1
  102. metadata +2 -2
@@ -72,7 +72,66 @@ function ownerOf(candidates, node) {
72
72
  return candidates[ownerIndex(candidates, node)] ?? null;
73
73
  }
74
74
 
75
+ // src/utils/owned_pointer_session.ts
76
+ var OwnedPointerSession = class {
77
+ #pointerId;
78
+ #owner;
79
+ #handlers;
80
+ #abort = new AbortController();
81
+ #active = true;
82
+ constructor(start, owner, handlers) {
83
+ this.#pointerId = start.pointerId;
84
+ this.#owner = owner;
85
+ this.#handlers = handlers;
86
+ const { signal } = this.#abort;
87
+ owner.ownerDocument.addEventListener("pointermove", this.#onMove, { signal });
88
+ owner.ownerDocument.addEventListener("pointerup", this.#onEndEvent, { signal });
89
+ owner.ownerDocument.addEventListener("pointercancel", this.#onEndEvent, { signal });
90
+ try {
91
+ owner.setPointerCapture?.(this.#pointerId);
92
+ } catch {
93
+ }
94
+ }
95
+ /** Releases capture and listeners, then reports the end exactly once. */
96
+ end() {
97
+ this.#finish("teardown");
98
+ }
99
+ #finish(kind) {
100
+ if (!this.#active) return;
101
+ this.#active = false;
102
+ this.#abort.abort();
103
+ try {
104
+ this.#owner.releasePointerCapture?.(this.#pointerId);
105
+ } catch {
106
+ }
107
+ this.#handlers.end?.(kind);
108
+ }
109
+ #owns(event) {
110
+ return this.#active && event.pointerId === this.#pointerId;
111
+ }
112
+ #onMove = (event) => {
113
+ if (this.#owns(event)) this.#handlers.move(event);
114
+ };
115
+ #onEndEvent = (event) => {
116
+ if (this.#owns(event)) this.#finish(event.type === "pointerup" ? "up" : "cancel");
117
+ };
118
+ };
119
+
120
+ // src/utils/transient_hooks.ts
121
+ var TransientHooks = class {
122
+ #attributes;
123
+ constructor(options) {
124
+ this.#attributes = options.attributes;
125
+ }
126
+ /** Drops the declared hooks from `element`; what it carries besides stays. */
127
+ reset(element) {
128
+ for (const attribute of this.#attributes) element.removeAttribute(attribute);
129
+ }
130
+ };
131
+
75
132
  // src/controllers/pointer_drag_controller.ts
133
+ var TRANSIENT_DRAG = new TransientHooks({ attributes: ["data-dragging"] });
134
+ var TRANSIENT_GRAB = new TransientHooks({ attributes: ["data-grabbed"] });
76
135
  var NATIVE_KEY_OWNERS = "input, textarea, select, button, a[href], summary, [contenteditable]";
77
136
  var PointerDragController = class _PointerDragController extends Controller {
78
137
  static targets = ["handle"];
@@ -93,14 +152,12 @@ var PointerDragController = class _PointerDragController extends Controller {
93
152
  #keyboard = null;
94
153
  /** Committed follow offset from past drops; a new drag's deltas add onto it. */
95
154
  #followBase = { x: 0, y: 0 };
96
- /** Aborts in-progress pointer-drag listeners on drag end / teardown. */
97
- #dragAbort = null;
98
155
  /** Decides whether a mid-session disconnect() is an in-page move or a detach. */
99
156
  #gate = new DetachGate();
100
157
  connect() {
101
158
  this.#gate.cancel();
102
- if (!this.#pointer) this.element.removeAttribute("data-dragging");
103
- if (!this.#keyboard) this.element.removeAttribute("data-grabbed");
159
+ if (!this.#pointer) TRANSIENT_DRAG.reset(this.element);
160
+ if (!this.#keyboard) TRANSIENT_GRAB.reset(this.element);
104
161
  if (this.followValue && !this.#pointer && !this.#keyboard) {
105
162
  this.#followBase = this.#parseFollowBase();
106
163
  }
@@ -170,15 +227,19 @@ var PointerDragController = class _PointerDragController extends Controller {
170
227
  }
171
228
  this.#teardownSessions();
172
229
  }
173
- /** Arms a pointer drag on a handle; `start` waits for the threshold. */
230
+ /**
231
+ * Arms a pointer drag on a handle; `start` waits for the threshold.
232
+ *
233
+ * @stimeoRuntimeOnly `disabled` decides whether this one pointer press starts a gesture.
234
+ */
174
235
  #onPointerDown = (event) => {
175
236
  if (this.disabledValue || this.#pointer || this.#keyboard || event.button !== 0) return;
176
237
  const handle = this.#handleFor(event.target);
177
238
  if (!handle) return;
178
239
  event.preventDefault();
179
240
  handle.focus();
180
- this.#pointer = {
181
- pointerId: event.pointerId,
241
+ const session = {
242
+ pointer: null,
182
243
  handle,
183
244
  originX: event.clientX,
184
245
  originY: event.clientY,
@@ -187,17 +248,18 @@ var PointerDragController = class _PointerDragController extends Controller {
187
248
  dy: 0,
188
249
  pointerType: this.#pointerTypeOf(event)
189
250
  };
190
- handle.setPointerCapture(event.pointerId);
191
- this.#dragAbort?.abort();
192
- const abort = new AbortController();
193
- this.#dragAbort = abort;
194
- document.addEventListener("pointermove", this.#onPointerMove, { signal: abort.signal });
195
- document.addEventListener("pointerup", this.#onPointerUp, { signal: abort.signal });
196
- document.addEventListener("pointercancel", this.#onPointerCancel, { signal: abort.signal });
251
+ session.pointer = new OwnedPointerSession(event, handle, {
252
+ move: (move) => this.#trackMove(session, move),
253
+ end: (kind) => this.#finishPointer(session, kind)
254
+ });
255
+ this.#pointer = session;
197
256
  };
198
- #onPointerMove = (event) => {
199
- const session = this.#pointer;
200
- if (!session || event.pointerId !== session.pointerId) return;
257
+ /**
258
+ * Applies one tracked move: threshold, hooks, follow offset, events.
259
+ *
260
+ * @stimeoRuntimeOnly `threshold` decides when this one pointer move starts the drag.
261
+ */
262
+ #trackMove(session, event) {
201
263
  const [dx, dy] = this.#filterAxis(
202
264
  event.clientX - session.originX,
203
265
  event.clientY - session.originY
@@ -216,25 +278,32 @@ var PointerDragController = class _PointerDragController extends Controller {
216
278
  this.dispatch("move", {
217
279
  detail: { dx, dy, x: event.clientX, y: event.clientY, pointerType: session.pointerType }
218
280
  });
219
- };
220
- #onPointerUp = (event) => {
221
- const session = this.#pointer;
222
- if (!session || event.pointerId !== session.pointerId) return;
281
+ }
282
+ /**
283
+ * Closes the session and reports only the outcome the caller does not own.
284
+ *
285
+ * A teardown (detach, Escape, mid-drag disable, `reset`) is announced by the
286
+ * path that asked for it, so this one stays silent; `pointerup` below the
287
+ * threshold was a plain click, and `pointercancel` is an OS gesture takeover.
288
+ */
289
+ #finishPointer(session, kind) {
223
290
  const { started, dx, dy, pointerType } = session;
224
- this.#endPointerSession();
225
- if (!started) return;
226
- this.#followCommit(dx, dy);
227
- this.dispatch("end", { detail: { dx, dy, pointerType } });
228
- };
229
- /** OS gesture / scroll takeover interrupted the drag: cancel, don't drop. */
230
- #onPointerCancel = (event) => {
231
- const session = this.#pointer;
232
- if (!session || event.pointerId !== session.pointerId) return;
233
- const { started, pointerType } = session;
234
- this.#endPointerSession();
235
- if (started) this.#dispatchCancel(pointerType);
236
- };
237
- /** Keyboard alternative: Space/Enter grab & drop, arrows move, Escape cancels. */
291
+ this.#pointer = null;
292
+ this.element.removeAttribute("data-dragging");
293
+ if (kind === "teardown" || !started) return;
294
+ if (kind === "up") {
295
+ this.#followCommit(dx, dy);
296
+ this.dispatch("end", { detail: { dx, dy, pointerType } });
297
+ return;
298
+ }
299
+ this.#dispatchCancel(pointerType);
300
+ }
301
+ /**
302
+ * Keyboard alternative: Space/Enter grab & drop, arrows move, Escape cancels.
303
+ *
304
+ * @stimeoRuntimeOnly `disabled` decides whether this one key is taken and `keyboardStep` is the
305
+ * distance of one keyboard move.
306
+ */
238
307
  #onKeydown = (event) => {
239
308
  if (this.disabledValue) return;
240
309
  const handle = this.#handleFor(event.target);
@@ -316,26 +385,19 @@ var PointerDragController = class _PointerDragController extends Controller {
316
385
  return null;
317
386
  }
318
387
  }
319
- /** Zeroes the delta on the locked axis (`axis` = x | y | both). */
388
+ /**
389
+ * Zeroes the delta on the locked axis (`axis` = x | y | both).
390
+ *
391
+ * @stimeoRuntimeOnly `axis` filters the offset of one move to the allowed direction.
392
+ */
320
393
  #filterAxis(dx, dy) {
321
394
  if (this.axisValue === "x") return [dx, 0];
322
395
  if (this.axisValue === "y") return [0, dy];
323
396
  return [dx, dy];
324
397
  }
325
- /** Releases capture + listeners and clears the pointer session and its hook. */
398
+ /** Silently closes a live pointer session; the caller announces the outcome. */
326
399
  #endPointerSession() {
327
- this.#releasePointerCapture();
328
- this.#pointer = null;
329
- this.#dragAbort?.abort();
330
- this.#dragAbort = null;
331
- this.element.removeAttribute("data-dragging");
332
- }
333
- /** Releases the pointer capture the active session set (idempotent, safe). */
334
- #releasePointerCapture() {
335
- const session = this.#pointer;
336
- if (session?.handle.hasPointerCapture(session.pointerId)) {
337
- session.handle.releasePointerCapture(session.pointerId);
338
- }
400
+ this.#pointer?.pointer?.end();
339
401
  }
340
402
  #clearKeyboardSession() {
341
403
  this.#keyboard?.handle.removeAttribute("data-grabbed");
@@ -372,18 +434,33 @@ var PointerDragController = class _PointerDragController extends Controller {
372
434
  this.#followReset();
373
435
  this.dispatch("cancel", { detail: { pointerType } });
374
436
  }
375
- /** Applies the in-flight offset to the element's `translate` (follow only). */
437
+ /**
438
+ * Applies the in-flight offset to the element's `translate` (follow only).
439
+ *
440
+ * @stimeoRuntimeOnly `follow` decides whether this one move writes the offset as a translate; the
441
+ * offset itself comes from the gesture.
442
+ */
376
443
  #followMove(dx, dy) {
377
444
  if (!this.followValue) return;
378
445
  this.#applyFollow(this.#followBase.x + dx, this.#followBase.y + dy);
379
446
  }
380
- /** Folds a drop's deltas into the committed base offset (follow only). */
447
+ /**
448
+ * Folds a drop's deltas into the committed base offset (follow only).
449
+ *
450
+ * @stimeoRuntimeOnly `follow` decides whether the end of this one gesture keeps the offset as a
451
+ * translate.
452
+ */
381
453
  #followCommit(dx, dy) {
382
454
  if (!this.followValue) return;
383
455
  this.#followBase = { x: this.#followBase.x + dx, y: this.#followBase.y + dy };
384
456
  this.#applyFollow(this.#followBase.x, this.#followBase.y);
385
457
  }
386
- /** Snaps back to the committed position (follow only) — the cancel contract. */
458
+ /**
459
+ * Snaps back to the committed position (follow only) — the cancel contract.
460
+ *
461
+ * @stimeoRuntimeOnly `follow` decides whether cancelling this one gesture clears the translate it
462
+ * wrote.
463
+ */
387
464
  #followReset() {
388
465
  if (!this.followValue) return;
389
466
  this.#applyFollow(this.#followBase.x, this.#followBase.y);
@@ -438,6 +515,8 @@ var PointerDragController = class _PointerDragController extends Controller {
438
515
  * idempotent) and focusability for the keyboard path (`tabindex="0"` only when
439
516
  * the author supplied none — prefer a real `<button>` handle). Both additions
440
517
  * are marker-owned so `#restoreHandle` reverts them symmetrically on teardown.
518
+ *
519
+ * @stimeoRenderRoot
441
520
  */
442
521
  #prepareHandle(handle) {
443
522
  const lent = this.#touchActionForAxis();
@@ -192,6 +192,16 @@ function isScrollable(element) {
192
192
  return /auto|scroll|overlay/.test(style.overflow + style.overflowX + style.overflowY);
193
193
  }
194
194
 
195
+ // src/utils/state_reason.ts
196
+ var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
197
+ var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
198
+ function stateReasonFor(event) {
199
+ if (!event) return "api";
200
+ if (FOCUS_EVENTS.has(event.type)) return "focus";
201
+ if (POINTER_EVENTS.has(event.type)) return "pointer";
202
+ return "user";
203
+ }
204
+
195
205
  // src/controllers/popover_controller.ts
196
206
  var PopoverController = class extends Controller {
197
207
  static targets = ["trigger", "panel"];
@@ -199,15 +209,19 @@ var PopoverController = class extends Controller {
199
209
  closeOnScroll: { type: Boolean, default: false }
200
210
  };
201
211
  static actions = ["close", "open", "toggle"];
212
+ static events = ["close", "open"];
202
213
  /** Cleanup for the dismiss-on-scroll listeners while open, or `null`. */
203
214
  #stopScrollDismiss = null;
204
215
  /** Escape-stack membership while open; the shared resolver dismisses via it. */
205
216
  #escapeLayer = new EscapeLayer();
217
+ /** Whether state moves are reported: set once `connect()` settled the baseline. */
218
+ #reporting = false;
206
219
  /** Starts closed and registers the standing dismissal listeners. */
207
220
  connect() {
208
- this.close();
221
+ this.#close("api");
209
222
  document.addEventListener("click", this.#onOutsideClick, true);
210
223
  this.element.addEventListener("focusout", this.#onFocusOut);
224
+ this.#reporting = true;
211
225
  }
212
226
  /**
213
227
  * Removes every standing listener registered in {@link connect} plus any active
@@ -216,6 +230,7 @@ var PopoverController = class extends Controller {
216
230
  * the element after a Turbo navigation.
217
231
  */
218
232
  disconnect() {
233
+ this.#reporting = false;
219
234
  this.#escapeLayer.deactivate();
220
235
  document.removeEventListener("click", this.#onOutsideClick, true);
221
236
  this.element.removeEventListener("focusout", this.#onFocusOut);
@@ -223,34 +238,51 @@ var PopoverController = class extends Controller {
223
238
  this.#stopScrollDismiss = null;
224
239
  }
225
240
  /** Toggles the popover. Bound via `data-action` (click on the trigger). */
226
- toggle() {
241
+ toggle(event) {
227
242
  if (this.#isOpen) {
228
- this.close();
243
+ this.close(event);
229
244
  } else {
230
- this.open();
245
+ this.open(event);
231
246
  }
232
247
  }
233
248
  /** Opens the panel, reflects state, and moves focus inside it. */
234
- open() {
249
+ open(event) {
250
+ this.#open(stateReasonFor(event));
251
+ }
252
+ /** Closes the panel and reflects the collapsed state. Bound via `data-action`. */
253
+ close(event) {
254
+ this.#close(stateReasonFor(event));
255
+ }
256
+ /**
257
+ * Opens the panel, reflects state, reports a move, and moves focus inside it.
258
+ *
259
+ * @stimeoRuntimeOnly `closeOnScroll` decides whether this opening wires the scroll dismissal;
260
+ * what is shown does not depend on it.
261
+ */
262
+ #open(reason) {
235
263
  if (!this.hasPanelTarget || this.#isOpen) return;
236
264
  this.panelTarget.hidden = false;
237
265
  if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
266
+ if (this.#reporting) this.dispatch("open", { detail: { reason }, cancelable: false });
267
+ if (!this.#isOpen) return;
238
268
  this.#escapeLayer.activate(document, {
239
269
  onDismiss: () => this.#closeAndRestore(),
240
270
  claims: claimsWhileFocusWithin(this.element)
241
271
  });
242
272
  if (this.closeOnScrollValue && !this.#stopScrollDismiss) {
243
- this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.close());
273
+ this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#close("scroll"));
244
274
  }
245
275
  this.#focusFirst();
246
276
  }
247
- /** Closes the panel and reflects the collapsed state. Bound via `data-action`. */
248
- close() {
277
+ /** Closes the panel, reflects the collapsed state, and reports a move. */
278
+ #close(reason) {
279
+ const was = this.#isOpen;
249
280
  this.#escapeLayer.deactivate();
250
281
  this.#stopScrollDismiss?.();
251
282
  this.#stopScrollDismiss = null;
252
283
  if (this.hasPanelTarget) this.panelTarget.hidden = true;
253
284
  if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
285
+ if (was && this.#reporting) this.dispatch("close", { detail: { reason }, cancelable: false });
254
286
  }
255
287
  /** Moves focus to the first focusable element in the panel, or the panel itself. */
256
288
  #focusFirst() {
@@ -264,13 +296,15 @@ var PopoverController = class extends Controller {
264
296
  }
265
297
  /** Closes and restores focus to the trigger for explicit keyboard dismissal. */
266
298
  #closeAndRestore() {
267
- this.close();
299
+ this.#close("escape");
268
300
  if (this.hasTriggerTarget) this.triggerTarget.focus();
269
301
  }
270
302
  /** Closes without moving focus when a click lands outside the controller element. */
271
303
  #onOutsideClick = (event) => {
272
304
  const target = event.target;
273
- if (this.#isOpen && target instanceof Node && !this.element.contains(target)) this.close();
305
+ if (this.#isOpen && target instanceof Node && !this.element.contains(target)) {
306
+ this.#close("outside");
307
+ }
274
308
  };
275
309
  /**
276
310
  * Closes when focus leaves the controller for a known external destination
@@ -284,7 +318,7 @@ var PopoverController = class extends Controller {
284
318
  if (!this.#isOpen) return;
285
319
  const next = event.relatedTarget;
286
320
  if (!(next instanceof Node) || this.element.contains(next)) return;
287
- this.close();
321
+ this.#close("focus");
288
322
  };
289
323
  /** Whether the panel is currently visible. */
290
324
  get #isOpen() {
@@ -122,7 +122,7 @@ var PortalController = class extends Controller {
122
122
  destination.appendChild(node);
123
123
  }
124
124
  node.setAttribute("data-portaled", "true");
125
- this.dispatch("mount", { detail: { target: destination } });
125
+ this.dispatch("mount", { detail: { destination } });
126
126
  }
127
127
  disconnect() {
128
128
  const state = portalState.get(this.element);
@@ -137,7 +137,11 @@ var PortalController = class extends Controller {
137
137
  if (current?.owner === this) this.#restore(current);
138
138
  });
139
139
  }
140
- /** Returns the node to its placeholder (or removes it) and clears the bookkeeping. */
140
+ /**
141
+ * Returns the node to its placeholder (or removes it) and clears the bookkeeping.
142
+ *
143
+ * @stimeoRuntimeOnly `restore` decides whether this one unmount puts the node back or drops it.
144
+ */
141
145
  #restore(state) {
142
146
  portalState.delete(this.element);
143
147
  const { node, placeholder } = state;
@@ -83,7 +83,20 @@ var StylePropertyLease = class {
83
83
  }
84
84
  };
85
85
 
86
+ // src/utils/transient_hooks.ts
87
+ var TransientHooks = class {
88
+ #attributes;
89
+ constructor(options) {
90
+ this.#attributes = options.attributes;
91
+ }
92
+ /** Drops the declared hooks from `element`; what it carries besides stays. */
93
+ reset(element) {
94
+ for (const attribute of this.#attributes) element.removeAttribute(attribute);
95
+ }
96
+ };
97
+
86
98
  // src/controllers/preview_guard_controller.ts
99
+ var TRANSIENT = new TransientHooks({ attributes: ["data-preview-hidden"] });
87
100
  var PreviewGuardController = class extends Controller {
88
101
  static values = {
89
102
  placeholder: { type: String, default: "" }
@@ -101,7 +114,7 @@ var PreviewGuardController = class extends Controller {
101
114
  connect() {
102
115
  this.#connected = true;
103
116
  if (this.#hidden) this.#reguard();
104
- else this.element.removeAttribute("data-preview-hidden");
117
+ else TRANSIENT.reset(this.element);
105
118
  this.#beforeCache.activate();
106
119
  if (typeof MutationObserver !== "undefined") {
107
120
  this.#observer = new MutationObserver(() => this.#sync());
@@ -161,6 +174,8 @@ var PreviewGuardController = class extends Controller {
161
174
  * first would reconnect the whole subtree for an instant. Only a change of *form* —
162
175
  * to or from the empty placeholder — has to revert, and the focus the guard is holding
163
176
  * carries across it rather than being handed back and taken again.
177
+ *
178
+ * @stimeoRenderRoot
164
179
  */
165
180
  #reguard() {
166
181
  if (this.#savedNodes && this.placeholderValue !== "") {
@@ -76,8 +76,12 @@ function rangeFraction(value, min, max) {
76
76
  }
77
77
 
78
78
  // src/controllers/progress_controller.ts
79
- var OWNED_VALUE_TEXT = "data-stimeo--progress-owns-valuetext";
79
+ var OWNED_VALUE_TEXT = "owns-valuetext";
80
80
  var ProgressController = class extends Controller {
81
+ /** The marker above, in the namespace this controller is registered under. */
82
+ get #ownedValueText() {
83
+ return `data-${this.identifier}-${OWNED_VALUE_TEXT}`;
84
+ }
81
85
  static targets = ["bar"];
82
86
  static values = {
83
87
  value: { type: Number, default: 0 },
@@ -189,13 +193,13 @@ var ProgressController = class extends Controller {
189
193
  const percent = Math.round(this.#ratio * 100);
190
194
  const text = this.valueTextValue.replaceAll("{value}", String(value)).replaceAll("{percent}", String(percent));
191
195
  this.element.setAttribute("aria-valuetext", text);
192
- this.element.setAttribute(OWNED_VALUE_TEXT, "");
196
+ this.element.setAttribute(this.#ownedValueText, "");
193
197
  }
194
198
  /** Removes `aria-valuetext` only when this controller is the one that wrote it. */
195
199
  #clearOwnValueText() {
196
- if (!this.element.hasAttribute(OWNED_VALUE_TEXT)) return;
200
+ if (!this.element.hasAttribute(this.#ownedValueText)) return;
197
201
  this.element.removeAttribute("aria-valuetext");
198
- this.element.removeAttribute(OWNED_VALUE_TEXT);
202
+ this.element.removeAttribute(this.#ownedValueText);
199
203
  }
200
204
  };
201
205
 
@@ -29,6 +29,16 @@ function ownerOf(candidates, node) {
29
29
  return candidates[ownerIndex(candidates, node)] ?? null;
30
30
  }
31
31
 
32
+ // src/utils/field_mirror.ts
33
+ function writeField(field, value) {
34
+ if (field.value === value) return false;
35
+ field.value = value;
36
+ return true;
37
+ }
38
+ function commitField(target) {
39
+ target.dispatchEvent(new Event("change", { bubbles: true }));
40
+ }
41
+
32
42
  // src/utils/focus_candidate.ts
33
43
  function inheritsFieldsetDisabled(control) {
34
44
  let fieldset = control.closest("fieldset[disabled]");
@@ -131,6 +141,7 @@ function rovingMove(current, length, delta, wrap) {
131
141
  }
132
142
 
133
143
  // src/controllers/radio_group_controller.ts
144
+ var NO_SELECTION = { radio: null, value: "" };
134
145
  var OBSERVED_ATTRIBUTES = [
135
146
  "aria-checked",
136
147
  "aria-disabled",
@@ -158,7 +169,8 @@ var RadioGroupController = class extends Controller {
158
169
  #internalCheckedValues = /* @__PURE__ */ new Map();
159
170
  #internalTabindexValues = /* @__PURE__ */ new Map();
160
171
  #observer = null;
161
- #committedRadio = null;
172
+ /** The selection last published: read on connect, then committed or reported. */
173
+ #committed = NO_SELECTION;
162
174
  #connected = false;
163
175
  #lastOrder = [];
164
176
  #focusedRadio = null;
@@ -177,7 +189,7 @@ var RadioGroupController = class extends Controller {
177
189
  this.#normalizeSelection();
178
190
  this.#ensureTabStop(true);
179
191
  this.#reflectField(this.#selectedRadio, { silent: true });
180
- this.#committedRadio = this.#selectedRadio ?? null;
192
+ this.#committed = this.#settled;
181
193
  this.#lastOrder = this.#managedTargets;
182
194
  this.element.addEventListener("click", this.#onClickCapture, true);
183
195
  this.element.addEventListener("keydown", this.#onKeydownCapture, true);
@@ -208,7 +220,7 @@ var RadioGroupController = class extends Controller {
208
220
  this.#internalTabindexValues.clear();
209
221
  this.#focusedRadio = null;
210
222
  this.#pendingFocusIndex = null;
211
- this.#committedRadio = null;
223
+ this.#committed = NO_SELECTION;
212
224
  this.#preferChecked = false;
213
225
  }
214
226
  /** Removes a newly connected radio from the Tab sequence before batch reconciliation. */
@@ -334,17 +346,23 @@ var RadioGroupController = class extends Controller {
334
346
  if (this.#isActivationDisabled(destination)) this.#setActive(destination, true);
335
347
  else this.#selectRadio(destination, { focus: true });
336
348
  }
337
- /** Applies one user selection and emits only when selected identity changes. */
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
+ */
338
356
  #selectRadio(radio, { focus }) {
339
357
  if (!this.#isSupportedHost(radio) || this.#isActivationDisabled(radio)) return;
340
358
  const previous = this.#selectedRadio;
341
359
  const changed = previous !== radio;
342
360
  if (changed) {
343
361
  for (const item of this.#managedTargets) this.#setChecked(item, item === radio);
362
+ this.#committed = { radio, value: this.#radioValue(radio) };
344
363
  }
345
364
  this.#setActive(radio, focus);
346
365
  this.#reflectField(radio, { silent: !changed });
347
- this.#committedRadio = radio;
348
366
  if (changed) {
349
367
  this.dispatch("change", { detail: { value: this.#radioValue(radio), radio } });
350
368
  }
@@ -372,16 +390,19 @@ var RadioGroupController = class extends Controller {
372
390
  this.#reportReconciledSelection();
373
391
  }
374
392
  /**
375
- * Announces a selection this pass decided rather than the user. Dispatched after
376
- * observation resumes so a consumer's own DOM edits are seen by the next pass.
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.
377
398
  */
378
399
  #reportReconciledSelection() {
379
- const settled = this.#selectedRadio ?? null;
380
- if (settled === this.#committedRadio) return;
381
- this.#committedRadio = settled;
382
- this.dispatch("reconcile", {
383
- detail: { value: settled ? this.#radioValue(settled) : "", radio: settled }
384
- });
400
+ if (!this.#connected) return;
401
+ const settled = this.#settled;
402
+ const committed = this.#committed;
403
+ if (settled.radio === committed.radio && settled.value === committed.value) return;
404
+ this.#committed = settled;
405
+ this.dispatch("reconcile", { detail: { value: settled.value, radio: settled.radio } });
385
406
  }
386
407
  /** Begins or ends ownership according to a radio's current host semantics. */
387
408
  #reconcileHost(radio, dropFromTabSequence) {
@@ -493,7 +514,7 @@ var RadioGroupController = class extends Controller {
493
514
  }
494
515
  /** Whether the closest Radio Group scope around a target is this instance. */
495
516
  #ownsEventTarget(target) {
496
- return target instanceof Element && target.closest('[data-controller~="stimeo--radio-group"]') === this.element;
517
+ return target instanceof Element && target.closest(`[data-controller~="${this.identifier}"]`) === this.element;
497
518
  }
498
519
  /** Hosts whose activation model can be owned without conflicting native behavior. */
499
520
  #isSupportedHost(radio) {
@@ -603,6 +624,11 @@ var RadioGroupController = class extends Controller {
603
624
  get #selectedRadio() {
604
625
  return this.#managedTargets.find((radio) => this.#isChecked(radio));
605
626
  }
627
+ /** The current selection as the group publishes it. */
628
+ get #settled() {
629
+ const radio = this.#selectedRadio;
630
+ return radio ? { radio, value: this.#radioValue(radio) } : NO_SELECTION;
631
+ }
606
632
  /** Whether a radio is currently checked. */
607
633
  #isChecked(radio) {
608
634
  return radio.getAttribute("aria-checked") === "true";
@@ -624,9 +650,8 @@ var RadioGroupController = class extends Controller {
624
650
  #reflectField(radio, { silent = false } = {}) {
625
651
  if (!this.hasFieldTarget) return;
626
652
  const value = radio ? this.#radioValue(radio) : "";
627
- if (this.fieldTarget.value === value) return;
628
- this.fieldTarget.value = value;
629
- if (!silent) this.fieldTarget.dispatchEvent(new Event("change", { bubbles: true }));
653
+ if (!writeField(this.fieldTarget, value)) return;
654
+ if (!silent) commitField(this.fieldTarget);
630
655
  }
631
656
  /** A radio's submitted value (`data-value`, defaulting to empty). */
632
657
  #radioValue(radio) {