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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -0
  3. data/dist/controllers/alert_dialog_controller.js +75 -4
  4. data/dist/controllers/avatar_controller.js +47 -5
  5. data/dist/controllers/character_counter_controller.js +338 -63
  6. data/dist/controllers/command_palette_controller.js +75 -4
  7. data/dist/controllers/conditional_fields_controller.js +345 -51
  8. data/dist/controllers/confirm_controller.js +75 -4
  9. data/dist/controllers/dialog_controller.js +75 -4
  10. data/dist/controllers/direct_upload_controller.js +201 -45
  11. data/dist/controllers/dirty_form_controller.js +192 -29
  12. data/dist/controllers/dismissible_controller.js +83 -18
  13. data/dist/controllers/drawer_controller.js +75 -4
  14. data/dist/controllers/focus_controller.js +75 -4
  15. data/dist/controllers/form_field_controller.js +280 -62
  16. data/dist/controllers/form_validation_controller.js +208 -83
  17. data/dist/controllers/number_input_controller.js +47 -5
  18. data/dist/controllers/overflow_menu_controller.js +2 -1
  19. data/dist/controllers/pagination_controller.js +2 -1
  20. data/dist/controllers/persist_controller.js +432 -122
  21. data/dist/controllers/popover_controller.js +77 -4
  22. data/dist/controllers/rating_controller.js +9 -5
  23. data/dist/controllers/scroll_area_controller.js +462 -162
  24. data/dist/controllers/separator_controller.js +354 -38
  25. data/dist/controllers/sidebar_controller.js +83 -10
  26. data/dist/controllers/submit_once_controller.js +399 -121
  27. data/dist/controllers/theme_controller.js +8 -6
  28. data/dist/controllers/tree_view_controller.js +2 -1
  29. data/dist/index.js +2387 -861
  30. data/lib/stimeo/ui/version.rb +1 -1
  31. metadata +2 -2
@@ -2,6 +2,52 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/direct_upload_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
+ function fillTemplate(template, values) {
16
+ return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
17
+ const replacement = values[name];
18
+ return replacement === void 0 ? match : String(replacement);
19
+ });
20
+ }
21
+
22
+ // src/utils/before_cache_reset.ts
23
+ var BeforeCacheReset = class _BeforeCacheReset {
24
+ /** Every subscribed instance, iterated by the one shared document listener. */
25
+ static #subscribers = /* @__PURE__ */ new Set();
26
+ /** The shared listener; installed while at least one instance is subscribed. */
27
+ static #onBeforeCache = () => {
28
+ for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
29
+ };
30
+ #rewind;
31
+ /** @param rewind - the pass that returns this controller's state to its initial form. */
32
+ constructor(rewind) {
33
+ this.#rewind = rewind;
34
+ }
35
+ /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
36
+ activate() {
37
+ const first = _BeforeCacheReset.#subscribers.size === 0;
38
+ _BeforeCacheReset.#subscribers.add(this);
39
+ if (first) {
40
+ document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
41
+ }
42
+ }
43
+ /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
44
+ deactivate() {
45
+ _BeforeCacheReset.#subscribers.delete(this);
46
+ if (_BeforeCacheReset.#subscribers.size > 0) return;
47
+ document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
48
+ }
49
+ };
50
+
5
51
  // src/utils/safe_timeout.ts
6
52
  var TimerRegistry = class {
7
53
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -58,101 +104,165 @@ var SafeTimeout = class extends TimerRegistry {
58
104
  // src/controllers/direct_upload_controller.ts
59
105
  var REMOVE_DELAY = 4e3;
60
106
  var DirectUploadController = class extends Controller {
61
- static targets = ["list", "row", "status"];
107
+ static targets = ["list", "row"];
62
108
  static values = {
63
- announce: { type: Boolean, default: true },
64
109
  removeOnDone: { type: Boolean, default: false },
65
- doneLabel: { type: String, default: "" },
66
- errorLabel: { type: String, default: "" },
110
+ announceDoneText: { type: String, default: "" },
111
+ announceErrorText: { type: String, default: "" },
67
112
  scope: { type: String, default: "" }
68
113
  };
69
114
  static events = ["progress", "done", "error"];
70
115
  #timeouts = new SafeTimeout();
71
116
  #rows = /* @__PURE__ */ new Map();
117
+ #beforeCache = new BeforeCacheReset(() => this.#reset());
118
+ /** The validated `scope` selector; a broken declaration falls back to `""`. */
119
+ #scopeSelector = "";
72
120
  #onInitialize = (event) => {
73
121
  if (!this.#inScope(event)) return;
74
122
  const detail = this.#detail(event);
75
- this.#rowFor(detail.id, detail.file?.name ?? "");
123
+ this.#rowFor(detail.id, this.#name(detail));
76
124
  };
77
125
  #onProgress = (event) => {
78
126
  if (!this.#inScope(event)) return;
79
127
  const detail = this.#detail(event);
80
- this.#updateProgress(this.#key(detail.id), Math.round(detail.progress ?? 0));
128
+ this.#updateProgress(this.#key(detail.id), detail.progress ?? 0, this.#name(detail));
81
129
  };
82
130
  #onError = (event) => {
83
131
  if (!this.#inScope(event)) return;
84
132
  const detail = this.#detail(event);
85
- this.#fail(this.#key(detail.id), detail.error ?? "");
133
+ const rendered = this.#fail(this.#key(detail.id), detail.error ?? "", this.#name(detail));
134
+ if (rendered) event.preventDefault();
86
135
  };
87
136
  #onEnd = (event) => {
88
137
  if (!this.#inScope(event)) return;
89
138
  const detail = this.#detail(event);
90
- this.#complete(this.#key(detail.id), detail.file?.name ?? "");
139
+ this.#complete(this.#key(detail.id), this.#name(detail));
91
140
  };
92
141
  connect() {
93
142
  document.addEventListener("direct-upload:initialize", this.#onInitialize);
94
143
  document.addEventListener("direct-upload:progress", this.#onProgress);
95
144
  document.addEventListener("direct-upload:error", this.#onError);
96
145
  document.addEventListener("direct-upload:end", this.#onEnd);
146
+ this.#beforeCache.activate();
147
+ this.#rescheduleRemovals();
97
148
  }
98
149
  disconnect() {
99
150
  document.removeEventListener("direct-upload:initialize", this.#onInitialize);
100
151
  document.removeEventListener("direct-upload:progress", this.#onProgress);
101
152
  document.removeEventListener("direct-upload:error", this.#onError);
102
153
  document.removeEventListener("direct-upload:end", this.#onEnd);
154
+ this.#beforeCache.deactivate();
103
155
  this.#timeouts.clearAll();
104
- this.#rows.clear();
156
+ }
157
+ /** Validates `scope` once so the per-event path never parses or throws. */
158
+ scopeValueChanged() {
159
+ const selector = this.scopeValue;
160
+ if (selector.length > 0) {
161
+ try {
162
+ this.element.matches(selector);
163
+ this.#scopeSelector = selector;
164
+ return;
165
+ } catch {
166
+ }
167
+ }
168
+ this.#scopeSelector = "";
105
169
  }
106
170
  /** Updates a row's progress and the aggregate, emitting `progress`. */
107
- #updateProgress(id, percent) {
108
- const row = this.#rowFor(id);
109
- if (row === null) return;
110
- const clamped = Math.max(0, Math.min(100, percent));
111
- row.setAttribute("aria-valuenow", String(clamped));
112
- row.setAttribute("aria-valuetext", `${clamped}%`);
113
- row.style.setProperty("--stimeo--upload-progress", `${clamped}%`);
114
- this.#setField(row, "percent", `${clamped}%`);
171
+ #updateProgress(id, percent, name) {
172
+ const row = this.#rowFor(id, name);
173
+ if (row === null || this.#isSettled(row)) return;
174
+ const clamped = this.#applyProgress(row, percent);
115
175
  this.#syncAggregate();
116
176
  this.dispatch("progress", { detail: { id, percent: clamped } });
117
177
  }
118
- /** Marks a row done, announces it, and emits `done`. */
119
- #complete(id, name = "") {
178
+ /** Marks a not-yet-settled row done at 100%, announces it, and emits `done`. */
179
+ #complete(id, name) {
120
180
  const row = this.#rowFor(id, name);
121
- if (row === null) return;
181
+ if (row === null || this.#isSettled(row)) return;
122
182
  row.setAttribute("data-upload-state", "done");
123
- this.#announce(this.doneLabelValue, row);
183
+ this.#applyProgress(row, 100);
184
+ this.#syncAggregate();
185
+ this.#announce(this.announceDoneTextValue, name, row);
124
186
  this.dispatch("done", { detail: { id } });
125
187
  if (this.removeOnDoneValue) {
126
188
  this.#timeouts.set(() => this.#removeRow(id), REMOVE_DELAY);
127
189
  }
128
190
  }
129
- /** Marks a row failed, announces it, and emits `error`. */
130
- #fail(id, error) {
131
- const row = this.#rowFor(id);
132
- if (row === null) return;
191
+ /**
192
+ * Marks a not-yet-settled row failed, announces it, and emits `error`.
193
+ * Returns whether the failure is rendered by this widget (used to decide the
194
+ * `direct-upload:error` default), which also holds when the row already
195
+ * displays an earlier failure.
196
+ */
197
+ #fail(id, error, name) {
198
+ const row = this.#rowFor(id, name);
199
+ if (row === null) return false;
200
+ if (this.#isSettled(row)) return row.getAttribute("data-upload-state") === "error";
133
201
  row.setAttribute("data-upload-state", "error");
134
- this.#announce(this.errorLabelValue, row);
202
+ this.#announce(this.announceErrorTextValue, name, row);
135
203
  this.dispatch("error", { detail: { id, error } });
204
+ return true;
136
205
  }
137
- /** Returns an existing row or clones one from the template. */
206
+ /** Whether the row reached a terminal state; settled rows are never rewritten. */
207
+ #isSettled(row) {
208
+ const state = row.getAttribute("data-upload-state");
209
+ return state === "done" || state === "error";
210
+ }
211
+ /** Re-arms `removeOnDone` for completed rows after a reconnect. */
212
+ #rescheduleRemovals() {
213
+ if (!this.removeOnDoneValue) return;
214
+ this.#prune();
215
+ for (const [id, row] of this.#rows) {
216
+ if (row.getAttribute("data-upload-state") === "done") {
217
+ this.#timeouts.set(() => this.#removeRow(id), REMOVE_DELAY);
218
+ }
219
+ }
220
+ }
221
+ /** Returns the live row for `id`, creating (and labeling) one on first sight. */
138
222
  #rowFor(id, name) {
139
223
  const key = this.#key(id);
140
224
  const existing = this.#rows.get(key);
141
- if (existing !== void 0) return existing;
225
+ if (existing !== void 0) {
226
+ if (this.#tracksRow(existing)) {
227
+ this.#applyName(existing, name);
228
+ return existing;
229
+ }
230
+ existing.remove();
231
+ this.#rows.delete(key);
232
+ }
142
233
  if (!this.hasRowTarget || !this.hasListTarget) return null;
143
234
  const clone = this.rowTarget.content.firstElementChild?.cloneNode(true);
144
235
  if (!(clone instanceof HTMLElement)) return null;
145
- if (name !== void 0 && name.length > 0) {
146
- this.#setField(clone, "name", name);
147
- clone.setAttribute("aria-label", name);
148
- }
149
- clone.setAttribute("aria-valuenow", "0");
236
+ this.#applyName(clone, name);
150
237
  clone.setAttribute("data-upload-state", "uploading");
151
- clone.style.setProperty("--stimeo--upload-progress", "0%");
238
+ this.#applyProgress(clone, 0);
152
239
  this.listTarget.appendChild(clone);
153
240
  this.#rows.set(key, clone);
241
+ this.#syncAggregate();
154
242
  return clone;
155
243
  }
244
+ /**
245
+ * Writes the event's file name into `[data-field="name"]` and, unless the row
246
+ * already carries a non-blank `aria-label` — authored on the template or
247
+ * applied by an earlier event — makes it the accessible name too. The visible
248
+ * name never depends on the label: an authored label keeps its wording while
249
+ * the field still shows which file this row tracks.
250
+ */
251
+ #applyName(row, name) {
252
+ if (name.length === 0) return;
253
+ this.#setField(row, "name", name);
254
+ if ((row.getAttribute("aria-label") ?? "").trim().length > 0) return;
255
+ row.setAttribute("aria-label", name);
256
+ }
257
+ /** Writes one progress value to every per-row hook; returns the clamped percent. */
258
+ #applyProgress(row, percent) {
259
+ const clamped = Math.max(0, Math.min(100, Math.round(percent)));
260
+ row.setAttribute("aria-valuenow", String(clamped));
261
+ row.setAttribute("aria-valuetext", `${clamped}%`);
262
+ row.style.setProperty("--stimeo--upload-progress", `${clamped}%`);
263
+ this.#setField(row, "percent", `${clamped}%`);
264
+ return clamped;
265
+ }
156
266
  #removeRow(id) {
157
267
  const row = this.#rows.get(id);
158
268
  if (row === void 0) return;
@@ -160,10 +270,15 @@ var DirectUploadController = class extends Controller {
160
270
  this.#rows.delete(id);
161
271
  this.#syncAggregate();
162
272
  }
163
- /** Reflects the average progress across rows on the controller element. */
273
+ /**
274
+ * Reflects the average progress across live rows on the controller element,
275
+ * withdrawing both hooks once no rows remain.
276
+ */
164
277
  #syncAggregate() {
278
+ this.#prune();
165
279
  if (this.#rows.size === 0) {
166
280
  this.element.removeAttribute("data-upload-progress");
281
+ this.element.style.removeProperty("--stimeo--upload-progress");
167
282
  return;
168
283
  }
169
284
  let total = 0;
@@ -174,11 +289,48 @@ var DirectUploadController = class extends Controller {
174
289
  this.element.setAttribute("data-upload-progress", String(overall));
175
290
  this.element.style.setProperty("--stimeo--upload-progress", `${overall}%`);
176
291
  }
177
- /** Writes a consumer label (with `%{name}` substituted) to the status region. */
178
- #announce(label, row) {
179
- if (!this.announceValue || !this.hasStatusTarget || label.length === 0) return;
180
- const name = this.#field(row, "name")?.textContent ?? "";
181
- this.statusTarget.textContent = label.replace("%{name}", name);
292
+ /**
293
+ * Whether a bookkept row is still this widget's live UI: connected, and — when
294
+ * a `list` target is present inside the *current* one, so re-pointing the
295
+ * target attribute at a new element retires rows kept alive in the old list.
296
+ */
297
+ #tracksRow(row) {
298
+ if (!row.isConnected) return false;
299
+ return !this.hasListTarget || this.listTarget.contains(row);
300
+ }
301
+ /**
302
+ * Retires rows that left the live UI (list swap, external removal). A retired
303
+ * clone is removed outright — generated rows only ever live under the current
304
+ * `list`, so the before-cache rewind never has an untracked leftover to miss.
305
+ */
306
+ #prune() {
307
+ for (const [id, row] of this.#rows) {
308
+ if (this.#tracksRow(row)) continue;
309
+ row.remove();
310
+ this.#rows.delete(id);
311
+ }
312
+ }
313
+ /**
314
+ * Returns the widget to its pre-upload state just before Turbo caches the
315
+ * page, so the snapshot never replays rows for uploads that cannot resume.
316
+ */
317
+ #reset() {
318
+ for (const row of this.#rows.values()) row.remove();
319
+ this.#rows.clear();
320
+ this.#timeouts.clearAll();
321
+ this.#syncAggregate();
322
+ }
323
+ /**
324
+ * Sends one consumer-worded message to the page's shared announcer. `{name}`
325
+ * resolves to the row's displayed name first — the event that settles an
326
+ * upload may omit the file although an earlier event already named the row —
327
+ * then the event's own name, then the accessible name (which an authored
328
+ * label owns, so it is the last resort, not the primary source).
329
+ */
330
+ #announce(template, name, row) {
331
+ const stored = this.#field(row, "name")?.textContent ?? "";
332
+ const label = stored.length > 0 ? stored : name.length > 0 ? name : row.getAttribute("aria-label") ?? "";
333
+ announce(fillTemplate(template, { name: label }));
182
334
  }
183
335
  #field(row, name) {
184
336
  return row.querySelector(`[data-field="${name}"]`);
@@ -190,17 +342,21 @@ var DirectUploadController = class extends Controller {
190
342
  #detail(event) {
191
343
  return event.detail ?? {};
192
344
  }
345
+ /** The file name every ActiveStorage `direct-upload:*` event carries. */
346
+ #name(detail) {
347
+ return detail.file?.name ?? "";
348
+ }
193
349
  /**
194
350
  * Whether an event belongs to this controller. With `scope` set, only events
195
351
  * whose target (the file input) sits inside an element matching `scope` are
196
- * handled, so several upload widgets on one page do not cross-populate. Resolved
197
- * with `closest()` from the target itself, so the chatty `progress` stream never
198
- * pays a document-wide query. Empty `scope` handles all.
352
+ * handled, so several upload widgets on one page do not cross-populate.
353
+ * Resolved with `closest()` from the target itself, so the chatty `progress`
354
+ * stream never pays a document-wide query. Empty `scope` handles all.
199
355
  */
200
356
  #inScope(event) {
201
- if (this.scopeValue.length === 0) return true;
357
+ if (this.#scopeSelector.length === 0) return true;
202
358
  const target = event.target;
203
- return target instanceof Element && target.closest(this.scopeValue) !== null;
359
+ return target instanceof Element && target.closest(this.#scopeSelector) !== null;
204
360
  }
205
361
  #key(id) {
206
362
  return String(id ?? "");
@@ -1,60 +1,220 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/dirty_form_controller.ts
4
+
5
+ // src/utils/safe_timeout.ts
6
+ var TimerRegistry = class {
7
+ /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
8
+ ids = /* @__PURE__ */ new Set();
9
+ /**
10
+ * Cancels a single tracked timer.
11
+ *
12
+ * No-ops if the id is unknown (already cleared, fired, or never owned by this
13
+ * registry), so callers can clear defensively without guarding.
14
+ */
15
+ clear(id) {
16
+ if (this.ids.delete(id)) {
17
+ this.cancel(id);
18
+ }
19
+ }
20
+ /**
21
+ * Cancels every tracked timer. Call this from a controller's `disconnect()`
22
+ * to guarantee no timer outlives the element.
23
+ */
24
+ clearAll() {
25
+ for (const id of this.ids) {
26
+ this.cancel(id);
27
+ }
28
+ this.ids.clear();
29
+ }
30
+ /** Number of timers currently tracked (pending). */
31
+ get size() {
32
+ return this.ids.size;
33
+ }
34
+ };
35
+ var SafeTimeout = class extends TimerRegistry {
36
+ /**
37
+ * Schedules `callback` after `delay` ms and returns the timer id.
38
+ *
39
+ * The id is removed from the registry automatically when the timeout fires,
40
+ * so {@link TimerRegistry.size | size} reflects only still-pending timers.
41
+ */
42
+ set(callback, delay) {
43
+ const id = this.schedule(() => {
44
+ this.ids.delete(id);
45
+ callback();
46
+ }, delay);
47
+ this.ids.add(id);
48
+ return id;
49
+ }
50
+ schedule(callback, delay) {
51
+ return window.setTimeout(callback, delay);
52
+ }
53
+ cancel(id) {
54
+ window.clearTimeout(id);
55
+ }
56
+ };
57
+
58
+ // src/controllers/dirty_form_controller.ts
59
+ var visitCoordinators = /* @__PURE__ */ new WeakMap();
60
+ var TurboVisitCoordinator = class {
61
+ #document;
62
+ #participants = /* @__PURE__ */ new Set();
63
+ #onBeforeVisit = (event) => {
64
+ let blocked = event.defaultPrevented;
65
+ let message;
66
+ for (const participant of this.#participantsInDomOrder()) {
67
+ if (!participant.form.isConnected) continue;
68
+ const decision = participant.evaluate(event);
69
+ blocked ||= event.defaultPrevented;
70
+ if (decision === null) continue;
71
+ if (decision.kind === "block") {
72
+ blocked = true;
73
+ } else {
74
+ message ??= decision.message;
75
+ }
76
+ }
77
+ if (blocked) {
78
+ event.preventDefault();
79
+ return;
80
+ }
81
+ if (message === void 0) return;
82
+ if (!this.#document.defaultView?.confirm(message)) event.preventDefault();
83
+ };
84
+ constructor(document) {
85
+ this.#document = document;
86
+ }
87
+ add(participant) {
88
+ if (this.#participants.size === 0) {
89
+ this.#document.addEventListener("turbo:before-visit", this.#onBeforeVisit);
90
+ }
91
+ this.#participants.add(participant);
92
+ }
93
+ /** Removes a participant and reports whether the coordinator became empty. */
94
+ remove(participant) {
95
+ this.#participants.delete(participant);
96
+ if (this.#participants.size > 0) return false;
97
+ this.#document.removeEventListener("turbo:before-visit", this.#onBeforeVisit);
98
+ return true;
99
+ }
100
+ #participantsInDomOrder() {
101
+ return Array.from(this.#participants).filter(
102
+ (participant) => participant.form.isConnected && participant.form.ownerDocument === this.#document
103
+ ).sort((left, right) => {
104
+ const position = left.form.compareDocumentPosition(right.form);
105
+ return Number(Boolean(position & Node.DOCUMENT_POSITION_PRECEDING)) - Number(Boolean(position & Node.DOCUMENT_POSITION_FOLLOWING));
106
+ });
107
+ }
108
+ };
109
+ function registerVisitParticipant(document, participant) {
110
+ const coordinator = visitCoordinators.get(document) ?? new TurboVisitCoordinator(document);
111
+ visitCoordinators.set(document, coordinator);
112
+ coordinator.add(participant);
113
+ return () => {
114
+ if (coordinator.remove(participant)) visitCoordinators.delete(document);
115
+ };
116
+ }
4
117
  var DirtyFormController = class extends Controller {
5
118
  static values = {
6
119
  message: { type: String, default: "You have unsaved changes that will be lost." },
7
120
  confirmBridge: { type: Boolean, default: false }
8
121
  };
9
- static actions = ["markClean"];
122
+ static actions = ["markClean", "acceptRestore"];
10
123
  static events = ["dirty", "guard"];
11
124
  #baseline = "";
12
125
  #dirty = false;
13
126
  #beforeunloadBound = false;
14
- /** True between a form `submit` and its `turbo:submit-end`, suppressing the guard. */
127
+ #timeouts = new SafeTimeout();
128
+ /** Whether the active submission still represents the form's current values. */
15
129
  #submitting = false;
130
+ #submitAttempt = null;
131
+ #submittedBaseline = null;
132
+ #activeSubmission = null;
133
+ #unregisterVisit = null;
16
134
  #onFieldChange = () => {
17
135
  this.#submitting = false;
18
136
  this.#evaluate();
19
137
  };
20
- #onSubmit = () => {
138
+ #onSubmit = (event) => {
139
+ const attempt = { event, snapshot: this.#serialize() };
140
+ this.#submitAttempt = attempt;
21
141
  this.#submitting = true;
142
+ this.#timeouts.set(() => {
143
+ if (this.#submitAttempt !== attempt) return;
144
+ this.#submitting = false;
145
+ }, 0);
22
146
  };
23
- #onBeforeVisit = (event) => {
24
- this.#guardVisit(event);
147
+ #onFormData = () => {
148
+ if (this.#submitAttempt === null) return;
149
+ this.#submitAttempt.snapshot = this.#serialize();
150
+ };
151
+ #onSubmitStart = (event) => {
152
+ const detail = event.detail;
153
+ this.#activeSubmission = detail?.formSubmission ?? event;
154
+ this.#submittedBaseline = this.#submitAttempt?.snapshot ?? this.#serialize();
155
+ this.#submitAttempt = null;
156
+ this.#submitting = this.#serialize() === this.#submittedBaseline;
25
157
  };
26
158
  #onSubmitEnd = (event) => {
159
+ const detail = event.detail;
160
+ if (detail?.formSubmission !== void 0 && (this.#activeSubmission === null || detail.formSubmission !== this.#activeSubmission)) {
161
+ return;
162
+ }
163
+ const submittedBaseline = this.#submittedBaseline;
27
164
  this.#submitting = false;
28
- const success = event.detail?.success;
29
- if (success !== false) this.markClean();
165
+ this.#submittedBaseline = null;
166
+ this.#activeSubmission = null;
167
+ if (detail?.success === false) return;
168
+ if (submittedBaseline === null) {
169
+ this.markClean();
170
+ return;
171
+ }
172
+ this.#baseline = submittedBaseline;
173
+ this.#evaluate();
30
174
  };
31
175
  #onBeforeUnload = (event) => {
32
- if (!this.#dirty || this.#submitting) return;
176
+ if (!this.#dirty || this.#guardSuppressed()) return;
33
177
  event.preventDefault();
34
178
  event.returnValue = this.messageValue;
35
179
  };
36
180
  connect() {
181
+ this.#resetSubmission();
37
182
  this.#baseline = this.#serialize();
183
+ this.#setDirty(false);
38
184
  this.element.removeAttribute("data-dirty");
39
185
  this.element.addEventListener("input", this.#onFieldChange);
40
186
  this.element.addEventListener("change", this.#onFieldChange);
41
187
  this.element.addEventListener("submit", this.#onSubmit);
188
+ this.element.addEventListener("formdata", this.#onFormData);
189
+ this.element.addEventListener("turbo:submit-start", this.#onSubmitStart);
42
190
  this.element.addEventListener("turbo:submit-end", this.#onSubmitEnd);
43
- document.addEventListener("turbo:before-visit", this.#onBeforeVisit);
191
+ this.#unregisterVisit = registerVisitParticipant(this.element.ownerDocument, {
192
+ form: this.element,
193
+ evaluate: (event) => this.#evaluateVisit(event)
194
+ });
44
195
  }
45
196
  disconnect() {
46
197
  this.element.removeEventListener("input", this.#onFieldChange);
47
198
  this.element.removeEventListener("change", this.#onFieldChange);
48
199
  this.element.removeEventListener("submit", this.#onSubmit);
200
+ this.element.removeEventListener("formdata", this.#onFormData);
201
+ this.element.removeEventListener("turbo:submit-start", this.#onSubmitStart);
49
202
  this.element.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
50
- document.removeEventListener("turbo:before-visit", this.#onBeforeVisit);
203
+ this.#unregisterVisit?.();
204
+ this.#unregisterVisit = null;
51
205
  this.#unbindBeforeUnload();
206
+ this.#resetSubmission();
52
207
  }
53
208
  /** Re-baselines to the current values and clears the dirty state (e.g. after a save). */
54
209
  markClean() {
55
210
  this.#baseline = this.#serialize();
56
211
  this.#setDirty(false);
57
212
  }
213
+ /** Adopts restored values without erasing an already-dirty user revision. */
214
+ acceptRestore() {
215
+ if (this.#dirty) return;
216
+ this.markClean();
217
+ }
58
218
  /** Recomputes dirty against the baseline and flips state when it changes. */
59
219
  #evaluate() {
60
220
  this.#setDirty(this.#serialize() !== this.#baseline);
@@ -71,21 +231,14 @@ var DirtyFormController = class extends Controller {
71
231
  }
72
232
  this.dispatch("dirty", { detail: { dirty } });
73
233
  }
74
- /** Guards a Turbo visit while dirty: consumer cancel → confirmBridge → confirm. */
75
- #guardVisit(event) {
76
- if (!this.#dirty || this.#submitting) return;
234
+ /** Evaluates this form for the document-level Turbo visit coordinator. */
235
+ #evaluateVisit(event) {
236
+ if (!this.#dirty || this.#guardSuppressed()) return null;
77
237
  const guard = this.dispatch("guard", { detail: { event }, cancelable: true });
78
- if (guard.defaultPrevented) {
79
- event.preventDefault();
80
- return;
81
- }
82
- if (this.confirmBridgeValue) {
83
- event.preventDefault();
84
- return;
85
- }
86
- if (!window.confirm(this.messageValue)) {
87
- event.preventDefault();
88
- }
238
+ if (guard.defaultPrevented) return { kind: "block" };
239
+ if (!this.element.isConnected || !this.#dirty || this.#guardSuppressed()) return null;
240
+ if (this.confirmBridgeValue) return { kind: "block" };
241
+ return { kind: "confirm", message: this.messageValue };
89
242
  }
90
243
  #bindBeforeUnload() {
91
244
  if (this.#beforeunloadBound) return;
@@ -97,6 +250,16 @@ var DirtyFormController = class extends Controller {
97
250
  window.removeEventListener("beforeunload", this.#onBeforeUnload);
98
251
  this.#beforeunloadBound = false;
99
252
  }
253
+ #guardSuppressed() {
254
+ return this.#submitting && (this.#submitAttempt === null || !this.#submitAttempt.event.defaultPrevented);
255
+ }
256
+ #resetSubmission() {
257
+ this.#timeouts.clearAll();
258
+ this.#submitting = false;
259
+ this.#submitAttempt = null;
260
+ this.#submittedBaseline = null;
261
+ this.#activeSubmission = null;
262
+ }
100
263
  /** Stable serialization of the form's controls for change detection. */
101
264
  #serialize() {
102
265
  const parts = [];
@@ -104,15 +267,15 @@ var DirtyFormController = class extends Controller {
104
267
  const name = this.#nameOf(el);
105
268
  if (name === null) continue;
106
269
  if (el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio")) {
107
- parts.push(`${name}:${el.checked ? 1 : 0}`);
270
+ parts.push([el.type, name, el.value, el.checked ? 1 : 0]);
108
271
  } else if (el instanceof HTMLSelectElement) {
109
- const value = el.multiple ? Array.from(el.selectedOptions).map((o) => o.value).join(",") : el.value;
110
- parts.push(`${name}:${value}`);
272
+ const value = el.multiple ? Array.from(el.selectedOptions).map((option) => option.value) : el.value;
273
+ parts.push([el.type, name, value]);
111
274
  } else if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
112
- parts.push(`${name}:${el.value}`);
275
+ parts.push([el.type, name, el.value]);
113
276
  }
114
277
  }
115
- return parts.join("|");
278
+ return JSON.stringify(parts);
116
279
  }
117
280
  /** A stable key for a control, or null for elements without value semantics. */
118
281
  #nameOf(el) {