stimeo-ui 0.6.0 → 0.7.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 (199) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/dist/controllers/alert_dialog_controller.d.ts +2 -0
  3. package/dist/controllers/alert_dialog_controller.js +75 -4
  4. package/dist/controllers/alert_dialog_controller.js.map +1 -1
  5. package/dist/controllers/auto_submit_controller.d.ts +2 -0
  6. package/dist/controllers/auto_submit_controller.js.map +1 -1
  7. package/dist/controllers/avatar_controller.js +47 -5
  8. package/dist/controllers/avatar_controller.js.map +1 -1
  9. package/dist/controllers/breadcrumb_controller.d.ts +2 -0
  10. package/dist/controllers/breadcrumb_controller.js.map +1 -1
  11. package/dist/controllers/bulk_select_controller.d.ts +2 -0
  12. package/dist/controllers/bulk_select_controller.js.map +1 -1
  13. package/dist/controllers/calendar_controller.d.ts +2 -0
  14. package/dist/controllers/calendar_controller.js.map +1 -1
  15. package/dist/controllers/character_counter_controller.d.ts +52 -20
  16. package/dist/controllers/character_counter_controller.js +338 -63
  17. package/dist/controllers/character_counter_controller.js.map +1 -1
  18. package/dist/controllers/checkbox_controller.d.ts +3 -0
  19. package/dist/controllers/checkbox_controller.js.map +1 -1
  20. package/dist/controllers/color_picker_controller.d.ts +3 -0
  21. package/dist/controllers/color_picker_controller.js.map +1 -1
  22. package/dist/controllers/combobox_controller.d.ts +2 -0
  23. package/dist/controllers/combobox_controller.js.map +1 -1
  24. package/dist/controllers/command_palette_controller.d.ts +2 -0
  25. package/dist/controllers/command_palette_controller.js +75 -4
  26. package/dist/controllers/command_palette_controller.js.map +1 -1
  27. package/dist/controllers/conditional_fields_controller.d.ts +42 -14
  28. package/dist/controllers/conditional_fields_controller.js +345 -51
  29. package/dist/controllers/conditional_fields_controller.js.map +1 -1
  30. package/dist/controllers/confirm_controller.d.ts +2 -0
  31. package/dist/controllers/confirm_controller.js +75 -4
  32. package/dist/controllers/confirm_controller.js.map +1 -1
  33. package/dist/controllers/count_up_controller.d.ts +2 -0
  34. package/dist/controllers/count_up_controller.js.map +1 -1
  35. package/dist/controllers/countdown_controller.d.ts +4 -0
  36. package/dist/controllers/countdown_controller.js.map +1 -1
  37. package/dist/controllers/currency_input_controller.d.ts +2 -0
  38. package/dist/controllers/currency_input_controller.js.map +1 -1
  39. package/dist/controllers/data_grid_controller.d.ts +3 -0
  40. package/dist/controllers/data_grid_controller.js.map +1 -1
  41. package/dist/controllers/dialog_controller.js +75 -4
  42. package/dist/controllers/dialog_controller.js.map +1 -1
  43. package/dist/controllers/direct_upload_controller.d.ts +56 -24
  44. package/dist/controllers/direct_upload_controller.js +201 -45
  45. package/dist/controllers/direct_upload_controller.js.map +1 -1
  46. package/dist/controllers/dirty_form_controller.d.ts +14 -6
  47. package/dist/controllers/dirty_form_controller.js +192 -29
  48. package/dist/controllers/dirty_form_controller.js.map +1 -1
  49. package/dist/controllers/dismissible_controller.d.ts +2 -0
  50. package/dist/controllers/dismissible_controller.js +83 -18
  51. package/dist/controllers/dismissible_controller.js.map +1 -1
  52. package/dist/controllers/drawer_controller.js +75 -4
  53. package/dist/controllers/drawer_controller.js.map +1 -1
  54. package/dist/controllers/empty_state_controller.d.ts +2 -0
  55. package/dist/controllers/empty_state_controller.js.map +1 -1
  56. package/dist/controllers/file_dropzone_controller.d.ts +2 -0
  57. package/dist/controllers/file_dropzone_controller.js.map +1 -1
  58. package/dist/controllers/filter_controller.d.ts +2 -0
  59. package/dist/controllers/filter_controller.js.map +1 -1
  60. package/dist/controllers/flash_controller.d.ts +4 -0
  61. package/dist/controllers/flash_controller.js.map +1 -1
  62. package/dist/controllers/focus_controller.d.ts +2 -0
  63. package/dist/controllers/focus_controller.js +75 -4
  64. package/dist/controllers/focus_controller.js.map +1 -1
  65. package/dist/controllers/form_field_controller.d.ts +50 -10
  66. package/dist/controllers/form_field_controller.js +280 -62
  67. package/dist/controllers/form_field_controller.js.map +1 -1
  68. package/dist/controllers/form_validation_controller.d.ts +10 -8
  69. package/dist/controllers/form_validation_controller.js +208 -83
  70. package/dist/controllers/form_validation_controller.js.map +1 -1
  71. package/dist/controllers/frame_loading_controller.d.ts +2 -0
  72. package/dist/controllers/frame_loading_controller.js.map +1 -1
  73. package/dist/controllers/highlight_controller.d.ts +2 -0
  74. package/dist/controllers/highlight_controller.js.map +1 -1
  75. package/dist/controllers/idle_controller.d.ts +2 -0
  76. package/dist/controllers/idle_controller.js.map +1 -1
  77. package/dist/controllers/input_mask_controller.d.ts +2 -0
  78. package/dist/controllers/input_mask_controller.js.map +1 -1
  79. package/dist/controllers/lazy_frame_controller.d.ts +2 -0
  80. package/dist/controllers/lazy_frame_controller.js.map +1 -1
  81. package/dist/controllers/listbox_controller.d.ts +2 -0
  82. package/dist/controllers/listbox_controller.js.map +1 -1
  83. package/dist/controllers/local_time_controller.d.ts +2 -0
  84. package/dist/controllers/local_time_controller.js.map +1 -1
  85. package/dist/controllers/masonry_controller.d.ts +2 -0
  86. package/dist/controllers/masonry_controller.js.map +1 -1
  87. package/dist/controllers/meter_controller.d.ts +2 -0
  88. package/dist/controllers/meter_controller.js.map +1 -1
  89. package/dist/controllers/multi_select_controller.d.ts +2 -0
  90. package/dist/controllers/multi_select_controller.js.map +1 -1
  91. package/dist/controllers/nested_form_controller.d.ts +2 -0
  92. package/dist/controllers/nested_form_controller.js.map +1 -1
  93. package/dist/controllers/network_status_controller.d.ts +2 -0
  94. package/dist/controllers/network_status_controller.js.map +1 -1
  95. package/dist/controllers/number_input_controller.d.ts +2 -0
  96. package/dist/controllers/number_input_controller.js +47 -5
  97. package/dist/controllers/number_input_controller.js.map +1 -1
  98. package/dist/controllers/otp_controller.d.ts +3 -0
  99. package/dist/controllers/otp_controller.js.map +1 -1
  100. package/dist/controllers/overflow_indicator_controller.d.ts +2 -0
  101. package/dist/controllers/overflow_indicator_controller.js.map +1 -1
  102. package/dist/controllers/overflow_menu_controller.d.ts +2 -0
  103. package/dist/controllers/overflow_menu_controller.js +2 -1
  104. package/dist/controllers/overflow_menu_controller.js.map +1 -1
  105. package/dist/controllers/pagination_controller.d.ts +2 -0
  106. package/dist/controllers/pagination_controller.js +2 -1
  107. package/dist/controllers/pagination_controller.js.map +1 -1
  108. package/dist/controllers/password_reveal_controller.d.ts +2 -0
  109. package/dist/controllers/password_reveal_controller.js.map +1 -1
  110. package/dist/controllers/password_strength_controller.d.ts +2 -0
  111. package/dist/controllers/password_strength_controller.js.map +1 -1
  112. package/dist/controllers/persist_controller.d.ts +30 -16
  113. package/dist/controllers/persist_controller.js +432 -122
  114. package/dist/controllers/persist_controller.js.map +1 -1
  115. package/dist/controllers/pointer_drag_controller.d.ts +3 -0
  116. package/dist/controllers/pointer_drag_controller.js.map +1 -1
  117. package/dist/controllers/popover_controller.js +77 -4
  118. package/dist/controllers/popover_controller.js.map +1 -1
  119. package/dist/controllers/portal_controller.d.ts +2 -0
  120. package/dist/controllers/portal_controller.js.map +1 -1
  121. package/dist/controllers/preview_guard_controller.d.ts +2 -0
  122. package/dist/controllers/preview_guard_controller.js.map +1 -1
  123. package/dist/controllers/progress_controller.d.ts +3 -0
  124. package/dist/controllers/progress_controller.js.map +1 -1
  125. package/dist/controllers/radio_group_controller.d.ts +3 -0
  126. package/dist/controllers/radio_group_controller.js.map +1 -1
  127. package/dist/controllers/rating_controller.d.ts +2 -0
  128. package/dist/controllers/rating_controller.js +9 -5
  129. package/dist/controllers/rating_controller.js.map +1 -1
  130. package/dist/controllers/reading_progress_controller.d.ts +2 -0
  131. package/dist/controllers/reading_progress_controller.js.map +1 -1
  132. package/dist/controllers/resizable_controller.d.ts +2 -0
  133. package/dist/controllers/resizable_controller.js.map +1 -1
  134. package/dist/controllers/roving_controller.d.ts +2 -0
  135. package/dist/controllers/roving_controller.js.map +1 -1
  136. package/dist/controllers/scroll_area_controller.d.ts +23 -15
  137. package/dist/controllers/scroll_area_controller.js +462 -162
  138. package/dist/controllers/scroll_area_controller.js.map +1 -1
  139. package/dist/controllers/scroll_visibility_controller.d.ts +2 -0
  140. package/dist/controllers/scroll_visibility_controller.js.map +1 -1
  141. package/dist/controllers/scrollspy_controller.d.ts +2 -0
  142. package/dist/controllers/scrollspy_controller.js.map +1 -1
  143. package/dist/controllers/separator_controller.d.ts +54 -11
  144. package/dist/controllers/separator_controller.js +354 -38
  145. package/dist/controllers/separator_controller.js.map +1 -1
  146. package/dist/controllers/sidebar_controller.js +83 -10
  147. package/dist/controllers/sidebar_controller.js.map +1 -1
  148. package/dist/controllers/skeleton_controller.d.ts +2 -0
  149. package/dist/controllers/skeleton_controller.js.map +1 -1
  150. package/dist/controllers/slider_controller.d.ts +2 -0
  151. package/dist/controllers/slider_controller.js.map +1 -1
  152. package/dist/controllers/smart_sticky_header_controller.d.ts +2 -0
  153. package/dist/controllers/smart_sticky_header_controller.js.map +1 -1
  154. package/dist/controllers/spinner_controller.d.ts +2 -0
  155. package/dist/controllers/spinner_controller.js.map +1 -1
  156. package/dist/controllers/step_indicator_controller.d.ts +2 -0
  157. package/dist/controllers/step_indicator_controller.js.map +1 -1
  158. package/dist/controllers/stepper_controller.d.ts +2 -0
  159. package/dist/controllers/stepper_controller.js.map +1 -1
  160. package/dist/controllers/stick_to_bottom_controller.d.ts +2 -0
  161. package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
  162. package/dist/controllers/sticky_observer_controller.d.ts +2 -0
  163. package/dist/controllers/sticky_observer_controller.js.map +1 -1
  164. package/dist/controllers/submit_once_controller.d.ts +94 -38
  165. package/dist/controllers/submit_once_controller.js +399 -121
  166. package/dist/controllers/submit_once_controller.js.map +1 -1
  167. package/dist/controllers/switch_controller.d.ts +2 -0
  168. package/dist/controllers/switch_controller.js.map +1 -1
  169. package/dist/controllers/tags_input_controller.d.ts +2 -0
  170. package/dist/controllers/tags_input_controller.js.map +1 -1
  171. package/dist/controllers/textarea_autosize_controller.d.ts +2 -0
  172. package/dist/controllers/textarea_autosize_controller.js.map +1 -1
  173. package/dist/controllers/theme_controller.d.ts +2 -0
  174. package/dist/controllers/theme_controller.js +8 -6
  175. package/dist/controllers/theme_controller.js.map +1 -1
  176. package/dist/controllers/time_picker_controller.d.ts +2 -0
  177. package/dist/controllers/time_picker_controller.js.map +1 -1
  178. package/dist/controllers/toast_controller.d.ts +4 -0
  179. package/dist/controllers/toast_controller.js.map +1 -1
  180. package/dist/controllers/toggle_group_controller.d.ts +2 -0
  181. package/dist/controllers/toggle_group_controller.js.map +1 -1
  182. package/dist/controllers/toolbar_controller.js.map +1 -1
  183. package/dist/controllers/transition_controller.d.ts +2 -0
  184. package/dist/controllers/transition_controller.js.map +1 -1
  185. package/dist/controllers/tree_view_controller.d.ts +2 -0
  186. package/dist/controllers/tree_view_controller.js +2 -1
  187. package/dist/controllers/tree_view_controller.js.map +1 -1
  188. package/dist/index.js +2387 -861
  189. package/dist/index.js.map +1 -1
  190. package/dist/inspector/cli.d.ts +107 -3
  191. package/dist/inspector/cli.js +192 -14
  192. package/dist/inspector/cli.js.map +1 -1
  193. package/dist/inspector/cli_bin.js +254 -46
  194. package/dist/inspector/cli_bin.js.map +1 -1
  195. package/dist/inspector/examples.json +7 -7
  196. package/dist/inspector/manifest.json +434 -44
  197. package/dist/positioning/index.d.ts +2 -0
  198. package/dist/positioning/index.js.map +1 -1
  199. package/package.json +1 -1
@@ -2,6 +2,142 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/submit_once_controller.ts
4
4
 
5
+ // src/utils/announce.ts
6
+ function announce(message, options = {}) {
7
+ const text = message.trim();
8
+ if (text.length === 0) return;
9
+ window.dispatchEvent(
10
+ new CustomEvent("stimeo--announcer:announce", {
11
+ detail: { message: text, assertive: options.assertive === true }
12
+ })
13
+ );
14
+ }
15
+
16
+ // src/utils/attribute_lease.ts
17
+ var AttributeLease = class {
18
+ #attribute;
19
+ #records = /* @__PURE__ */ new Map();
20
+ /** @param attribute - The attribute whose temporary values this lease owns. */
21
+ constructor(attribute) {
22
+ this.#attribute = attribute;
23
+ }
24
+ /** Writes or removes the leased attribute while preserving its authored value. */
25
+ write(element, value) {
26
+ const existing = this.#records.get(element);
27
+ if (existing) {
28
+ existing.written = value;
29
+ } else {
30
+ this.#records.set(element, {
31
+ original: element.getAttribute(this.#attribute),
32
+ written: value
33
+ });
34
+ }
35
+ this.#reflect(element, value);
36
+ }
37
+ /** Returns one lease without overwriting a value subsequently authored by a consumer. */
38
+ return(element) {
39
+ const record = this.#records.get(element);
40
+ if (!record) return;
41
+ this.#records.delete(element);
42
+ const stillOwned = element.getAttribute(this.#attribute) === record.written;
43
+ if (stillOwned) this.#reflect(element, record.original);
44
+ }
45
+ /** Reflects only a real value transition, avoiding self-triggered mutation work. */
46
+ #reflect(element, value) {
47
+ if (element.getAttribute(this.#attribute) === value) return;
48
+ if (value === null) element.removeAttribute(this.#attribute);
49
+ else element.setAttribute(this.#attribute, value);
50
+ }
51
+ /** Returns every outstanding lease using the same ownership check as {@link return}. */
52
+ returnAll() {
53
+ for (const element of Array.from(this.#records.keys())) this.return(element);
54
+ }
55
+ };
56
+
57
+ // src/utils/before_cache_reset.ts
58
+ var BeforeCacheReset = class _BeforeCacheReset {
59
+ /** Every subscribed instance, iterated by the one shared document listener. */
60
+ static #subscribers = /* @__PURE__ */ new Set();
61
+ /** The shared listener; installed while at least one instance is subscribed. */
62
+ static #onBeforeCache = () => {
63
+ for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
64
+ };
65
+ #rewind;
66
+ /** @param rewind - the pass that returns this controller's state to its initial form. */
67
+ constructor(rewind) {
68
+ this.#rewind = rewind;
69
+ }
70
+ /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
71
+ activate() {
72
+ const first = _BeforeCacheReset.#subscribers.size === 0;
73
+ _BeforeCacheReset.#subscribers.add(this);
74
+ if (first) {
75
+ document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
76
+ }
77
+ }
78
+ /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
79
+ deactivate() {
80
+ _BeforeCacheReset.#subscribers.delete(this);
81
+ if (_BeforeCacheReset.#subscribers.size > 0) return;
82
+ document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
83
+ }
84
+ };
85
+
86
+ // src/utils/detach_gate.ts
87
+ var DetachGate = class _DetachGate {
88
+ /** Set while a probe is queued, waiting for a reconnect to cancel it. */
89
+ #pending = false;
90
+ /**
91
+ * True while a probe is queued — the last disconnect was ambiguous and no
92
+ * reconnect has cancelled it yet. Read it from `connect()` to tell the
93
+ * reconnect half of an in-page move from a first connect: a controller whose
94
+ * initialisation restarts a measurement (a min-duration floor, an elapsed
95
+ * counter) must skip it for the move, where nothing actually restarted.
96
+ */
97
+ get pending() {
98
+ return this.#pending;
99
+ }
100
+ /**
101
+ * True when the disconnect is definitely a real detach — the element left
102
+ * the document, or `data-controller` no longer lists the identifier. False
103
+ * means ambiguous (in-page move or observed-root exit), NOT "alive".
104
+ */
105
+ static isDetached(host) {
106
+ if (!host.element.isConnected) return true;
107
+ const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
108
+ return !tokens.includes(host.identifier);
109
+ }
110
+ /**
111
+ * Call from `disconnect()`: runs `teardown` synchronously on a definite
112
+ * detach (fast path), otherwise defers it one microtask — a reconnect
113
+ * ({@link cancel} from `connect()`) keeps the state, no reconnect runs it.
114
+ * One microtask is the whole probe window: Stimulus reconnects a moved
115
+ * element within the same mutation batch, before the checkpoint drains.
116
+ */
117
+ disconnected(host, teardown) {
118
+ if (_DetachGate.isDetached(host)) {
119
+ this.#pending = false;
120
+ teardown();
121
+ return;
122
+ }
123
+ this.#pending = true;
124
+ queueMicrotask(() => {
125
+ if (!this.#pending) return;
126
+ this.#pending = false;
127
+ teardown();
128
+ });
129
+ }
130
+ /**
131
+ * Disarms a pending probe. Call from `connect()` (the reconnect that proves
132
+ * an in-page move) and from the head of any teardown path not routed through
133
+ * {@link disconnected} (disabled-toggle, Escape), so an orphaned probe can
134
+ * never run the teardown a second time.
135
+ */
136
+ cancel() {
137
+ this.#pending = false;
138
+ }
139
+ };
140
+
5
141
  // src/utils/safe_timeout.ts
6
142
  var TimerRegistry = class {
7
143
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -56,169 +192,311 @@ var SafeTimeout = class extends TimerRegistry {
56
192
  };
57
193
 
58
194
  // src/controllers/submit_once_controller.ts
59
- var ORIGINAL_LABEL = "data-submit-once-original-label";
60
- var DISABLED_MARKER = "data-submit-once-disabled";
61
195
  var BUTTON_BUSY_LABEL = "data-submit-once-busy-label";
62
196
  var SubmitOnceController = class extends Controller {
63
- static targets = ["submit"];
197
+ static targets = ["submit", "idle", "busy"];
64
198
  static values = {
199
+ announceText: { type: String, default: "" },
200
+ announceReadyText: { type: String, default: "" },
65
201
  busyLabel: { type: String, default: "" },
66
202
  timeout: { type: Number, default: 0 },
67
203
  restoreFocus: { type: Boolean, default: false }
68
204
  };
69
- static actions = ["start"];
205
+ static actions = ["cancel", "finish", "start"];
70
206
  static events = ["start", "end"];
71
- #timeouts = new SafeTimeout();
72
- #timeoutId = null;
73
- #busy = false;
74
- #submitter = null;
207
+ #timers = new SafeTimeout();
208
+ #gate = new DetachGate();
209
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
210
+ #sessions = /* @__PURE__ */ new Map();
211
+ #disabled = new AttributeLease("disabled");
212
+ #controlBusy = new AttributeLease("aria-busy");
213
+ #formBusy = new AttributeLease("aria-busy");
214
+ #submitting = new AttributeLease("data-submitting");
215
+ #ariaLabel = new AttributeLease("aria-label");
216
+ #hidden = new AttributeLease("hidden");
75
217
  #onSubmitStart = (event) => {
76
218
  this.start(event);
77
219
  };
78
220
  #onSubmitEnd = (event) => {
79
- const success = event.detail?.success;
80
- this.#restore(success);
221
+ this.#complete(this.#eventForm(event), "turbo", this.#eventSuccess(event));
222
+ };
223
+ /** Blocks a later native submit while one for the same form is still active. */
224
+ #onNativeSubmit = (event) => {
225
+ const session = this.#sessionFor(event.target);
226
+ if (!session) return;
227
+ this.#syncControls(session);
228
+ event.preventDefault();
81
229
  };
82
230
  connect() {
231
+ this.#gate.cancel();
232
+ this.#beforeCache.activate();
233
+ this.element.addEventListener("submit", this.#onNativeSubmit, true);
83
234
  this.element.addEventListener("turbo:submit-start", this.#onSubmitStart);
84
235
  this.element.addEventListener("turbo:submit-end", this.#onSubmitEnd);
85
- this.#clearStaleBusy();
86
236
  }
87
237
  disconnect() {
238
+ this.element.removeEventListener("submit", this.#onNativeSubmit, true);
88
239
  this.element.removeEventListener("turbo:submit-start", this.#onSubmitStart);
89
240
  this.element.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
90
- this.#clearTimeout();
241
+ this.#beforeCache.deactivate();
242
+ this.#gate.disconnected(this, () => this.#teardown());
91
243
  }
92
244
  /**
93
- * Enters the busy state for the submission started by `event`. Accepts both a
94
- * native `SubmitEvent` (manual `submit->#start` on non-Turbo forms) and Turbo's
95
- * `turbo:submit-start` CustomEvent (auto-subscribed in `connect`).
245
+ * Begins the submission represented by `event`.
246
+ *
247
+ * Native `submit` events are intended for non-Turbo async forms. Turbo calls
248
+ * this path through its auto-subscribed `turbo:submit-start` event. An event
249
+ * whose default an earlier listener already cancelled carries no submission,
250
+ * so it starts nothing.
96
251
  */
97
252
  start(event) {
98
- if (this.#busy) return;
99
- this.#busy = true;
100
- const buttons = this.#buttons;
101
- const submitter = this.#resolveSubmitter(event, buttons);
102
- this.#submitter = submitter;
103
- for (const button of buttons) {
104
- this.#enterBusy(button, button === submitter);
105
- }
106
- this.element.setAttribute("data-submitting", "true");
107
- this.element.setAttribute("aria-busy", "true");
108
- this.dispatch("start", { detail: {} });
253
+ if (event.defaultPrevented) return;
254
+ const form = this.#eventForm(event);
255
+ if (!form || this.#sessions.has(form)) return;
256
+ const controls = this.#submitControls(form);
257
+ const submitter = this.#resolveSubmitter(event, controls);
258
+ const session = {
259
+ form,
260
+ submitter,
261
+ submitterHadFocus: submitter !== null && document.activeElement === submitter,
262
+ controls: /* @__PURE__ */ new Set(),
263
+ timeoutId: null,
264
+ labelWrite: null,
265
+ idlePart: null,
266
+ busyPart: null
267
+ };
268
+ this.#sessions.set(form, session);
269
+ this.#formBusy.write(form, "true");
270
+ this.#submitting.write(form, "true");
271
+ this.#syncControls(session, controls);
272
+ this.#enterLabel(session);
273
+ this.dispatch("start", { detail: { form, submitter } });
109
274
  if (this.timeoutValue > 0) {
110
- this.#timeoutId = this.#timeouts.set(() => this.#restore(false), this.timeoutValue);
111
- }
112
- }
113
- /** Restores the non-busy state, re-enabling buttons and putting labels back. */
114
- #restore(success) {
115
- if (!this.#busy) return;
116
- this.#busy = false;
117
- this.#clearTimeout();
118
- for (const button of this.#buttons) {
119
- this.#exitBusy(button);
120
- }
121
- this.element.removeAttribute("data-submitting");
122
- this.element.removeAttribute("aria-busy");
123
- this.dispatch("end", { detail: { success } });
124
- if (this.restoreFocusValue && this.#submitter) {
125
- this.#submitter.focus();
126
- }
127
- this.#submitter = null;
128
- }
129
- /** Disables a button, marks it busy, and swaps the trigger's label. */
130
- #enterBusy(button, isTrigger) {
131
- if (!button.disabled) {
132
- button.disabled = true;
133
- button.setAttribute(DISABLED_MARKER, "true");
134
- button.setAttribute("aria-busy", "true");
135
- }
136
- if (!isTrigger) return;
137
- const label = button.getAttribute(BUTTON_BUSY_LABEL) ?? this.busyLabelValue;
275
+ session.timeoutId = this.#timers.set(
276
+ () => this.#complete(form, "timeout", false),
277
+ this.timeoutValue
278
+ );
279
+ }
280
+ announce(this.announceTextValue);
281
+ }
282
+ /**
283
+ * Completes a non-Turbo async submission for the form associated with
284
+ * `event`. When called directly with no event, it completes the controller's
285
+ * sole active form (or the controller element itself when it is a form).
286
+ */
287
+ finish(event) {
288
+ const form = event ? this.#eventForm(event) : this.#defaultForm();
289
+ this.#complete(form, "manual", event ? this.#eventSuccess(event) : void 0);
290
+ }
291
+ /**
292
+ * Abandons the submission for the form associated with `event` because it
293
+ * never reached the network — a veto from validation, or a request the
294
+ * consumer gave up before sending. With no event it targets the controller's
295
+ * sole active form, exactly like {@link finish}.
296
+ *
297
+ * The distinction from `finish` is observable: a request that ran and failed
298
+ * is `finish` with `success: false` and still announces completion, while a
299
+ * submission that never happened announces nothing.
300
+ */
301
+ cancel(event) {
302
+ const form = event ? this.#eventForm(event) : this.#defaultForm();
303
+ this.#complete(form, "canceled", false);
304
+ }
305
+ /** Disables a newly connected explicit submit target during its form's session. */
306
+ submitTargetConnected(target) {
307
+ const session = this.#sessionFor(target.form);
308
+ if (!session || !this.#isNativeSubmitControl(target)) return;
309
+ this.#enterControl(session, target);
310
+ }
311
+ /** Ends one session, restores owned state, and reports the observable completion. */
312
+ #complete(form, reason, success) {
313
+ const session = this.#sessionFor(form);
314
+ if (!session) return;
315
+ this.#sessions.delete(session.form);
316
+ this.#returnSessionState(session);
317
+ const detail = success === void 0 ? { form: session.form, submitter: session.submitter, reason } : { form: session.form, submitter: session.submitter, reason, success };
318
+ this.dispatch("end", { detail });
319
+ this.#restoreFocus(session);
320
+ if (reason !== "canceled") announce(this.announceReadyTextValue);
321
+ }
322
+ /** Applies the busy state to every current native/explicit control exactly once. */
323
+ #syncControls(session, controls = this.#submitControls(session.form)) {
324
+ for (const control of controls) this.#enterControl(session, control);
325
+ }
326
+ /** Temporarily disables one enabled submit control while retaining authored state. */
327
+ #enterControl(session, control) {
328
+ if (session.controls.has(control) || control.matches(":disabled")) return;
329
+ session.controls.add(control);
330
+ this.#disabled.write(control, "");
331
+ this.#controlBusy.write(control, "true");
332
+ }
333
+ /** Swaps only the triggering control's safe label channel. */
334
+ #enterLabel(session) {
335
+ const control = session.submitter;
336
+ if (!control) return;
337
+ const idlePart = this.#partInside(control, this.idleTargets);
338
+ const busyPart = this.#partInside(control, this.busyTargets);
339
+ if (idlePart && busyPart) {
340
+ session.idlePart = idlePart;
341
+ session.busyPart = busyPart;
342
+ this.#hidden.write(idlePart, "");
343
+ this.#hidden.write(busyPart, null);
344
+ }
345
+ const label = control.getAttribute(BUTTON_BUSY_LABEL) ?? this.busyLabelValue;
138
346
  if (label.length === 0) return;
139
- if (!button.hasAttribute(ORIGINAL_LABEL)) {
140
- button.setAttribute(ORIGINAL_LABEL, this.#getLabel(button));
347
+ if (control instanceof HTMLInputElement) {
348
+ if (control.type !== "submit") return;
349
+ session.labelWrite = {
350
+ channel: "value",
351
+ control,
352
+ original: control.value,
353
+ written: label
354
+ };
355
+ control.value = label;
356
+ return;
141
357
  }
142
- this.#setLabel(button, label);
358
+ if (control.hasAttribute("aria-label")) {
359
+ this.#ariaLabel.write(control, label);
360
+ return;
361
+ }
362
+ if (control.childElementCount > 0) return;
363
+ session.labelWrite = {
364
+ channel: "text",
365
+ control,
366
+ original: control.textContent ?? "",
367
+ written: label
368
+ };
369
+ control.textContent = label;
143
370
  }
144
- /** Re-enables a button we disabled and restores its parked label, if any. */
145
- #exitBusy(button) {
146
- if (button.hasAttribute(DISABLED_MARKER)) {
147
- button.disabled = false;
148
- button.removeAttribute(DISABLED_MARKER);
149
- button.removeAttribute("aria-busy");
371
+ /** Returns labels, controls, and form hooks, respecting intervening consumer writes. */
372
+ #returnSessionState(session) {
373
+ if (session.timeoutId !== null) {
374
+ this.#timers.clear(session.timeoutId);
375
+ session.timeoutId = null;
376
+ }
377
+ if (session.labelWrite?.channel === "value") {
378
+ const { control, original, written } = session.labelWrite;
379
+ if (control.value === written) control.value = original;
380
+ } else if (session.labelWrite?.channel === "text") {
381
+ const { control, original, written } = session.labelWrite;
382
+ if (control.childElementCount === 0 && control.textContent === written) {
383
+ control.textContent = original;
384
+ }
150
385
  }
151
- const original = button.getAttribute(ORIGINAL_LABEL);
152
- if (original !== null) {
153
- this.#setLabel(button, original);
154
- button.removeAttribute(ORIGINAL_LABEL);
386
+ session.labelWrite = null;
387
+ if (session.submitter instanceof HTMLButtonElement) {
388
+ this.#ariaLabel.return(session.submitter);
155
389
  }
390
+ if (session.idlePart) this.#hidden.return(session.idlePart);
391
+ if (session.busyPart) this.#hidden.return(session.busyPart);
392
+ session.idlePart = null;
393
+ session.busyPart = null;
394
+ for (const control of session.controls) {
395
+ this.#controlBusy.return(control);
396
+ this.#disabled.return(control);
397
+ }
398
+ session.controls.clear();
399
+ this.#submitting.return(session.form);
400
+ this.#formBusy.return(session.form);
156
401
  }
157
- /** Resets busy state left in a restored cache snapshot without firing events. */
158
- #clearStaleBusy() {
159
- this.#busy = false;
160
- if (this.element.hasAttribute("data-submitting")) {
161
- this.element.removeAttribute("data-submitting");
162
- this.element.removeAttribute("aria-busy");
402
+ /** Restores focus only when disabling the focused submitter actually stranded it. */
403
+ #restoreFocus(session) {
404
+ const submitter = session.submitter;
405
+ if (!this.restoreFocusValue || !session.submitterHadFocus || !submitter?.isConnected) return;
406
+ const active = document.activeElement;
407
+ if (active === null || active === document.body || active === document.documentElement) {
408
+ submitter.focus();
163
409
  }
164
- for (const button of this.#buttons) {
165
- this.#exitBusy(button);
410
+ }
411
+ /** Silently abandons every session before caching or on a real detach. */
412
+ #abandonSessions() {
413
+ this.#timers.clearAll();
414
+ for (const session of this.#sessions.values()) {
415
+ session.timeoutId = null;
416
+ this.#returnSessionState(session);
166
417
  }
418
+ this.#sessions.clear();
167
419
  }
168
- /** The triggering button: the event's submitter when ours, else the first button. */
169
- #resolveSubmitter(event, buttons) {
170
- const submitter = this.#eventSubmitter(event);
171
- if (submitter && this.#isSubmitButton(submitter) && buttons.includes(submitter)) {
172
- return submitter;
420
+ /** Keeps Turbo's cached snapshot idle, without events, announcements, or focus moves. */
421
+ #rewindForCache() {
422
+ this.#abandonSessions();
423
+ }
424
+ /** Tears down a true detach; an in-page move is canceled by the next `connect()`. */
425
+ #teardown() {
426
+ this.#gate.cancel();
427
+ this.#abandonSessions();
428
+ }
429
+ /** All native controls associated with `form`, plus valid explicit targets. */
430
+ #submitControls(form) {
431
+ const controls = /* @__PURE__ */ new Set();
432
+ for (const element of Array.from(form.elements)) {
433
+ if (this.#isNativeSubmitControl(element)) controls.add(element);
434
+ }
435
+ const root = form.getRootNode();
436
+ for (const image of root.querySelectorAll('input[type="image"]')) {
437
+ if (image.form === form) controls.add(image);
173
438
  }
174
- return buttons[0] ?? null;
439
+ return Array.from(controls);
175
440
  }
176
- /**
177
- * Reads the submitter from a native `SubmitEvent` (`event.submitter`) or from
178
- * Turbo's `turbo:submit-start` detail (`detail.formSubmission.submitter`), so
179
- * the busy label swaps onto the right button under either path.
180
- */
441
+ /** Resolves the real submitter, falling back to the first enabled submit control. */
442
+ #resolveSubmitter(event, controls) {
443
+ const submitter = this.#eventSubmitter(event);
444
+ if (submitter && controls.includes(submitter)) return submitter;
445
+ return controls.find((control) => !control.matches(":disabled")) ?? controls[0] ?? null;
446
+ }
447
+ /** Reads a native or Turbo submitter without assuming `SubmitEvent` exists. */
181
448
  #eventSubmitter(event) {
182
449
  if (typeof SubmitEvent !== "undefined" && event instanceof SubmitEvent) {
183
- return event.submitter;
450
+ return this.#isNativeSubmitControl(event.submitter) ? event.submitter : null;
184
451
  }
185
452
  const detail = event.detail;
186
- return detail?.formSubmission?.submitter ?? null;
187
- }
188
- /** The controlled buttons: `submit` targets, or the form's native submit controls. */
189
- get #buttons() {
190
- if (this.hasSubmitTarget) return this.submitTargets;
191
- return Array.from(
192
- this.element.querySelectorAll('button[type="submit"], input[type="submit"]')
193
- );
194
- }
195
- #isSubmitButton(el) {
196
- return el instanceof HTMLButtonElement || el instanceof HTMLInputElement;
197
- }
198
- /** Reads a button's visible label (input value, aria-label, or text). */
199
- #getLabel(button) {
200
- if (button instanceof HTMLInputElement) return button.value;
201
- const aria = button.getAttribute("aria-label");
202
- if (aria !== null) return aria;
203
- return button.textContent ?? "";
204
- }
205
- /** Writes a button's visible label through the same channel it was read from. */
206
- #setLabel(button, text) {
207
- if (button instanceof HTMLInputElement) {
208
- button.value = text;
209
- return;
210
- }
211
- if (button.hasAttribute("aria-label")) {
212
- button.setAttribute("aria-label", text);
213
- return;
214
- }
215
- button.textContent = text;
453
+ const submitter = detail?.formSubmission?.submitter;
454
+ return this.#isNativeSubmitControl(submitter) ? submitter : null;
455
+ }
456
+ /** Finds the form represented by an action/Turbo event and verifies this scope owns it. */
457
+ #eventForm(event) {
458
+ const detail = event.detail;
459
+ const target = event.target;
460
+ const candidate = (target instanceof HTMLFormElement ? target : null) ?? (target instanceof Element ? target.closest("form") : null) ?? detail?.formSubmission?.formElement ?? detail?.form ?? this.#eventSubmitter(event)?.form ?? (this.element instanceof HTMLFormElement ? this.element : null);
461
+ return candidate && this.#ownsForm(candidate) ? candidate : null;
216
462
  }
217
- #clearTimeout() {
218
- if (this.#timeoutId !== null) {
219
- this.#timeouts.clear(this.#timeoutId);
220
- this.#timeoutId = null;
463
+ /** The only unambiguous form for a direct `finish()` call. */
464
+ #defaultForm() {
465
+ if (this.element instanceof HTMLFormElement && this.#ownsForm(this.element))
466
+ return this.element;
467
+ if (this.#sessions.size === 1) return this.#sessions.keys().next().value ?? null;
468
+ return null;
469
+ }
470
+ /** Prevents an ancestor controller from also taking over a nested instance's form. */
471
+ #ownsForm(form) {
472
+ let candidate = form;
473
+ while (candidate) {
474
+ const identifiers = (candidate.getAttribute("data-controller") ?? "").split(/\s+/);
475
+ if (identifiers.includes(this.identifier)) return candidate === this.element;
476
+ candidate = candidate.parentElement;
221
477
  }
478
+ return false;
479
+ }
480
+ /** Returns the active session for a form-shaped external event value. */
481
+ #sessionFor(candidate) {
482
+ if (!(candidate instanceof HTMLFormElement)) return void 0;
483
+ return this.#sessions.get(candidate);
484
+ }
485
+ /** Effective native submit controls, including implicit buttons and image inputs. */
486
+ #isNativeSubmitControl(element) {
487
+ if (element instanceof HTMLButtonElement) return element.type === "submit";
488
+ return element instanceof HTMLInputElement && (element.type === "submit" || element.type === "image");
489
+ }
490
+ /** Finds a structured label part belonging to this button, not a nested control. */
491
+ #partInside(control, parts) {
492
+ return parts.find(
493
+ (part) => control.contains(part) && part.closest("button, input[type=submit], input[type=image]") === control
494
+ ) ?? null;
495
+ }
496
+ /** Reads an optional boolean `success` from Turbo or a manual completion event. */
497
+ #eventSuccess(event) {
498
+ const success = event.detail?.success;
499
+ return typeof success === "boolean" ? success : void 0;
222
500
  }
223
501
  };
224
502