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.
@@ -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 = ["zone", "trigger", "input", "list", "item", "itemTemplate", "status"];
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
- dragLabel: { type: String, default: "Drop files to add them" }
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
- /** Prevents initial and teardown target callbacks from binding outside controller lifetime. */
16
- #connected = false;
17
- /** Wires file removal as a delegated listener on the list container. */
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.#connected = true;
20
- if (this.hasListTarget) this.#bindList(this.listTarget);
156
+ this.#gate.cancel();
157
+ this.#warnedTemplate = false;
158
+ this.#beforeCache.activate();
21
159
  }
22
- /** Revokes any outstanding preview URLs so none leaks across navigations. */
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
- for (const entry of this.#entries) {
27
- if (entry.url) URL.revokeObjectURL(entry.url);
28
- }
29
- this.#entries.length = 0;
167
+ this.#beforeCache.deactivate();
168
+ this.#gate.disconnected(this, () => this.#teardown());
30
169
  }
31
- /** Rebinds removal and restores client-only previews when Turbo replaces the list target. */
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
- /** Adds the files chosen through the native dialog. */
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
- if (this.inputTarget.files) this.#addFiles(this.inputTarget.files);
54
- this.inputTarget.value = "";
201
+ const files = this.inputTarget.files;
202
+ if (files) this.#addFiles(files);
203
+ else this.#syncInput();
55
204
  }
56
- /** Marks the zone as a drop target and announces the affordance in words. */
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.hasZoneTarget) this.zoneTarget.setAttribute("data-dragover", "");
60
- this.#setStatus(this.dragLabelValue);
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
- /** Clears the drag-over flag when the pointer leaves the zone. */
63
- onDragLeave() {
64
- if (this.hasZoneTarget) this.zoneTarget.removeAttribute("data-dragover");
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 list = event.currentTarget;
79
- const button = event.target.closest("button");
80
- if (!this.#connected || !list || !button || !this.hasListTarget || list !== this.listTarget || !list.contains(button)) {
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 and renders the accepted ones. */
254
+ /** Validates each incoming file, renders the accepted ones, and reports the batch. */
87
255
  #addFiles(files) {
88
- let changed = false;
89
- if (this.hasZoneTarget) this.zoneTarget.removeAttribute("data-stimeo--file-dropzone-invalid");
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
- this.zoneTarget.setAttribute("data-stimeo--file-dropzone-invalid", "");
95
- }
96
- this.#setStatus(file.name);
97
- this.dispatch("reject", { detail: { file, reason } });
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
- this.#setStatus(file.name);
102
- changed = true;
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
- /** Returns the rejection reason for `file`, or `null` when it is acceptable. */
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
- /** Builds one preview item (name, optional thumbnail, remove button). */
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.hasItemTemplateTarget || !this.hasListTarget) return;
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.querySelector('[data-stimeo--file-dropzone-target="item"]');
119
- const name = fragment.querySelector('[data-file-dropzone-slot="name"]');
120
- const thumb = fragment.querySelector('[data-file-dropzone-slot="thumb"]');
121
- const button = fragment.querySelector("button");
122
- if (!item) return;
123
- if (name) name.textContent = file.name;
124
- if (button) button.setAttribute("aria-label", `Remove ${file.name}`);
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.#setStatus(entry.file.name);
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
- /** Updates the live region so assistive tech announces the change. */
168
- #setStatus(text) {
169
- if (this.hasStatusTarget) this.statusTarget.textContent = text;
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 buttons currently in the list, in order. */
492
+ /** The declared remove button of each rendered item, in selection order. */
177
493
  get #removeButtons() {
178
- return Array.from(this.listTarget.querySelectorAll("button"));
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