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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +62 -0
- data/dist/controllers/alert_dialog_controller.js +75 -4
- data/dist/controllers/avatar_controller.js +47 -5
- data/dist/controllers/character_counter_controller.js +338 -63
- data/dist/controllers/command_palette_controller.js +75 -4
- data/dist/controllers/conditional_fields_controller.js +345 -51
- data/dist/controllers/confirm_controller.js +75 -4
- data/dist/controllers/dialog_controller.js +75 -4
- data/dist/controllers/direct_upload_controller.js +201 -45
- data/dist/controllers/dirty_form_controller.js +192 -29
- data/dist/controllers/dismissible_controller.js +83 -18
- data/dist/controllers/drawer_controller.js +75 -4
- data/dist/controllers/focus_controller.js +75 -4
- data/dist/controllers/form_field_controller.js +280 -62
- data/dist/controllers/form_validation_controller.js +208 -83
- data/dist/controllers/number_input_controller.js +47 -5
- data/dist/controllers/overflow_menu_controller.js +2 -1
- data/dist/controllers/pagination_controller.js +2 -1
- data/dist/controllers/persist_controller.js +432 -122
- data/dist/controllers/popover_controller.js +77 -4
- data/dist/controllers/rating_controller.js +9 -5
- data/dist/controllers/scroll_area_controller.js +462 -162
- data/dist/controllers/separator_controller.js +354 -38
- data/dist/controllers/sidebar_controller.js +83 -10
- data/dist/controllers/submit_once_controller.js +399 -121
- data/dist/controllers/theme_controller.js +8 -6
- data/dist/controllers/tree_view_controller.js +2 -1
- data/dist/index.js +2387 -861
- data/lib/stimeo/ui/version.rb +1 -1
- 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"
|
|
107
|
+
static targets = ["list", "row"];
|
|
62
108
|
static values = {
|
|
63
|
-
announce: { type: Boolean, default: true },
|
|
64
109
|
removeOnDone: { type: Boolean, default: false },
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
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),
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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.#
|
|
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
|
-
/**
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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.
|
|
202
|
+
this.#announce(this.announceErrorTextValue, name, row);
|
|
135
203
|
this.dispatch("error", { detail: { id, error } });
|
|
204
|
+
return true;
|
|
136
205
|
}
|
|
137
|
-
/**
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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.
|
|
197
|
-
* with `closest()` from the target itself, so the chatty `progress`
|
|
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.
|
|
357
|
+
if (this.#scopeSelector.length === 0) return true;
|
|
202
358
|
const target = event.target;
|
|
203
|
-
return target instanceof Element && target.closest(this
|
|
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
|
-
|
|
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
|
-
#
|
|
24
|
-
this.#
|
|
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
|
-
|
|
29
|
-
|
|
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.#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
75
|
-
#
|
|
76
|
-
if (!this.#dirty || this.#
|
|
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
|
-
|
|
80
|
-
|
|
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(
|
|
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((
|
|
110
|
-
parts.push(
|
|
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(
|
|
275
|
+
parts.push([el.type, name, el.value]);
|
|
113
276
|
}
|
|
114
277
|
}
|
|
115
|
-
return
|
|
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) {
|