stimeo-ui 0.14.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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +212 -0
  3. data/README.md +120 -0
  4. data/dist/cable/index.js +123 -29
  5. data/dist/controllers/accordion_controller.js +98 -9
  6. data/dist/controllers/announcer_controller.js +96 -62
  7. data/dist/controllers/auto_submit_controller.js +83 -7
  8. data/dist/controllers/avatar_controller.js +1 -1
  9. data/dist/controllers/breadcrumb_controller.js +38 -11
  10. data/dist/controllers/bulk_select_controller.js +7 -6
  11. data/dist/controllers/calendar_controller.js +340 -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 +81 -12
  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 +85 -17
  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 +221 -67
  25. data/dist/controllers/data_grid_controller.js +195 -29
  26. data/dist/controllers/date_range_picker_controller.js +151 -30
  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 +46 -13
  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 +432 -71
  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 +45 -8
  41. data/dist/controllers/highlight_controller.js +82 -25
  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/local_time_controller.js +10 -5
  49. data/dist/controllers/masonry_controller.js +31 -15
  50. data/dist/controllers/menu_controller.js +45 -16
  51. data/dist/controllers/menubar_controller.js +58 -24
  52. data/dist/controllers/meter_controller.js +9 -5
  53. data/dist/controllers/multi_select_controller.js +278 -104
  54. data/dist/controllers/navigation_menu_controller.js +48 -15
  55. data/dist/controllers/nested_form_controller.js +37 -8
  56. data/dist/controllers/network_status_controller.js +9 -1
  57. data/dist/controllers/number_input_controller.js +124 -21
  58. data/dist/controllers/optimistic_controller.js +42 -5
  59. data/dist/controllers/otp_controller.js +198 -55
  60. data/dist/controllers/overflow_indicator_controller.js +115 -21
  61. data/dist/controllers/overflow_menu_controller.js +141 -44
  62. data/dist/controllers/pagination_controller.js +74 -28
  63. data/dist/controllers/password_reveal_controller.js +59 -2
  64. data/dist/controllers/persist_controller.js +30 -8
  65. data/dist/controllers/pointer_drag_controller.js +131 -52
  66. data/dist/controllers/popover_controller.js +45 -11
  67. data/dist/controllers/portal_controller.js +6 -2
  68. data/dist/controllers/preview_guard_controller.js +16 -1
  69. data/dist/controllers/progress_controller.js +8 -4
  70. data/dist/controllers/radio_group_controller.js +42 -17
  71. data/dist/controllers/range_slider_controller.js +88 -42
  72. data/dist/controllers/rating_controller.js +39 -15
  73. data/dist/controllers/read_more_controller.js +100 -7
  74. data/dist/controllers/reading_progress_controller.js +65 -19
  75. data/dist/controllers/relative_time_controller.js +10 -5
  76. data/dist/controllers/resizable_controller.js +82 -22
  77. data/dist/controllers/scroll_area_controller.js +75 -27
  78. data/dist/controllers/scroll_restore_controller.js +37 -16
  79. data/dist/controllers/scroll_visibility_controller.js +49 -30
  80. data/dist/controllers/scrollspy_controller.js +71 -26
  81. data/dist/controllers/separator_controller.js +66 -37
  82. data/dist/controllers/sidebar_controller.js +77 -18
  83. data/dist/controllers/skeleton_controller.js +6 -1
  84. data/dist/controllers/slider_controller.js +82 -47
  85. data/dist/controllers/smart_sticky_header_controller.js +60 -26
  86. data/dist/controllers/sortable_controller.js +17 -2
  87. data/dist/controllers/spinner_controller.js +10 -2
  88. data/dist/controllers/step_indicator_controller.js +18 -17
  89. data/dist/controllers/stepper_controller.js +101 -19
  90. data/dist/controllers/stick_to_bottom_controller.js +104 -8
  91. data/dist/controllers/submit_once_controller.js +45 -9
  92. data/dist/controllers/switch_controller.js +101 -10
  93. data/dist/controllers/tabs_controller.js +21 -2
  94. data/dist/controllers/tags_input_controller.js +209 -59
  95. data/dist/controllers/textarea_autosize_controller.js +29 -3
  96. data/dist/controllers/theme_controller.js +64 -14
  97. data/dist/controllers/time_picker_controller.js +23 -8
  98. data/dist/controllers/toast_controller.js +451 -105
  99. data/dist/controllers/toggle_group_controller.js +159 -23
  100. data/dist/controllers/toolbar_controller.js +32 -0
  101. data/dist/controllers/tooltip_controller.js +39 -13
  102. data/dist/controllers/transition_controller.js +4 -0
  103. data/dist/controllers/tree_view_controller.js +169 -16
  104. data/dist/index.js +5002 -1911
  105. data/dist/positioning/index.js +2 -0
  106. data/lib/stimeo/ui/version.rb +1 -1
  107. metadata +2 -2
@@ -1,5 +1,40 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
+ // src/controllers/stepper_controller.ts
4
+
5
+ // src/utils/microtask_coalescer.ts
6
+ var MicrotaskCoalescer = class {
7
+ #run;
8
+ #queued = false;
9
+ #active = false;
10
+ #generation = 0;
11
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
12
+ constructor(run) {
13
+ this.#run = run;
14
+ }
15
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
16
+ activate() {
17
+ this.#active = true;
18
+ }
19
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
20
+ cancel() {
21
+ this.#active = false;
22
+ this.#queued = false;
23
+ this.#generation += 1;
24
+ }
25
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
26
+ schedule() {
27
+ if (!this.#active || this.#queued) return;
28
+ this.#queued = true;
29
+ const generation = this.#generation;
30
+ queueMicrotask(() => {
31
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
32
+ this.#queued = false;
33
+ this.#run();
34
+ });
35
+ }
36
+ };
37
+
3
38
  // src/controllers/stepper_controller.ts
4
39
  var StepperController = class extends Controller {
5
40
  static targets = ["step"];
@@ -8,20 +43,39 @@ var StepperController = class extends Controller {
8
43
  linear: { type: Boolean, default: false }
9
44
  };
10
45
  static actions = ["goto", "next", "prev"];
11
- static events = ["change"];
12
- #isConnected = false;
13
- /** Normalizes an out-of-range initial `index` and renders the initial state. */
46
+ static events = ["change", "reconcile"];
47
+ /**
48
+ * Collapses the Value and target callbacks of one mutation into one pass, and
49
+ * refuses the ones Stimulus delivers before `connect()`, which renders itself.
50
+ */
51
+ #repaint = new MicrotaskCoalescer(() => this.#reconcileStep());
52
+ /**
53
+ * The position shown current last, which the next move is measured from, or
54
+ * `null` before any step has been shown. While no step is present it keeps the
55
+ * last one.
56
+ */
57
+ #shown = null;
58
+ /** Renders the initial state from `index`, clamped into the step range. */
14
59
  connect() {
15
- this.#isConnected = true;
16
- this.#normalizeAndRender();
60
+ this.#repaint.activate();
61
+ this.#shown = this.#currentStep;
62
+ this.#render();
17
63
  }
64
+ /** Drops a pending pass, so nothing renders or reports for a stepper that left. */
18
65
  disconnect() {
19
- this.#isConnected = false;
66
+ this.#repaint.cancel();
20
67
  }
21
68
  /** Re-renders when Turbo Morph or application code changes `index` at runtime. */
22
69
  indexValueChanged() {
23
- if (!this.#isConnected) return;
24
- this.#normalizeAndRender();
70
+ this.#repaint.schedule();
71
+ }
72
+ /** Re-derives every step's state for a step added at runtime. */
73
+ stepTargetConnected() {
74
+ this.#repaint.schedule();
75
+ }
76
+ /** Re-derives every step's state for a step removed at runtime. */
77
+ stepTargetDisconnected() {
78
+ this.#repaint.schedule();
25
79
  }
26
80
  /** Advances to the next step (ignored at the last step). */
27
81
  next() {
@@ -31,9 +85,16 @@ var StepperController = class extends Controller {
31
85
  prev() {
32
86
  this.#moveTo(this.#clampIndex(this.indexValue) - 1);
33
87
  }
34
- /** Jumps to the step carried in the action's `index` param. */
88
+ /**
89
+ * Jumps to the step carried in the action's `index` param. A param that is empty,
90
+ * or that Stimulus parses into anything but a number or a string, is no index,
91
+ * so it jumps nowhere.
92
+ */
35
93
  goto(event) {
36
- const target = Number(event.params.index);
94
+ const { index } = event.params;
95
+ if (index === "") return;
96
+ if (typeof index !== "number" && typeof index !== "string") return;
97
+ const target = Number(index);
37
98
  if (!Number.isFinite(target) || !Number.isInteger(target)) return;
38
99
  this.#moveTo(target);
39
100
  }
@@ -51,19 +112,39 @@ var StepperController = class extends Controller {
51
112
  if (this.linearValue && target > current + 1) return;
52
113
  const previous = current;
53
114
  this.indexValue = target;
54
- this.#render(target);
115
+ this.#shown = target;
116
+ this.#render();
55
117
  this.dispatch("change", {
56
- detail: { index: target, previous, step: this.stepTargets[target] }
118
+ detail: { index: target, previous, total, step: this.stepTargets[target] }
57
119
  });
58
120
  }
59
- /** Normalizes the public Value and reflects it without dispatching an action event. */
60
- #normalizeAndRender() {
61
- const normalized = this.#clampIndex(this.indexValue);
62
- if (!Object.is(normalized, this.indexValue)) this.indexValue = normalized;
63
- this.#render(normalized);
121
+ /**
122
+ * Re-derives one settled batch of Value and target changes, and reports a
123
+ * current position that moved from the one shown before as `reconcile`. With no
124
+ * step present there is nothing to report, and the position shown last stays the
125
+ * one the next move is measured from.
126
+ */
127
+ #reconcileStep() {
128
+ const previous = this.#shown;
129
+ const index = this.#currentStep;
130
+ if (index !== null) this.#shown = index;
131
+ this.#render();
132
+ if (index === null || previous === null || index === previous) return;
133
+ const steps = this.stepTargets;
134
+ this.dispatch("reconcile", {
135
+ detail: { index, previous, total: steps.length, step: steps[index] }
136
+ });
137
+ }
138
+ /** The current position, clamped into the step range, or `null` when there is no step. */
139
+ get #currentStep() {
140
+ return this.stepTargets.length > 0 ? this.#clampIndex(this.indexValue) : null;
64
141
  }
65
142
  /**
66
- * Derives each step's `data-state` and the current button's `aria-current`.
143
+ * Derives each step's `data-state` and the current button's `aria-current` from
144
+ * `index`, clamped into the step range, without dispatching an action event.
145
+ *
146
+ * The clamp decides only what is shown. This body never writes `index`, so an
147
+ * out-of-range declaration stays in the attribute as the page wrote it.
67
148
  *
68
149
  * `aria-current="step"` is placed on the step's **first** `<button>`; the markup
69
150
  * contract assumes one operable button per step. If a step needs multiple
@@ -71,7 +152,8 @@ var StepperController = class extends Controller {
71
152
  *
72
153
  * @stimeoRenderRoot
73
154
  */
74
- #render(current) {
155
+ #render() {
156
+ const current = this.#clampIndex(this.indexValue);
75
157
  this.stepTargets.forEach((step, index) => {
76
158
  step.dataset.state = index < current ? "complete" : index === current ? "current" : "upcoming";
77
159
  const button = step.querySelector("button");
@@ -8,10 +8,15 @@ var LayoutObserver = class {
8
8
  #resizeObserverFactory;
9
9
  #resizeObserver = null;
10
10
  #observingViewport = false;
11
+ #loadContainer = null;
11
12
  /** Stable bound handler so add/removeEventListener target the same reference. */
12
13
  #handleViewportResize = () => {
13
14
  this.#callback();
14
15
  };
16
+ /** Stable bound handler for the capture-phase `load`; see {@link observeDescendantLoads}. */
17
+ #handleDescendantLoad = () => {
18
+ this.#callback();
19
+ };
15
20
  constructor(callback, options = {}) {
16
21
  this.#callback = callback;
17
22
  this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
@@ -47,14 +52,35 @@ var LayoutObserver = class {
47
52
  window.removeEventListener("resize", this.#handleViewportResize);
48
53
  }
49
54
  /**
50
- * Releases every observation: disconnects the {@link ResizeObserver} and
51
- * removes the viewport listener. Safe to call multiple times. Call this from a
52
- * controller's `disconnect()`.
55
+ * Starts reporting a `load` from anywhere inside `container` — an image or a
56
+ * frame settling changes the box it sits in, and it measures as zero high until
57
+ * then. `load` does not bubble, so the subscription is a capture-phase listener
58
+ * on the container itself and nothing the caller spells.
59
+ *
60
+ * **One container at a time.** A further call moves the observation, so a widget
61
+ * whose content element is swapped at runtime releases the element it let go by
62
+ * naming the new one — there is no second place for the release to drift from.
63
+ */
64
+ observeDescendantLoads(container) {
65
+ this.unobserveDescendantLoads();
66
+ this.#loadContainer = container;
67
+ container.addEventListener("load", this.#handleDescendantLoad, true);
68
+ }
69
+ /** Stops reporting descendant loads without affecting element or viewport observation. */
70
+ unobserveDescendantLoads() {
71
+ this.#loadContainer?.removeEventListener("load", this.#handleDescendantLoad, true);
72
+ this.#loadContainer = null;
73
+ }
74
+ /**
75
+ * Releases every observation: disconnects the {@link ResizeObserver} and removes
76
+ * the viewport and descendant-load listeners. Safe to call multiple times. Call
77
+ * this from a controller's `disconnect()`.
53
78
  */
54
79
  disconnect() {
55
80
  this.#resizeObserver?.disconnect();
56
81
  this.#resizeObserver = null;
57
82
  this.unobserveViewport();
83
+ this.unobserveDescendantLoads();
58
84
  }
59
85
  };
60
86
 
@@ -63,6 +89,47 @@ function prefersReducedMotion() {
63
89
  return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
64
90
  }
65
91
 
92
+ // src/utils/state_regions.ts
93
+ var StateRegions = class {
94
+ #whenTrue;
95
+ #whenFalse;
96
+ #taken = /* @__PURE__ */ new WeakSet();
97
+ constructor(options) {
98
+ this.#whenTrue = options.whenTrue;
99
+ this.#whenFalse = options.whenFalse ?? null;
100
+ }
101
+ /** Shows the regions inside `host` that belong to `isTrue` and hides the others. */
102
+ reflect(host, isTrue) {
103
+ const shown = this.#inside(host, this.#whenTrue());
104
+ if (!this.#whenFalse) {
105
+ for (const region of shown) this.#write(region, !isTrue);
106
+ return;
107
+ }
108
+ const hiddenSide = this.#inside(host, this.#whenFalse());
109
+ if (shown.length === 0 || hiddenSide.length === 0) {
110
+ for (const region of shown) this.#give(region);
111
+ for (const region of hiddenSide) this.#give(region);
112
+ return;
113
+ }
114
+ for (const region of shown) this.#write(region, !isTrue);
115
+ for (const region of hiddenSide) this.#write(region, isTrue);
116
+ }
117
+ /** Writes `hidden` where it moves, noting which regions it takes out of view. */
118
+ #write(region, hidden) {
119
+ if (hidden) this.#taken.add(region);
120
+ else this.#taken.delete(region);
121
+ if (region.hidden !== hidden) region.hidden = hidden;
122
+ }
123
+ /** Returns a region this instance hid; one it never hid keeps what it carries. */
124
+ #give(region) {
125
+ if (this.#taken.delete(region)) region.hidden = false;
126
+ }
127
+ /** The declared regions that sit within `host`, which may be the host itself. */
128
+ #inside(host, regions) {
129
+ return regions.filter((region) => host.contains(region));
130
+ }
131
+ };
132
+
66
133
  // src/controllers/stick_to_bottom_controller.ts
67
134
  var DEFAULT_THRESHOLD = 80;
68
135
  var countElements = (nodes) => {
@@ -71,7 +138,7 @@ var countElements = (nodes) => {
71
138
  return n;
72
139
  };
73
140
  var StickToBottomController = class extends Controller {
74
- static targets = ["content"];
141
+ static targets = ["content", "hasNew"];
75
142
  static values = {
76
143
  threshold: { type: Number, default: DEFAULT_THRESHOLD },
77
144
  behavior: { type: String, default: "auto" },
@@ -87,11 +154,14 @@ var StickToBottomController = class extends Controller {
87
154
  #awaitingLayout = false;
88
155
  #connected = false;
89
156
  #pinned = false;
157
+ #hasNew = false;
158
+ /** Owns `hidden` on the regions declared for the has-new state. */
159
+ #hasNewRegion = new StateRegions({ whenTrue: () => this.hasNewTargets });
90
160
  #onScroll = () => this.#updatePinned();
91
161
  connect() {
92
162
  this.#connected = true;
93
163
  if (this.pinOnConnectValue && this.#measurable()) this.#scrollToBottom("instant");
94
- this.element.removeAttribute("data-has-new");
164
+ this.#setHasNew(false);
95
165
  this.#pinned = this.#isPinned();
96
166
  this.#reflectPinned();
97
167
  this.element.addEventListener("scroll", this.#onScroll, { passive: true });
@@ -112,6 +182,19 @@ var StickToBottomController = class extends Controller {
112
182
  contentTargetDisconnected() {
113
183
  this.#syncWatched();
114
184
  }
185
+ /**
186
+ * Settles a has-new region that arrived at runtime on the side the flag is currently
187
+ * on, the way a region present at connect is settled by the connect-time normalization.
188
+ *
189
+ * Stimulus attaches targets before `connect()`, and a container that leaves the document
190
+ * and comes back keeps its controller, so in that window the flag still reads the
191
+ * arrival of the connection that ended. A region attached there is left to the
192
+ * connect-time normalization, which drops the flag and settles the region in one write.
193
+ */
194
+ hasNewTargetConnected() {
195
+ if (!this.#connected) return;
196
+ this.#hasNewRegion.reflect(this.element, this.#hasNew);
197
+ }
115
198
  /**
116
199
  * Re-derives pinned when the distance that counts as the bottom is changed at runtime
117
200
  * (a morph that swaps the attribute on a retained element).
@@ -133,7 +216,7 @@ var StickToBottomController = class extends Controller {
133
216
  */
134
217
  scrollToBottom() {
135
218
  this.#scrollToBottom();
136
- this.element.removeAttribute("data-has-new");
219
+ this.#setHasNew(false);
137
220
  this.#updatePinned();
138
221
  }
139
222
  /** Follows appended children while pinned; otherwise flags new content. */
@@ -144,7 +227,7 @@ var StickToBottomController = class extends Controller {
144
227
  if (this.#pinned) {
145
228
  this.#scrollToBottom();
146
229
  } else {
147
- this.element.setAttribute("data-has-new", "true");
230
+ this.#setHasNew(true);
148
231
  this.dispatch("new", { detail: { count: added } });
149
232
  }
150
233
  }
@@ -164,11 +247,24 @@ var StickToBottomController = class extends Controller {
164
247
  #reflectPinned() {
165
248
  if (this.#pinned) {
166
249
  this.element.setAttribute("data-pinned", "true");
167
- this.element.removeAttribute("data-has-new");
250
+ this.#setHasNew(false);
168
251
  } else {
169
252
  this.element.removeAttribute("data-pinned");
170
253
  }
171
254
  }
255
+ /**
256
+ * Mirrors whether content has arrived that the container did not follow onto the state
257
+ * hook and onto the regions declared for that state.
258
+ */
259
+ #setHasNew(hasNew) {
260
+ this.#hasNew = hasNew;
261
+ if (hasNew) {
262
+ this.element.setAttribute("data-has-new", "true");
263
+ } else {
264
+ this.element.removeAttribute("data-has-new");
265
+ }
266
+ this.#hasNewRegion.reflect(this.element, hasNew);
267
+ }
172
268
  /** Whether the container currently sits within `threshold` of its bottom. */
173
269
  #isPinned() {
174
270
  if (!this.#measurable()) return false;
@@ -138,6 +138,30 @@ var DetachGate = class _DetachGate {
138
138
  }
139
139
  };
140
140
 
141
+ // src/utils/listener_set.ts
142
+ var ListenerSet = class {
143
+ /** The generation every `add` joins until the next `dispose()`. */
144
+ #abort = new AbortController();
145
+ /**
146
+ * Attaches `handler` to the open generation, exactly as the caller spelled it.
147
+ *
148
+ * The set supplies the signal, so `options` carries everything else the DOM
149
+ * accepts — `capture` included, which has to match at release time and no
150
+ * longer has a second place to drift from.
151
+ */
152
+ add(target, type, handler, options) {
153
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
154
+ }
155
+ /**
156
+ * Releases every listener of the open generation, synchronously, and opens the
157
+ * next one. Idempotent, and safe before anything has been added.
158
+ */
159
+ dispose() {
160
+ this.#abort.abort();
161
+ this.#abort = new AbortController();
162
+ }
163
+ };
164
+
141
165
  // src/utils/safe_timeout.ts
142
166
  var TimerRegistry = class {
143
167
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -206,6 +230,7 @@ var SubmitOnceController = class extends Controller {
206
230
  static events = ["start", "end", "reconcile"];
207
231
  #timers = new SafeTimeout();
208
232
  #gate = new DetachGate();
233
+ #listeners = new ListenerSet();
209
234
  #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
210
235
  #sessions = /* @__PURE__ */ new Map();
211
236
  #disabled = new AttributeLease("disabled");
@@ -230,14 +255,12 @@ var SubmitOnceController = class extends Controller {
230
255
  connect() {
231
256
  this.#gate.cancel();
232
257
  this.#beforeCache.activate();
233
- this.element.addEventListener("submit", this.#onNativeSubmit, true);
234
- this.element.addEventListener("turbo:submit-start", this.#onSubmitStart);
235
- this.element.addEventListener("turbo:submit-end", this.#onSubmitEnd);
258
+ this.#listeners.add(this.element, "submit", this.#onNativeSubmit, { capture: true });
259
+ this.#listeners.add(this.element, "turbo:submit-start", this.#onSubmitStart);
260
+ this.#listeners.add(this.element, "turbo:submit-end", this.#onSubmitEnd);
236
261
  }
237
262
  disconnect() {
238
- this.element.removeEventListener("submit", this.#onNativeSubmit, true);
239
- this.element.removeEventListener("turbo:submit-start", this.#onSubmitStart);
240
- this.element.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
263
+ this.#listeners.dispose();
241
264
  this.#beforeCache.deactivate();
242
265
  this.#gate.disconnected(this, () => this.#teardown());
243
266
  }
@@ -308,7 +331,12 @@ var SubmitOnceController = class extends Controller {
308
331
  if (!session || !this.#isNativeSubmitControl(target)) return;
309
332
  this.#enterControl(session, target);
310
333
  }
311
- /** Ends one session, restores owned state, and reports the observable completion. */
334
+ /**
335
+ * Ends one session, restores owned state, and reports the observable completion.
336
+ *
337
+ * @stimeoRuntimeOnly `announceReadyText` words the one announcement of this finished submission
338
+ * and `restoreFocus` decides where its focus returns.
339
+ */
312
340
  #complete(form, reason, success) {
313
341
  const session = this.#sessionFor(form);
314
342
  if (!session) return;
@@ -330,7 +358,12 @@ var SubmitOnceController = class extends Controller {
330
358
  this.#disabled.write(control, "");
331
359
  this.#controlBusy.write(control, "true");
332
360
  }
333
- /** Swaps only the triggering control's safe label channel. */
361
+ /**
362
+ * Swaps only the triggering control's safe label channel.
363
+ *
364
+ * @stimeoRuntimeOnly `busyLabel` is the label this one submission shows until it ends; the idle
365
+ * label comes back with the state it returns.
366
+ */
334
367
  #enterLabel(session) {
335
368
  const control = session.submitter;
336
369
  if (!control) return;
@@ -417,7 +450,10 @@ var SubmitOnceController = class extends Controller {
417
450
  }
418
451
  this.#sessions.clear();
419
452
  }
420
- /** Keeps Turbo's cached snapshot idle, without events, announcements, or focus moves. */
453
+ /**
454
+ * Keeps Turbo's cached snapshot idle without `end`, announcements, or focus
455
+ * moves; the abandoned forms are reported once as `reconcile`.
456
+ */
421
457
  #rewindForCache() {
422
458
  const forms = [...this.#sessions.keys()];
423
459
  this.#abandonSessions();
@@ -9,6 +9,16 @@ function setDefaultAttribute(element, name, value) {
9
9
  return true;
10
10
  }
11
11
 
12
+ // src/utils/field_mirror.ts
13
+ function writeField(field, value) {
14
+ if (field.value === value) return false;
15
+ field.value = value;
16
+ return true;
17
+ }
18
+ function commitField(target) {
19
+ target.dispatchEvent(new Event("change", { bubbles: true }));
20
+ }
21
+
12
22
  // src/utils/focus_candidate.ts
13
23
  function inheritsFieldsetDisabled(control) {
14
24
  let fieldset = control.closest("fieldset[disabled]");
@@ -37,6 +47,39 @@ function isInteractiveHost(element) {
37
47
  return false;
38
48
  }
39
49
 
50
+ // src/utils/microtask_coalescer.ts
51
+ var MicrotaskCoalescer = class {
52
+ #run;
53
+ #queued = false;
54
+ #active = false;
55
+ #generation = 0;
56
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
57
+ constructor(run) {
58
+ this.#run = run;
59
+ }
60
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
61
+ activate() {
62
+ this.#active = true;
63
+ }
64
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
65
+ cancel() {
66
+ this.#active = false;
67
+ this.#queued = false;
68
+ this.#generation += 1;
69
+ }
70
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
71
+ schedule() {
72
+ if (!this.#active || this.#queued) return;
73
+ this.#queued = true;
74
+ const generation = this.#generation;
75
+ queueMicrotask(() => {
76
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
77
+ this.#queued = false;
78
+ this.#run();
79
+ });
80
+ }
81
+ };
82
+
40
83
  // src/controllers/switch_controller.ts
41
84
  var OBSERVED_ATTRIBUTES = [
42
85
  "role",
@@ -49,17 +92,20 @@ var OBSERVED_ATTRIBUTES = [
49
92
  ];
50
93
  var OBSERVED_ANCESTOR_ATTRIBUTES = ["contenteditable"];
51
94
  var SwitchController = class extends Controller {
95
+ static targets = ["field"];
52
96
  static actions = ["onKeydown", "toggle"];
53
- static events = ["changed"];
97
+ static events = ["change", "reconcile"];
54
98
  /** Defaults this instance introduced and may therefore remove safely. */
55
99
  #ownedDefaults = /* @__PURE__ */ new Set();
56
100
  /** Controller writes that must not be mistaken for authored morph changes. */
57
101
  #internalAttributeValues = /* @__PURE__ */ new Map();
102
+ /** One pass per batch of retained-element changes and field arrivals. */
103
+ #pass = new MicrotaskCoalescer(() => this.#reconcile());
104
+ /** The checked state last published: read on connect, then committed or reported. */
105
+ #committedChecked = false;
58
106
  #attributeObserver = new MutationObserver((records) => {
59
107
  this.#releaseAuthoredDefaults(records);
60
- this.#attributeObserver.disconnect();
61
- this.#reconcileDefaults();
62
- this.#observeAttributes();
108
+ this.#pass.schedule();
63
109
  });
64
110
  /** Blocks disabled pointer/native-key activation before consumer click handlers. */
65
111
  #onClickCapture = (event) => {
@@ -67,14 +113,25 @@ var SwitchController = class extends Controller {
67
113
  event.preventDefault();
68
114
  event.stopImmediatePropagation();
69
115
  };
70
- /** Ensures the switch exposes a role and is keyboard-reachable. */
116
+ /**
117
+ * Ensures the switch exposes a role and is keyboard-reachable, and takes the
118
+ * state it finds as the one a later move is measured from, reporting nothing.
119
+ */
71
120
  connect() {
72
121
  this.#reconcileDefaults();
122
+ this.#mirrorField(false);
123
+ this.#committedChecked = this.#checked;
73
124
  this.element.addEventListener("click", this.#onClickCapture, true);
125
+ this.#pass.activate();
74
126
  this.#observeAttributes();
75
127
  }
76
- /** Releases the explicit click guard and retained-element attribute observer. */
128
+ /** Fills a form field inserted or replaced at runtime with the current state. */
129
+ fieldTargetConnected() {
130
+ this.#pass.schedule();
131
+ }
132
+ /** Releases the click guard, the attribute observer, and a pass still queued. */
77
133
  disconnect() {
134
+ this.#pass.cancel();
78
135
  this.element.removeEventListener("click", this.#onClickCapture, true);
79
136
  this.#attributeObserver.disconnect();
80
137
  this.#internalAttributeValues.clear();
@@ -82,7 +139,7 @@ var SwitchController = class extends Controller {
82
139
  /** Toggles the checked state. Bound via `data-action` (click). */
83
140
  toggle() {
84
141
  if (!this.#isSupportedHost || this.#isActivationDisabled) return;
85
- this.#checked = !this.#checked;
142
+ this.#commit(!this.#checked);
86
143
  }
87
144
  /**
88
145
  * Activates the switch on Space/Enter for non-native hosts and suppresses key
@@ -107,6 +164,28 @@ var SwitchController = class extends Controller {
107
164
  if (event.repeat) return;
108
165
  this.toggle();
109
166
  }
167
+ /**
168
+ * Re-derives the defaults and the field after the page changed the host, then
169
+ * reports a checked state that moved. Records still queued are read first, so
170
+ * an author's write is not lost when observation is suspended for this pass's
171
+ * own writes; observation resumes before the report, so an edit a listener
172
+ * makes is seen by the next pass.
173
+ */
174
+ #reconcile() {
175
+ this.#releaseAuthoredDefaults(this.#attributeObserver.takeRecords());
176
+ this.#attributeObserver.disconnect();
177
+ this.#reconcileDefaults();
178
+ this.#mirrorField(false);
179
+ this.#observeAttributes();
180
+ this.#reportMove();
181
+ }
182
+ /** Reports a checked state that moved since the one this switch last published. */
183
+ #reportMove() {
184
+ const checked = this.#checked;
185
+ if (checked === this.#committedChecked) return;
186
+ this.#committedChecked = checked;
187
+ this.dispatch("reconcile", { detail: { checked } });
188
+ }
110
189
  /** Re-adds only missing defaults, preserving every authored attribute value. */
111
190
  #reconcileDefaults() {
112
191
  if (!this.#isSupportedHost) {
@@ -119,6 +198,12 @@ var SwitchController = class extends Controller {
119
198
  this.#setOwnedDefault("tabindex", "0");
120
199
  }
121
200
  }
201
+ /** Mirrors the checked state into the optional form field. */
202
+ #mirrorField(notify) {
203
+ if (!this.hasFieldTarget) return;
204
+ const moved = writeField(this.fieldTarget, this.#checked ? "true" : "false");
205
+ if (moved && notify) commitField(this.fieldTarget);
206
+ }
122
207
  /** Records a newly introduced default without claiming authored markup. */
123
208
  #setOwnedDefault(name, value) {
124
209
  if (setDefaultAttribute(this.element, name, value)) this.#ownedDefaults.add(name);
@@ -181,12 +266,18 @@ var SwitchController = class extends Controller {
181
266
  get #checked() {
182
267
  return this.element.getAttribute("aria-checked") === "true";
183
268
  }
184
- /** Reflects the new state on `aria-checked` and notifies listeners. */
185
- set #checked(value) {
269
+ /**
270
+ * Applies one toggle the user made. The state is taken as published before
271
+ * either report goes out, so a listener that toggles again or writes the
272
+ * attribute is measured from it.
273
+ */
274
+ #commit(value) {
186
275
  const reflected = value ? "true" : "false";
187
276
  this.#internalAttributeValues.set("aria-checked", reflected);
188
277
  this.element.setAttribute("aria-checked", reflected);
189
- this.dispatch("changed", { detail: { checked: value } });
278
+ this.#committedChecked = value;
279
+ this.#mirrorField(true);
280
+ this.dispatch("change", { detail: { checked: value } });
190
281
  }
191
282
  };
192
283
 
@@ -17,6 +17,9 @@ function isReservedArrowChord(event, allow = []) {
17
17
  var TabsController = class extends Controller {
18
18
  static targets = ["tab", "panel", "list"];
19
19
  static actions = ["onKeydown", "select"];
20
+ static events = ["change"];
21
+ /** Whether selection moves are reported: set once `connect()` settled the baseline. */
22
+ #reporting = false;
20
23
  /**
21
24
  * Selects the initially active tab: the pre-selected one, else the first.
22
25
  *
@@ -30,6 +33,14 @@ var TabsController = class extends Controller {
30
33
  (tab) => tab.getAttribute("aria-selected") === "true"
31
34
  );
32
35
  this.#selectIndex(preselected === -1 ? 0 : preselected, { focus: false });
36
+ this.#reporting = true;
37
+ }
38
+ /**
39
+ * Stops reporting until the next `connect()` has settled its baseline, so that
40
+ * normalization stays silent.
41
+ */
42
+ disconnect() {
43
+ this.#reporting = false;
33
44
  }
34
45
  /** Selects the clicked tab. Bound via `data-action` (click). */
35
46
  select(event) {
@@ -69,7 +80,9 @@ var TabsController = class extends Controller {
69
80
  * `tabindex`, and panel visibility. Optionally moves focus to the new tab.
70
81
  */
71
82
  #selectIndex(index, { focus }) {
72
- this.tabTargets.forEach((tab, i) => {
83
+ const tabs = this.tabTargets;
84
+ const previous = tabs.findIndex((tab) => tab.getAttribute("aria-selected") === "true");
85
+ tabs.forEach((tab, i) => {
73
86
  const selected = i === index;
74
87
  tab.setAttribute("aria-selected", selected ? "true" : "false");
75
88
  tab.tabIndex = selected ? 0 : -1;
@@ -77,7 +90,13 @@ var TabsController = class extends Controller {
77
90
  this.panelTargets.forEach((panel, i) => {
78
91
  panel.hidden = i !== index;
79
92
  });
80
- if (focus) this.tabTargets[index]?.focus();
93
+ if (previous !== index && this.#reporting) {
94
+ this.dispatch("change", {
95
+ detail: { index, total: tabs.length, previous },
96
+ cancelable: false
97
+ });
98
+ }
99
+ if (focus && tabs[index]?.getAttribute("aria-selected") === "true") tabs[index]?.focus();
81
100
  }
82
101
  };
83
102