stimeo-ui 0.7.0 → 0.8.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/auto_submit_controller.js +94 -16
- data/dist/controllers/carousel_controller.js +451 -100
- data/dist/controllers/currency_input_controller.js +305 -74
- data/dist/controllers/direct_upload_controller.js +12 -2
- data/dist/controllers/file_dropzone_controller.js +386 -63
- data/dist/controllers/flash_controller.js +3 -1
- data/dist/controllers/frame_loading_controller.js +2 -1
- data/dist/controllers/input_mask_controller.js +251 -76
- data/dist/controllers/nested_form_controller.js +450 -42
- data/dist/controllers/otp_controller.js +472 -114
- data/dist/controllers/spinner_controller.js +7 -5
- data/dist/controllers/submit_once_controller.js +3 -1
- data/dist/controllers/textarea_autosize_controller.js +131 -3
- data/dist/index.js +1927 -607
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -1,36 +1,179 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
3
|
// src/controllers/file_dropzone_controller.ts
|
|
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
|
+
|
|
51
|
+
// src/utils/detach_gate.ts
|
|
52
|
+
var DetachGate = class _DetachGate {
|
|
53
|
+
/** Set while a probe is queued, waiting for a reconnect to cancel it. */
|
|
54
|
+
#pending = false;
|
|
55
|
+
/**
|
|
56
|
+
* True while a probe is queued — the last disconnect was ambiguous and no
|
|
57
|
+
* reconnect has cancelled it yet. Read it from `connect()` to tell the
|
|
58
|
+
* reconnect half of an in-page move from a first connect: a controller whose
|
|
59
|
+
* initialisation restarts a measurement (a min-duration floor, an elapsed
|
|
60
|
+
* counter) must skip it for the move, where nothing actually restarted.
|
|
61
|
+
*/
|
|
62
|
+
get pending() {
|
|
63
|
+
return this.#pending;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* True when the disconnect is definitely a real detach — the element left
|
|
67
|
+
* the document, or `data-controller` no longer lists the identifier. False
|
|
68
|
+
* means ambiguous (in-page move or observed-root exit), NOT "alive".
|
|
69
|
+
*/
|
|
70
|
+
static isDetached(host) {
|
|
71
|
+
if (!host.element.isConnected) return true;
|
|
72
|
+
const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
|
|
73
|
+
return !tokens.includes(host.identifier);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Call from `disconnect()`: runs `teardown` synchronously on a definite
|
|
77
|
+
* detach (fast path), otherwise defers it one microtask — a reconnect
|
|
78
|
+
* ({@link cancel} from `connect()`) keeps the state, no reconnect runs it.
|
|
79
|
+
* One microtask is the whole probe window: Stimulus reconnects a moved
|
|
80
|
+
* element within the same mutation batch, before the checkpoint drains.
|
|
81
|
+
*/
|
|
82
|
+
disconnected(host, teardown) {
|
|
83
|
+
if (_DetachGate.isDetached(host)) {
|
|
84
|
+
this.#pending = false;
|
|
85
|
+
teardown();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
this.#pending = true;
|
|
89
|
+
queueMicrotask(() => {
|
|
90
|
+
if (!this.#pending) return;
|
|
91
|
+
this.#pending = false;
|
|
92
|
+
teardown();
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Disarms a pending probe. Call from `connect()` (the reconnect that proves
|
|
97
|
+
* an in-page move) and from the head of any teardown path not routed through
|
|
98
|
+
* {@link disconnected} (disabled-toggle, Escape), so an orphaned probe can
|
|
99
|
+
* never run the teardown a second time.
|
|
100
|
+
*/
|
|
101
|
+
cancel() {
|
|
102
|
+
this.#pending = false;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// src/utils/focus_candidate.ts
|
|
107
|
+
function inheritsFieldsetDisabled(control) {
|
|
108
|
+
let fieldset = control.closest("fieldset[disabled]");
|
|
109
|
+
while (fieldset) {
|
|
110
|
+
const legend = Array.from(fieldset.children).find((child) => child.tagName === "LEGEND");
|
|
111
|
+
if (!legend?.contains(control)) return true;
|
|
112
|
+
fieldset = fieldset.parentElement?.closest("fieldset[disabled]") ?? null;
|
|
113
|
+
}
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// src/controllers/file_dropzone_controller.ts
|
|
118
|
+
var DRAGOVER_ATTRIBUTE = "data-dragover";
|
|
119
|
+
var INVALID_ATTRIBUTE = "data-stimeo--file-dropzone-invalid";
|
|
4
120
|
var FileDropzoneController = class extends Controller {
|
|
5
|
-
static targets = [
|
|
121
|
+
static targets = [
|
|
122
|
+
"zone",
|
|
123
|
+
"trigger",
|
|
124
|
+
"input",
|
|
125
|
+
"list",
|
|
126
|
+
"item",
|
|
127
|
+
"itemTemplate",
|
|
128
|
+
"name",
|
|
129
|
+
"thumb",
|
|
130
|
+
"remove"
|
|
131
|
+
];
|
|
6
132
|
static values = {
|
|
7
133
|
maxSize: { type: Number, default: 0 },
|
|
8
134
|
maxFiles: { type: Number, default: 0 },
|
|
9
|
-
|
|
135
|
+
allowDuplicates: { type: Boolean, default: false },
|
|
136
|
+
announceDragText: { type: String, default: "" },
|
|
137
|
+
announceAddedText: { type: String, default: "" },
|
|
138
|
+
announceRemovedText: { type: String, default: "" },
|
|
139
|
+
announceRejectedTypeText: { type: String, default: "" },
|
|
140
|
+
announceRejectedSizeText: { type: String, default: "" },
|
|
141
|
+
announceRejectedDuplicateText: { type: String, default: "" },
|
|
142
|
+
announceRejectedCountText: { type: String, default: "" }
|
|
10
143
|
};
|
|
11
144
|
static actions = ["onChange", "onDragLeave", "onDragOver", "onDrop", "openDialog"];
|
|
12
|
-
static events = ["change", "reject"];
|
|
145
|
+
static events = ["change", "reject", "reconcile"];
|
|
13
146
|
/** Selected files paired with their rendered item and any preview objectURL. */
|
|
14
147
|
#entries = [];
|
|
15
|
-
/**
|
|
16
|
-
#
|
|
17
|
-
/**
|
|
148
|
+
/** Whether a drag is currently over the zone; the source for `data-dragover`. */
|
|
149
|
+
#dragging = false;
|
|
150
|
+
/** Whether this connection already reported its unusable item template. */
|
|
151
|
+
#warnedTemplate = false;
|
|
152
|
+
#gate = new DetachGate();
|
|
153
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
154
|
+
/** Subscribes to the Turbo cache rewind and re-arms the template diagnostic. */
|
|
18
155
|
connect() {
|
|
19
|
-
this.#
|
|
20
|
-
|
|
156
|
+
this.#gate.cancel();
|
|
157
|
+
this.#warnedTemplate = false;
|
|
158
|
+
this.#beforeCache.activate();
|
|
21
159
|
}
|
|
22
|
-
/**
|
|
160
|
+
/**
|
|
161
|
+
* Releases the delegated listeners. The selection itself survives an in-page
|
|
162
|
+
* move and is released only once the gate proves a real detach — revoking a
|
|
163
|
+
* preview URL on a move would leave a live item pointing at a dead `blob:`.
|
|
164
|
+
*/
|
|
23
165
|
disconnect() {
|
|
24
|
-
this.#connected = false;
|
|
25
166
|
for (const list of this.listTargets) list.removeEventListener("click", this.#onItemClick);
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
this.#entries.length = 0;
|
|
167
|
+
this.#beforeCache.deactivate();
|
|
168
|
+
this.#gate.disconnected(this, () => this.#teardown());
|
|
30
169
|
}
|
|
31
|
-
/**
|
|
170
|
+
/**
|
|
171
|
+
* Binds removal and restores client-only previews for every list this controller
|
|
172
|
+
* renders into — the one present at connect and any Turbo puts in its place.
|
|
173
|
+
* This is the only place the listener is attached, so the pair with
|
|
174
|
+
* {@link listTargetDisconnected} keeps it from outliving the element it is on.
|
|
175
|
+
*/
|
|
32
176
|
listTargetConnected(list) {
|
|
33
|
-
if (!this.#connected) return;
|
|
34
177
|
this.#bindList(list);
|
|
35
178
|
}
|
|
36
179
|
/** Releases only the list target that actually disconnected. */
|
|
@@ -46,82 +189,147 @@ var FileDropzoneController = class extends Controller {
|
|
|
46
189
|
}
|
|
47
190
|
/** Opens the native file dialog. Bound via `data-action` (trigger click). */
|
|
48
191
|
openDialog() {
|
|
192
|
+
if (this.#isDisabled) return;
|
|
49
193
|
this.inputTarget.click();
|
|
50
194
|
}
|
|
51
|
-
/**
|
|
195
|
+
/**
|
|
196
|
+
* Adds the files chosen through the native dialog. The input holds only what
|
|
197
|
+
* the dialog just returned, so the accepted set is written back over it once
|
|
198
|
+
* the batch is validated.
|
|
199
|
+
*/
|
|
52
200
|
onChange() {
|
|
53
|
-
|
|
54
|
-
|
|
201
|
+
const files = this.inputTarget.files;
|
|
202
|
+
if (files) this.#addFiles(files);
|
|
203
|
+
else this.#syncInput();
|
|
55
204
|
}
|
|
56
|
-
/**
|
|
205
|
+
/**
|
|
206
|
+
* Marks the zone as a drop target and announces the affordance once per drag.
|
|
207
|
+
* Bound via `data-action` (dragover). Leaving the default alone is what refuses
|
|
208
|
+
* the drop, so a disabled field and a drag an inner dropzone already claimed
|
|
209
|
+
* both fall through untouched.
|
|
210
|
+
*/
|
|
57
211
|
onDragOver(event) {
|
|
212
|
+
if (event.defaultPrevented || this.#isDisabled) return;
|
|
58
213
|
event.preventDefault();
|
|
59
|
-
if (this
|
|
60
|
-
this.#
|
|
214
|
+
if (this.#dragging) return;
|
|
215
|
+
this.#dragging = true;
|
|
216
|
+
if (this.hasZoneTarget) this.zoneTarget.setAttribute(DRAGOVER_ATTRIBUTE, "");
|
|
217
|
+
announce(fillTemplate(this.announceDragTextValue, { total: this.#entries.length }));
|
|
61
218
|
}
|
|
62
|
-
/**
|
|
63
|
-
|
|
64
|
-
|
|
219
|
+
/**
|
|
220
|
+
* Clears the drag-over flag when the pointer leaves the zone. Bound via
|
|
221
|
+
* `data-action` (dragleave). `dragleave` bubbles from every descendant the
|
|
222
|
+
* pointer crosses, so the flag only drops when the element being entered is
|
|
223
|
+
* outside the zone (or there is none, the pointer having left the window).
|
|
224
|
+
*/
|
|
225
|
+
onDragLeave(event) {
|
|
226
|
+
const next = event.relatedTarget;
|
|
227
|
+
if (this.hasZoneTarget && next instanceof Node && this.zoneTarget.contains(next)) return;
|
|
228
|
+
this.#endDrag();
|
|
65
229
|
}
|
|
66
|
-
/** Accepts dropped files, clearing the drag-over state. */
|
|
230
|
+
/** Accepts dropped files, clearing the drag-over state. Bound via `data-action` (drop). */
|
|
67
231
|
onDrop(event) {
|
|
232
|
+
if (event.defaultPrevented) return;
|
|
233
|
+
this.#endDrag();
|
|
234
|
+
if (this.#isDisabled) return;
|
|
68
235
|
event.preventDefault();
|
|
69
|
-
if (this.hasZoneTarget) this.zoneTarget.removeAttribute("data-dragover");
|
|
70
236
|
if (event.dataTransfer?.files) this.#addFiles(event.dataTransfer.files);
|
|
71
237
|
}
|
|
72
238
|
/**
|
|
73
239
|
* Removes the file whose remove button was clicked. Delegated on the list
|
|
74
240
|
* container rather than bound per item via `data-action`, so it works the instant
|
|
75
241
|
* an item is appended without waiting on Stimulus to wire a freshly created element.
|
|
242
|
+
* Only the item's declared `remove` target counts, so an authored second control
|
|
243
|
+
* inside an item does what it says instead of silently discarding the file. The
|
|
244
|
+
* button has to belong to a tracked item, which is what makes a list this
|
|
245
|
+
* controller no longer renders into inert — its items moved out with it.
|
|
76
246
|
*/
|
|
77
247
|
#onItemClick = (event) => {
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
248
|
+
const button = event.target.closest(
|
|
249
|
+
'button[data-stimeo--file-dropzone-target~="remove"]'
|
|
250
|
+
);
|
|
83
251
|
const index = this.#entries.findIndex((entry) => entry.item.contains(button));
|
|
84
252
|
if (index !== -1) this.#removeAt(index);
|
|
85
253
|
};
|
|
86
|
-
/** Validates each incoming file
|
|
254
|
+
/** Validates each incoming file, renders the accepted ones, and reports the batch. */
|
|
87
255
|
#addFiles(files) {
|
|
88
|
-
|
|
89
|
-
if (this.hasZoneTarget) this.zoneTarget.removeAttribute(
|
|
256
|
+
this.#rehome();
|
|
257
|
+
if (this.hasZoneTarget) this.zoneTarget.removeAttribute(INVALID_ATTRIBUTE);
|
|
258
|
+
const rejected = /* @__PURE__ */ new Map();
|
|
259
|
+
const turnedAway = [];
|
|
260
|
+
let addedName = "";
|
|
261
|
+
let added = 0;
|
|
90
262
|
for (const file of Array.from(files)) {
|
|
91
263
|
const reason = this.#validate(file);
|
|
92
|
-
if (reason) {
|
|
93
|
-
if (this.hasZoneTarget)
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
264
|
+
if (reason !== null) {
|
|
265
|
+
if (this.hasZoneTarget) this.zoneTarget.setAttribute(INVALID_ATTRIBUTE, "");
|
|
266
|
+
const batch = rejected.get(reason);
|
|
267
|
+
if (batch) batch.count += 1;
|
|
268
|
+
else rejected.set(reason, { name: file.name, count: 1 });
|
|
269
|
+
turnedAway.push({ file, reason });
|
|
98
270
|
continue;
|
|
99
271
|
}
|
|
100
|
-
this.#appendFile(file);
|
|
101
|
-
|
|
102
|
-
|
|
272
|
+
if (!this.#appendFile(file)) continue;
|
|
273
|
+
if (added === 0) addedName = file.name;
|
|
274
|
+
added += 1;
|
|
275
|
+
}
|
|
276
|
+
this.#syncInput();
|
|
277
|
+
if (added > 0) {
|
|
278
|
+
this.#announce(this.announceAddedTextValue, addedName, added);
|
|
279
|
+
this.dispatch("change", { detail: { files: this.#files } });
|
|
280
|
+
}
|
|
281
|
+
for (const { file, reason } of turnedAway) {
|
|
282
|
+
this.dispatch("reject", { detail: { file, reason } });
|
|
283
|
+
}
|
|
284
|
+
for (const [reason, batch] of rejected) {
|
|
285
|
+
this.#announce(this.#rejectText(reason), batch.name, batch.count);
|
|
103
286
|
}
|
|
104
|
-
if (changed) this.dispatch("change", { detail: { files: this.#files } });
|
|
105
287
|
}
|
|
106
|
-
/**
|
|
288
|
+
/**
|
|
289
|
+
* Returns the rejection reason for `file`, or `null` when it is acceptable.
|
|
290
|
+
* The file's own defects are decided first, so a full list still tells the user
|
|
291
|
+
* which files it would never have taken.
|
|
292
|
+
*/
|
|
107
293
|
#validate(file) {
|
|
108
|
-
const limit = this.#effectiveMaxFiles;
|
|
109
|
-
if (limit > 0 && this.#entries.length >= limit) return "count";
|
|
110
294
|
if (!this.#matchesAccept(file)) return "type";
|
|
111
295
|
if (this.maxSizeValue > 0 && file.size > this.maxSizeValue) return "size";
|
|
296
|
+
if (!this.allowDuplicatesValue && this.#entries.some((entry) => this.#isSame(entry.file, file))) {
|
|
297
|
+
return "duplicate";
|
|
298
|
+
}
|
|
299
|
+
const limit = this.#effectiveMaxFiles;
|
|
300
|
+
if (limit > 0 && this.#entries.length >= limit) return "count";
|
|
112
301
|
return null;
|
|
113
302
|
}
|
|
114
|
-
/**
|
|
303
|
+
/**
|
|
304
|
+
* Whether two files are the same selection. `File` objects from separate picks
|
|
305
|
+
* are never the same reference, so identity is the triple the platform exposes.
|
|
306
|
+
*/
|
|
307
|
+
#isSame(a, b) {
|
|
308
|
+
return a.name === b.name && a.size === b.size && a.lastModified === b.lastModified;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Builds one preview item (name, optional thumbnail, remove button) and reports
|
|
312
|
+
* whether it was rendered.
|
|
313
|
+
*/
|
|
115
314
|
#appendFile(file) {
|
|
116
|
-
if (!this.
|
|
315
|
+
if (!this.hasListTarget) return this.#warnTemplate('a "list" target to render into');
|
|
316
|
+
if (!this.hasItemTemplateTarget) return this.#warnTemplate('an "itemTemplate" target');
|
|
117
317
|
const fragment = this.itemTemplateTarget.content.cloneNode(true);
|
|
118
|
-
const item = fragment
|
|
119
|
-
const name = fragment
|
|
120
|
-
const thumb = fragment
|
|
121
|
-
const button = fragment.querySelector(
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
318
|
+
const item = this.#slot(fragment, "item");
|
|
319
|
+
const name = this.#slot(fragment, "name");
|
|
320
|
+
const thumb = this.#slot(fragment, "thumb");
|
|
321
|
+
const button = fragment.querySelector(
|
|
322
|
+
'button[data-stimeo--file-dropzone-target~="remove"]'
|
|
323
|
+
);
|
|
324
|
+
const removeName = button?.getAttribute("aria-label")?.trim() ?? "";
|
|
325
|
+
if (!item) return this.#warnTemplate('an "item" root');
|
|
326
|
+
if (!name) return this.#warnTemplate('a "name" element');
|
|
327
|
+
if (!button) return this.#warnTemplate('a "remove" target <button>');
|
|
328
|
+
if (removeName === "") {
|
|
329
|
+
return this.#warnTemplate('a non-empty aria-label on its "remove" target');
|
|
330
|
+
}
|
|
331
|
+
name.textContent = file.name;
|
|
332
|
+
button.setAttribute("aria-label", fillTemplate(removeName, { name: file.name }));
|
|
125
333
|
let url;
|
|
126
334
|
if (thumb && file.type.startsWith("image/")) {
|
|
127
335
|
url = URL.createObjectURL(file);
|
|
@@ -133,6 +341,29 @@ var FileDropzoneController = class extends Controller {
|
|
|
133
341
|
}
|
|
134
342
|
this.listTarget.appendChild(fragment);
|
|
135
343
|
this.#entries.push({ file, item, url });
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
/** Resolves one declared part inside a cloned item template. */
|
|
347
|
+
#slot(fragment, name) {
|
|
348
|
+
return fragment.querySelector(`[data-stimeo--file-dropzone-target~="${name}"]`);
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Reports an unusable item template to the author, once per connection.
|
|
352
|
+
*
|
|
353
|
+
* The addition itself stays a no-op — nothing about the selection, the native
|
|
354
|
+
* input, the announcements, or the events changes. Without this line the only
|
|
355
|
+
* symptom is a picker that accepts no file at all, and the causes the Inspector
|
|
356
|
+
* cannot see statically (a server-rendered template, a name that renders empty
|
|
357
|
+
* from a missing translation) would have no diagnostic anywhere.
|
|
358
|
+
*/
|
|
359
|
+
#warnTemplate(missing) {
|
|
360
|
+
if (!this.#warnedTemplate) {
|
|
361
|
+
this.#warnedTemplate = true;
|
|
362
|
+
console.warn(
|
|
363
|
+
`Stimeo UI: "${this.identifier}" added no file because its item template lacks ${missing}.`
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
return false;
|
|
136
367
|
}
|
|
137
368
|
/** Removes entry `index`, revokes its preview, and re-homes focus. */
|
|
138
369
|
#removeAt(index) {
|
|
@@ -141,7 +372,8 @@ var FileDropzoneController = class extends Controller {
|
|
|
141
372
|
if (entry.url) URL.revokeObjectURL(entry.url);
|
|
142
373
|
entry.item.remove();
|
|
143
374
|
this.#entries.splice(index, 1);
|
|
144
|
-
this.#
|
|
375
|
+
this.#syncInput();
|
|
376
|
+
this.#announce(this.announceRemovedTextValue, entry.file.name, 1);
|
|
145
377
|
this.dispatch("change", { detail: { files: this.#files } });
|
|
146
378
|
const buttons = this.#removeButtons;
|
|
147
379
|
if (buttons.length === 0) {
|
|
@@ -164,18 +396,109 @@ var FileDropzoneController = class extends Controller {
|
|
|
164
396
|
return type === token;
|
|
165
397
|
});
|
|
166
398
|
}
|
|
167
|
-
/**
|
|
168
|
-
|
|
169
|
-
|
|
399
|
+
/**
|
|
400
|
+
* Sends one consumer-worded message to the page's shared announcer. `{name}` is
|
|
401
|
+
* the file the message is about, `{count}` how many files it covers, and
|
|
402
|
+
* `{total}` how many are selected once the batch has settled.
|
|
403
|
+
*/
|
|
404
|
+
#announce(template, name, count) {
|
|
405
|
+
announce(fillTemplate(template, { name, count, total: this.#entries.length }));
|
|
406
|
+
}
|
|
407
|
+
/** The consumer's wording for one rejection reason. */
|
|
408
|
+
#rejectText(reason) {
|
|
409
|
+
switch (reason) {
|
|
410
|
+
case "type":
|
|
411
|
+
return this.announceRejectedTypeTextValue;
|
|
412
|
+
case "size":
|
|
413
|
+
return this.announceRejectedSizeTextValue;
|
|
414
|
+
case "duplicate":
|
|
415
|
+
return this.announceRejectedDuplicateTextValue;
|
|
416
|
+
case "count":
|
|
417
|
+
return this.announceRejectedCountTextValue;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Mirrors the accepted set onto the native input, so a plain form submit carries
|
|
422
|
+
* the dropped files. Skipped where `DataTransfer` cannot be constructed: the
|
|
423
|
+
* widget keeps working and the consumer still receives every `File` on `change`.
|
|
424
|
+
*/
|
|
425
|
+
#syncInput() {
|
|
426
|
+
if (!this.hasInputTarget) return;
|
|
427
|
+
const transfer = this.#newTransfer();
|
|
428
|
+
if (!transfer) return;
|
|
429
|
+
for (const entry of this.#entries) transfer.items.add(entry.file);
|
|
430
|
+
this.inputTarget.files = transfer.files;
|
|
431
|
+
}
|
|
432
|
+
/** A usable empty `DataTransfer`, or `null` where the platform has none. */
|
|
433
|
+
#newTransfer() {
|
|
434
|
+
try {
|
|
435
|
+
return new DataTransfer();
|
|
436
|
+
} catch {
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Moves surviving preview items back under the current list. A morph that
|
|
442
|
+
* empties the list in place leaves the selection with no rendering, and the
|
|
443
|
+
* files it holds cannot be rebuilt from the DOM, so the items are re-homed
|
|
444
|
+
* rather than forgotten.
|
|
445
|
+
*/
|
|
446
|
+
#rehome() {
|
|
447
|
+
if (!this.hasListTarget) return;
|
|
448
|
+
for (const entry of this.#entries) {
|
|
449
|
+
if (!this.listTarget.contains(entry.item)) this.listTarget.appendChild(entry.item);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
/** Drops the drag-over state, whether the drag ended in a drop or left the zone. */
|
|
453
|
+
#endDrag() {
|
|
454
|
+
this.#dragging = false;
|
|
455
|
+
if (this.hasZoneTarget) this.zoneTarget.removeAttribute(DRAGOVER_ATTRIBUTE);
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Discards the selection and every state attribute this controller wrote, so
|
|
459
|
+
* neither a cached snapshot nor a stranded subtree keeps items whose files are
|
|
460
|
+
* gone. Silent: `change` means a selection the user changed, and the cache
|
|
461
|
+
* rewind reports itself as `reconcile` instead.
|
|
462
|
+
*/
|
|
463
|
+
#rewindForCache() {
|
|
464
|
+
const had = this.#entries.length > 0;
|
|
465
|
+
this.#reset();
|
|
466
|
+
if (had) this.dispatch("reconcile", { detail: { files: this.#files } });
|
|
467
|
+
}
|
|
468
|
+
#reset() {
|
|
469
|
+
for (const entry of this.#entries) {
|
|
470
|
+
if (entry.url) URL.revokeObjectURL(entry.url);
|
|
471
|
+
entry.item.remove();
|
|
472
|
+
}
|
|
473
|
+
this.#entries.length = 0;
|
|
474
|
+
this.#syncInput();
|
|
475
|
+
this.#endDrag();
|
|
476
|
+
if (this.hasZoneTarget) this.zoneTarget.removeAttribute(INVALID_ATTRIBUTE);
|
|
477
|
+
}
|
|
478
|
+
/** Releases the selection once the disconnect is known to be a real detach. */
|
|
479
|
+
#teardown() {
|
|
480
|
+
this.#gate.cancel();
|
|
481
|
+
this.#reset();
|
|
482
|
+
}
|
|
483
|
+
/** Whether the field refuses input, natively or through an ancestor `fieldset`. */
|
|
484
|
+
get #isDisabled() {
|
|
485
|
+
return this.inputTarget.disabled || inheritsFieldsetDisabled(this.inputTarget);
|
|
170
486
|
}
|
|
171
487
|
/** Effective file cap: `maxFiles`, or 1 when the input is single-select. */
|
|
172
488
|
get #effectiveMaxFiles() {
|
|
173
489
|
if (this.maxFilesValue > 0) return this.maxFilesValue;
|
|
174
490
|
return this.inputTarget.multiple ? 0 : 1;
|
|
175
491
|
}
|
|
176
|
-
/** The remove
|
|
492
|
+
/** The declared remove button of each rendered item, in selection order. */
|
|
177
493
|
get #removeButtons() {
|
|
178
|
-
|
|
494
|
+
const buttons = [];
|
|
495
|
+
for (const entry of this.#entries) {
|
|
496
|
+
const button = entry.item.querySelector(
|
|
497
|
+
'button[data-stimeo--file-dropzone-target~="remove"]'
|
|
498
|
+
);
|
|
499
|
+
if (button) buttons.push(button);
|
|
500
|
+
}
|
|
501
|
+
return buttons;
|
|
179
502
|
}
|
|
180
503
|
/** The accepted files in selection order. */
|
|
181
504
|
get #files() {
|
|
@@ -129,7 +129,7 @@ var FlashController = class extends Controller {
|
|
|
129
129
|
max: { type: Number, default: 0 }
|
|
130
130
|
};
|
|
131
131
|
static actions = ["dismiss"];
|
|
132
|
-
static events = ["show", "dismiss"];
|
|
132
|
+
static events = ["show", "dismiss", "reconcile"];
|
|
133
133
|
#timers = new SafeTimeout();
|
|
134
134
|
#observer = null;
|
|
135
135
|
/** Whether the controller is between `connect()` and `disconnect()`. */
|
|
@@ -175,12 +175,14 @@ var FlashController = class extends Controller {
|
|
|
175
175
|
* only: `dismiss` reports a dismissal, and freezing the page is not one.
|
|
176
176
|
*/
|
|
177
177
|
#rewindForCache() {
|
|
178
|
+
const removed = this.#order.length;
|
|
178
179
|
for (const message of [...this.#order]) {
|
|
179
180
|
message.remove();
|
|
180
181
|
this.#forget(message);
|
|
181
182
|
}
|
|
182
183
|
for (const message of this.#leaving) message.remove();
|
|
183
184
|
this.#leaving.clear();
|
|
185
|
+
if (removed > 0) this.dispatch("reconcile", { detail: { removed } });
|
|
184
186
|
}
|
|
185
187
|
/** Follows a `region` element swapped in — or arriving — at runtime (Turbo Stream). */
|
|
186
188
|
regionTargetConnected() {
|
|
@@ -213,7 +213,7 @@ var FrameLoadingController = class extends Controller {
|
|
|
213
213
|
minDuration: { type: Number, default: 0 },
|
|
214
214
|
restoreFocus: { type: Boolean, default: true }
|
|
215
215
|
};
|
|
216
|
-
static events = ["start", "end"];
|
|
216
|
+
static events = ["start", "end", "reconcile"];
|
|
217
217
|
#timeouts = new SafeTimeout();
|
|
218
218
|
#floor = new MinDurationFloor(this.#timeouts);
|
|
219
219
|
#gate = new DetachGate();
|
|
@@ -289,6 +289,7 @@ var FrameLoadingController = class extends Controller {
|
|
|
289
289
|
this.#loading = false;
|
|
290
290
|
this.#floor.cancel();
|
|
291
291
|
this.#rewindHooks();
|
|
292
|
+
this.dispatch("reconcile", { detail: {} });
|
|
292
293
|
}
|
|
293
294
|
/**
|
|
294
295
|
* Clears every hook the loading state writes. Shared by the three ways a load can
|