stimeo-ui 0.4.0 → 0.6.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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +184 -0
  3. data/dist/controllers/aspect_ratio_controller.js +19 -11
  4. data/dist/controllers/avatar_controller.js +195 -40
  5. data/dist/controllers/breadcrumb_controller.js +5 -1
  6. data/dist/controllers/carousel_controller.js +90 -10
  7. data/dist/controllers/checkbox_controller.js +136 -25
  8. data/dist/controllers/clipboard_controller.js +8 -3
  9. data/dist/controllers/collapsible_controller.js +4 -1
  10. data/dist/controllers/color_picker_controller.js +41 -11
  11. data/dist/controllers/context_menu_controller.js +2 -2
  12. data/dist/controllers/countdown_controller.js +5 -1
  13. data/dist/controllers/date_range_picker_controller.js +162 -31
  14. data/dist/controllers/direct_upload_controller.js +3 -3
  15. data/dist/controllers/empty_state_controller.js +107 -16
  16. data/dist/controllers/file_dropzone_controller.js +26 -3
  17. data/dist/controllers/flash_controller.js +161 -21
  18. data/dist/controllers/form_validation_controller.js +8 -2
  19. data/dist/controllers/frame_loading_controller.js +94 -22
  20. data/dist/controllers/highlight_controller.js +38 -1
  21. data/dist/controllers/idle_controller.js +39 -6
  22. data/dist/controllers/local_time_controller.js +2 -0
  23. data/dist/controllers/masonry_controller.js +1 -1
  24. data/dist/controllers/menubar_controller.js +5 -3
  25. data/dist/controllers/meter_controller.js +3 -1
  26. data/dist/controllers/multi_select_controller.js +460 -151
  27. data/dist/controllers/network_status_controller.js +1 -3
  28. data/dist/controllers/number_input_controller.js +455 -64
  29. data/dist/controllers/overflow_menu_controller.js +5 -1
  30. data/dist/controllers/pagination_controller.js +38 -1
  31. data/dist/controllers/password_strength_controller.js +21 -3
  32. data/dist/controllers/persist_controller.js +24 -5
  33. data/dist/controllers/pointer_drag_controller.js +10 -0
  34. data/dist/controllers/portal_controller.js +10 -0
  35. data/dist/controllers/progress_controller.js +7 -3
  36. data/dist/controllers/radio_group_controller.js +540 -56
  37. data/dist/controllers/range_slider_controller.js +385 -94
  38. data/dist/controllers/rating_controller.js +274 -89
  39. data/dist/controllers/relative_time_controller.js +2 -0
  40. data/dist/controllers/resizable_controller.js +33 -0
  41. data/dist/controllers/roving_controller.js +60 -5
  42. data/dist/controllers/scroll_area_controller.js +155 -23
  43. data/dist/controllers/scroll_visibility_controller.js +33 -0
  44. data/dist/controllers/separator_controller.js +13 -17
  45. data/dist/controllers/skeleton_controller.js +71 -3
  46. data/dist/controllers/slider_controller.js +325 -48
  47. data/dist/controllers/spinner_controller.js +18 -3
  48. data/dist/controllers/step_indicator_controller.js +3 -1
  49. data/dist/controllers/stepper_controller.js +2 -0
  50. data/dist/controllers/switch_controller.js +162 -18
  51. data/dist/controllers/tags_input_controller.js +356 -120
  52. data/dist/controllers/textarea_autosize_controller.js +1 -1
  53. data/dist/controllers/time_picker_controller.js +296 -104
  54. data/dist/controllers/toggle_group_controller.js +378 -55
  55. data/dist/controllers/toolbar_controller.js +5 -3
  56. data/dist/controllers/tree_view_controller.js +24 -4
  57. data/dist/index.js +3811 -1048
  58. data/lib/stimeo/ui/version.rb +1 -1
  59. metadata +2 -2
@@ -18,6 +18,35 @@ function isReservedArrowChord(event, allow = []) {
18
18
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
19
19
  }
20
20
 
21
+ // src/utils/before_cache_reset.ts
22
+ var BeforeCacheReset = class _BeforeCacheReset {
23
+ /** Every subscribed instance, iterated by the one shared document listener. */
24
+ static #subscribers = /* @__PURE__ */ new Set();
25
+ /** The shared listener; installed while at least one instance is subscribed. */
26
+ static #onBeforeCache = () => {
27
+ for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
28
+ };
29
+ #rewind;
30
+ /** @param rewind - the pass that returns this controller's state to its initial form. */
31
+ constructor(rewind) {
32
+ this.#rewind = rewind;
33
+ }
34
+ /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
35
+ activate() {
36
+ const first = _BeforeCacheReset.#subscribers.size === 0;
37
+ _BeforeCacheReset.#subscribers.add(this);
38
+ if (first) {
39
+ document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
40
+ }
41
+ }
42
+ /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
43
+ deactivate() {
44
+ _BeforeCacheReset.#subscribers.delete(this);
45
+ if (_BeforeCacheReset.#subscribers.size > 0) return;
46
+ document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
47
+ }
48
+ };
49
+
21
50
  // src/utils/dates.ts
22
51
  function toISODateString(date) {
23
52
  const year = date.getFullYear();
@@ -138,16 +167,34 @@ var SafeTimeout = class extends TimerRegistry {
138
167
  }
139
168
  };
140
169
 
170
+ // src/utils/string_list.ts
171
+ function parseStringList(raw, fallback = []) {
172
+ const text = raw.trim();
173
+ if (text.length === 0) return [...fallback];
174
+ let parsed;
175
+ try {
176
+ parsed = JSON.parse(text);
177
+ } catch {
178
+ return [...fallback];
179
+ }
180
+ if (!Array.isArray(parsed)) return [...fallback];
181
+ return parsed.filter((entry) => typeof entry === "string");
182
+ }
183
+
141
184
  // src/controllers/date_range_picker_controller.ts
142
185
  var GRID_SIZE = 42;
143
186
  var DateRangePickerController = class extends Controller {
144
187
  static targets = ["grid", "monthLabel", "cell", "status", "startField", "endField"];
145
188
  static values = {
146
189
  min: { type: String, default: "" },
147
- max: { type: String, default: "" }
190
+ max: { type: String, default: "" },
191
+ // A JSON list read through `parseStringList` rather than Stimulus's `Array`
192
+ // type: that reader throws out of the value observer before any callback
193
+ // runs, so one malformed attribute would stop the picker from connecting.
194
+ disabledDates: { type: String, default: "" }
148
195
  };
149
196
  static actions = ["applyPreset", "next", "onKeydown", "prev", "previewTo", "selectDate"];
150
- static events = ["change"];
197
+ static events = ["change", "monthchange"];
151
198
  /** The month currently rendered, as `YYYY-MM`. */
152
199
  #viewMonth = "";
153
200
  /** The confirmed range endpoints (ISO), or "" when unset. */
@@ -161,7 +208,12 @@ var DateRangePickerController = class extends Controller {
161
208
  #focusedDate = /* @__PURE__ */ new Date();
162
209
  /** Deferred focus after an async month transition (cancelled on teardown). */
163
210
  #focusTimer = new SafeTimeout();
164
- /** Seeds the range from any pre-filled hidden fields and renders the grid. */
211
+ /** The declared unavailable dates, indexed for the per-cell paint lookup. */
212
+ #disabledDates = /* @__PURE__ */ new Set();
213
+ /** Rewinds an unfinished selection before Turbo snapshots the page. */
214
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
215
+ /** Last painted month, or `null` until the initial paint has settled. */
216
+ #announcedMonth = null;
165
217
  /**
166
218
  * Collapses a morph that swaps render inputs into one repaint, and refuses the
167
219
  * pass Stimulus delivers before `connect()`.
@@ -169,10 +221,17 @@ var DateRangePickerController = class extends Controller {
169
221
  #repaint = new MicrotaskCoalescer(() => {
170
222
  this.#render();
171
223
  });
224
+ /** Seeds and normalizes the range from optional hidden fields, then paints the grid. */
172
225
  connect() {
173
226
  this.#repaint.activate();
174
- this.#startDate = this.hasStartFieldTarget ? normalizeISO(this.startFieldTarget.value) : "";
175
- this.#endDate = this.hasEndFieldTarget ? normalizeISO(this.endFieldTarget.value) : "";
227
+ this.#beforeCache.activate();
228
+ const authoredStart = this.hasStartFieldTarget ? normalizeISO(this.startFieldTarget.value) : "";
229
+ const authoredEnd = this.hasEndFieldTarget ? normalizeISO(this.endFieldTarget.value) : "";
230
+ [this.#startDate, this.#endDate] = orderRange(authoredStart, authoredEnd);
231
+ this.#pendingStart = "";
232
+ this.#previewDate = "";
233
+ this.#announcedMonth = null;
234
+ this.#commitFields();
176
235
  const anchor = parseISODateString(this.#startDate) ?? this.#clampToBounds(/* @__PURE__ */ new Date()) ?? /* @__PURE__ */ new Date();
177
236
  this.#focusedDate = anchor;
178
237
  this.#viewMonth = toISOMonthString(anchor);
@@ -182,6 +241,7 @@ var DateRangePickerController = class extends Controller {
182
241
  /** Cancels any pending deferred focus so it never fires on a detached element. */
183
242
  disconnect() {
184
243
  this.#repaint.cancel();
244
+ this.#beforeCache.deactivate();
185
245
  this.#focusTimer.clearAll();
186
246
  }
187
247
  /** Repaints when application code (or a Turbo morph) changes `min` at runtime. */
@@ -192,6 +252,11 @@ var DateRangePickerController = class extends Controller {
192
252
  maxValueChanged() {
193
253
  this.#repaint.schedule();
194
254
  }
255
+ /** Re-indexes the unavailable dates and repaints when the declared set changes. */
256
+ disabledDatesValueChanged() {
257
+ this.#disabledDates = new Set(parseStringList(this.disabledDatesValue));
258
+ this.#repaint.schedule();
259
+ }
195
260
  /** Navigates to the previous month. */
196
261
  prev(event) {
197
262
  event?.preventDefault();
@@ -207,7 +272,7 @@ var DateRangePickerController = class extends Controller {
207
272
  const cell = this.#cellFrom(event.target);
208
273
  if (!cell) return;
209
274
  const date = cell.getAttribute("data-date");
210
- if (!date || cell.getAttribute("aria-disabled") === "true") return;
275
+ if (!date || !this.#isSelectable(date)) return;
211
276
  this.#choose(date);
212
277
  }
213
278
  /** Previews the range up to a hovered/focused cell while selecting. */
@@ -216,28 +281,27 @@ var DateRangePickerController = class extends Controller {
216
281
  if (!cell) return;
217
282
  const date = cell.getAttribute("data-date");
218
283
  if (!date) return;
284
+ let shouldRender = false;
219
285
  if (event.type.startsWith("focus")) {
220
286
  const parsed = parseISODateString(date);
221
287
  if (parsed) this.#focusedDate = parsed;
222
- if (!this.#pendingStart && cell.getAttribute("tabindex") !== "0") {
223
- this.#render();
224
- }
288
+ shouldRender = cell.getAttribute("tabindex") !== "0";
225
289
  }
226
- if (this.#pendingStart) {
290
+ if (this.#pendingStart && this.#isSelectable(date) && this.#previewDate !== date) {
227
291
  this.#previewDate = date;
228
- this.#render();
292
+ shouldRender = true;
229
293
  }
294
+ if (shouldRender) this.#render();
230
295
  }
231
296
  /** Applies a named preset (`today` / `last7` / `last30` / `thisMonth`). */
232
297
  applyPreset(event) {
233
298
  const button = event.target?.closest("[data-range]");
234
- const name = button?.getAttribute("data-range");
235
- if (!name) return;
236
- const range = computePreset(name);
299
+ const range = computePreset(button?.getAttribute("data-range") ?? "");
237
300
  if (!range) return;
238
- const start = this.#clampISO(range.start);
239
- const end = this.#clampISO(range.end);
240
- if (!start || !end) return;
301
+ const intersection = this.#intersectRange(range);
302
+ if (!intersection) return;
303
+ const { start, end } = intersection;
304
+ if (!this.#isSelectable(start) || !this.#isSelectable(end)) return;
241
305
  this.#startDate = start;
242
306
  this.#endDate = end;
243
307
  this.#pendingStart = "";
@@ -255,12 +319,12 @@ var DateRangePickerController = class extends Controller {
255
319
  if (isReservedArrowChord(event)) return;
256
320
  const cell = this.#cellFrom(event.target);
257
321
  if (!cell) return;
258
- const dateStr = cell.getAttribute("data-date");
259
- const date = dateStr ? parseISODateString(dateStr) : null;
322
+ const dateStr = cell.getAttribute("data-date") ?? "";
323
+ const date = parseISODateString(dateStr);
260
324
  if (!date) return;
261
325
  if (event.key === "Enter" || event.key === " ") {
262
326
  event.preventDefault();
263
- if (dateStr && cell.getAttribute("aria-disabled") !== "true") this.#choose(dateStr);
327
+ if (this.#isSelectable(dateStr)) this.#choose(dateStr);
264
328
  return;
265
329
  }
266
330
  if (event.key === "Escape") {
@@ -293,10 +357,10 @@ var DateRangePickerController = class extends Controller {
293
357
  next = addDays(date, 6 - date.getDay());
294
358
  break;
295
359
  case "PageUp":
296
- next = shiftMonthClamped(date, -1);
360
+ next = event.shiftKey ? shiftYearClamped(date, -1) : shiftMonthClamped(date, -1);
297
361
  break;
298
362
  case "PageDown":
299
- next = shiftMonthClamped(date, 1);
363
+ next = event.shiftKey ? shiftYearClamped(date, 1) : shiftMonthClamped(date, 1);
300
364
  break;
301
365
  default:
302
366
  return;
@@ -327,12 +391,14 @@ var DateRangePickerController = class extends Controller {
327
391
  /** Moves roving focus to `date`, transitioning the month when needed. */
328
392
  #moveFocusTo(date) {
329
393
  this.#focusedDate = date;
330
- if (this.#pendingStart) this.#previewDate = toISODateString(date);
331
- this.#transitionTo(toISOMonthString(date), toISODateString(date));
394
+ const iso = toISODateString(date);
395
+ if (this.#pendingStart && this.#isSelectable(iso)) this.#previewDate = iso;
396
+ this.#transitionTo(toISOMonthString(date), iso);
332
397
  }
333
398
  /** Renders `month`, then focuses the cell for `dateStr` (deferred if async). */
334
399
  #transitionTo(month, dateStr) {
335
400
  const isTransition = month !== this.#viewMonth;
401
+ this.#focusTimer.clearAll();
336
402
  this.#viewMonth = month;
337
403
  this.#render();
338
404
  const focusCell = () => {
@@ -355,22 +421,28 @@ var DateRangePickerController = class extends Controller {
355
421
  this.#focusedDate = target;
356
422
  this.#render();
357
423
  }
358
- /** Builds the six-week grid and binds range/roving/disabled state per cell. */
424
+ /**
425
+ * Builds the six-week grid and binds range/roving/disabled state per cell.
426
+ *
427
+ * @stimeoRenderRoot
428
+ */
359
429
  #render() {
360
430
  const info = parseISOMonthString(this.#viewMonth);
361
431
  if (!info) return;
362
432
  const { year, month } = info;
433
+ if (this.#previewDate && !this.#isSelectable(this.#previewDate)) this.#previewDate = "";
363
434
  if (this.hasMonthLabelTarget) {
364
435
  const lang = document.documentElement.lang || "en";
365
- const formatter = new Intl.DateTimeFormat(lang, { month: "long", year: "numeric" });
436
+ const formatter = monthFormatter(lang);
366
437
  this.monthLabelTarget.textContent = formatter.format(new Date(year, month - 1, 1));
367
438
  }
368
439
  const [rangeStart, rangeEnd] = this.#visualRange();
369
440
  const days = gridDays(year, month);
370
441
  const focusedStr = toISODateString(this.#focusedDate);
371
442
  const todayStr = toISODateString(/* @__PURE__ */ new Date());
443
+ const cells = this.cellTargets;
372
444
  for (let i = 0; i < GRID_SIZE; i++) {
373
- const el = this.cellTargets[i];
445
+ const el = cells[i];
374
446
  const date = days[i];
375
447
  if (!el || !date) continue;
376
448
  const iso = toISODateString(date);
@@ -379,8 +451,8 @@ var DateRangePickerController = class extends Controller {
379
451
  el.setAttribute("data-outside", String(date.getMonth() !== month - 1));
380
452
  el.setAttribute("data-today", String(iso === todayStr));
381
453
  el.setAttribute("tabindex", iso === focusedStr ? "0" : "-1");
382
- if (this.#outOfBounds(iso)) el.setAttribute("aria-disabled", "true");
383
- else el.removeAttribute("aria-disabled");
454
+ if (this.#isSelectable(iso)) el.removeAttribute("aria-disabled");
455
+ else el.setAttribute("aria-disabled", "true");
384
456
  const isStart = !!rangeStart && iso === rangeStart;
385
457
  const isEnd = !!rangeEnd && iso === rangeEnd && rangeEnd !== rangeStart;
386
458
  const inside = !!rangeStart && !!rangeEnd && iso > rangeStart && iso < rangeEnd;
@@ -394,6 +466,16 @@ var DateRangePickerController = class extends Controller {
394
466
  )
395
467
  );
396
468
  }
469
+ for (const extra of cells.slice(GRID_SIZE)) extra.setAttribute("tabindex", "-1");
470
+ if (!cells.some((cell) => cell.getAttribute("tabindex") === "0")) {
471
+ const fallback = cells.find((cell) => cell.getAttribute("data-outside") === "false") ?? cells[0];
472
+ fallback?.setAttribute("tabindex", "0");
473
+ }
474
+ const previous = this.#announcedMonth;
475
+ this.#announcedMonth = this.#viewMonth;
476
+ if (previous !== null && previous !== this.#viewMonth) {
477
+ this.dispatch("monthchange", { detail: { month: this.#viewMonth } });
478
+ }
397
479
  }
398
480
  /** The ordered [start, end] pair to paint: the preview while selecting, else confirmed. */
399
481
  #visualRange() {
@@ -414,15 +496,25 @@ var DateRangePickerController = class extends Controller {
414
496
  this.statusTarget.textContent = `${this.#startDate} \u2013 ${this.#endDate}`;
415
497
  }
416
498
  }
499
+ /**
500
+ * True when `iso` may be chosen as a range endpoint.
501
+ *
502
+ * The single availability question in the controller: the grid paint, the
503
+ * preview, and both commit paths ask it, so a cell's `aria-disabled` and what
504
+ * a click on that cell does are the same decision rather than two that have to
505
+ * be kept in step.
506
+ */
507
+ #isSelectable(iso) {
508
+ return !this.#outOfBounds(iso) && !this.#disabledDates.has(iso);
509
+ }
417
510
  /** True when `iso` falls outside the `[min, max]` bounds. */
418
511
  #outOfBounds(iso) {
419
512
  if (this.minValue && iso < this.minValue) return true;
420
513
  if (this.maxValue && iso > this.maxValue) return true;
421
514
  return false;
422
515
  }
423
- /** Clamps an ISO date string into `[min, max]`, or "" when unparseable. */
516
+ /** Clamps a generated ISO date string into `[min, max]`. */
424
517
  #clampISO(iso) {
425
- if (!iso) return "";
426
518
  if (this.minValue && iso < this.minValue) return this.minValue;
427
519
  if (this.maxValue && iso > this.maxValue) return this.maxValue;
428
520
  return iso;
@@ -432,6 +524,27 @@ var DateRangePickerController = class extends Controller {
432
524
  const clamped = this.#clampISO(toISODateString(date));
433
525
  return parseISODateString(clamped);
434
526
  }
527
+ /**
528
+ * Intersects a preset with `[min, max]`, rejecting a disjoint interval.
529
+ *
530
+ * Only the bounds narrow a preset here: `disabledDates` excludes single days,
531
+ * not sub-intervals, so it cannot shrink one to a still-contiguous range. A
532
+ * day it excludes is still refused as an endpoint — the caller checks that —
533
+ * but a preset that merely spans one keeps its span.
534
+ */
535
+ #intersectRange(range) {
536
+ const start = this.minValue && range.start < this.minValue ? this.minValue : range.start;
537
+ const end = this.maxValue && range.end > this.maxValue ? this.maxValue : range.end;
538
+ return start <= end ? { start, end } : null;
539
+ }
540
+ /** Removes provisional range state before Turbo freezes a cached snapshot. */
541
+ #rewindForCache() {
542
+ this.#focusTimer.clearAll();
543
+ if (!this.#pendingStart && !this.#previewDate) return;
544
+ this.#pendingStart = "";
545
+ this.#previewDate = "";
546
+ this.#render();
547
+ }
435
548
  /** Resolves the cell element from an event target, or null. */
436
549
  #cellFrom(target) {
437
550
  return target?.closest(
@@ -450,6 +563,12 @@ function shiftMonthClamped(date, delta) {
450
563
  target.setDate(Math.min(date.getDate(), lastDay));
451
564
  return target;
452
565
  }
566
+ function shiftYearClamped(date, delta) {
567
+ const target = new Date(date.getFullYear() + delta, date.getMonth(), 1);
568
+ const lastDay = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate();
569
+ target.setDate(Math.min(date.getDate(), lastDay));
570
+ return target;
571
+ }
453
572
  function gridDays(year, month) {
454
573
  const first = new Date(year, month - 1, 1);
455
574
  const start = new Date(first);
@@ -466,6 +585,18 @@ function normalizeISO(value) {
466
585
  const date = parseISODateString(value.trim());
467
586
  return date ? toISODateString(date) : "";
468
587
  }
588
+ function orderRange(start, end) {
589
+ return start && end && end < start ? [end, start] : [start, end];
590
+ }
591
+ function monthFormatter(locale) {
592
+ const options = { month: "long", year: "numeric" };
593
+ try {
594
+ return new Intl.DateTimeFormat(locale, options);
595
+ } catch (error) {
596
+ if (!(error instanceof RangeError)) throw error;
597
+ return new Intl.DateTimeFormat("en", options);
598
+ }
599
+ }
469
600
  function computePreset(name) {
470
601
  const today = /* @__PURE__ */ new Date();
471
602
  const todayStr = toISODateString(today);
@@ -110,7 +110,7 @@ var DirectUploadController = class extends Controller {
110
110
  const clamped = Math.max(0, Math.min(100, percent));
111
111
  row.setAttribute("aria-valuenow", String(clamped));
112
112
  row.setAttribute("aria-valuetext", `${clamped}%`);
113
- row.style.setProperty("--stimeo-upload-progress", `${clamped}%`);
113
+ row.style.setProperty("--stimeo--upload-progress", `${clamped}%`);
114
114
  this.#setField(row, "percent", `${clamped}%`);
115
115
  this.#syncAggregate();
116
116
  this.dispatch("progress", { detail: { id, percent: clamped } });
@@ -148,7 +148,7 @@ var DirectUploadController = class extends Controller {
148
148
  }
149
149
  clone.setAttribute("aria-valuenow", "0");
150
150
  clone.setAttribute("data-upload-state", "uploading");
151
- clone.style.setProperty("--stimeo-upload-progress", "0%");
151
+ clone.style.setProperty("--stimeo--upload-progress", "0%");
152
152
  this.listTarget.appendChild(clone);
153
153
  this.#rows.set(key, clone);
154
154
  return clone;
@@ -172,7 +172,7 @@ var DirectUploadController = class extends Controller {
172
172
  }
173
173
  const overall = Math.round(total / this.#rows.size);
174
174
  this.element.setAttribute("data-upload-progress", String(overall));
175
- this.element.style.setProperty("--stimeo-upload-progress", `${overall}%`);
175
+ this.element.style.setProperty("--stimeo--upload-progress", `${overall}%`);
176
176
  }
177
177
  /** Writes a consumer label (with `%{name}` substituted) to the status region. */
178
178
  #announce(label, row) {
@@ -29,23 +29,120 @@ var EmptyStateController = class extends Controller {
29
29
  };
30
30
  static events = ["change"];
31
31
  #observer = null;
32
+ /** Whether the controller is between `connect()` and `disconnect()`. */
33
+ #connected = false;
32
34
  /** Last applied empty state; `null` until the first sync so connect emits nothing. */
33
35
  #empty = null;
34
36
  connect() {
35
- if (!this.hasListTarget) return;
36
- if (typeof MutationObserver !== "undefined") {
37
- this.#observer = new MutationObserver(() => this.#apply());
38
- this.#observer.observe(this.listTarget, { childList: true });
39
- }
40
- this.#apply();
37
+ this.#connected = true;
38
+ this.#syncObservation();
39
+ this.#update();
41
40
  }
42
41
  disconnect() {
42
+ this.#connected = false;
43
+ this.#stopObserving();
44
+ }
45
+ /** Follows a `list` element swapped in at runtime (Turbo Stream `replace` / morph). */
46
+ listTargetConnected() {
47
+ this.#resync();
48
+ }
49
+ /** Releases the observation when the `list` element leaves the target set. */
50
+ listTargetDisconnected() {
51
+ this.#resync();
52
+ }
53
+ /** Syncs an `empty` element that arrives — or is replaced — at runtime. */
54
+ emptyTargetConnected() {
55
+ this.#resync();
56
+ }
57
+ /**
58
+ * Syncs the `empty` element that remains when one leaves the target set. A
59
+ * single-target getter resolves to the first `empty` element in document order,
60
+ * so a swap that inserts the replacement *before* removing the original (Turbo
61
+ * Stream `after` / `before` / `append` followed by `remove`) leaves the
62
+ * replacement untouched until the original goes — this callback is that moment.
63
+ */
64
+ emptyTargetDisconnected() {
65
+ this.#resync();
66
+ }
67
+ /** Re-renders when application code (or a Turbo morph) changes `itemSelector` at runtime. */
68
+ itemSelectorValueChanged() {
69
+ this.#resync();
70
+ }
71
+ /**
72
+ * Re-points the observation and re-renders after a target or selector change.
73
+ * The `#connected` guard is load-bearing: Stimulus runs value and target
74
+ * callbacks for the initial markup *before* `connect()` and runs target
75
+ * callbacks during teardown *after* `disconnect()`, and re-observing there
76
+ * would outlive the controller.
77
+ */
78
+ #resync() {
79
+ if (!this.#connected) return;
80
+ this.#syncObservation();
81
+ this.#update();
82
+ }
83
+ /**
84
+ * Points the mutation observation at the current `list` target — re-resolved on
85
+ * every sync rather than captured at connect, so an element swapped in at
86
+ * runtime is observed instead of the detached original.
87
+ *
88
+ * The observation covers exactly what the count predicate reads. With no
89
+ * `itemSelector` the count is the child element count, which only `childList`
90
+ * can change. With one, the predicate reads the children themselves, so
91
+ * attribute and descendant mutations are watched too — and the controller's own
92
+ * writes are filtered back out, or toggling `hidden` would re-enter the render.
93
+ */
94
+ #syncObservation() {
95
+ this.#stopObserving();
96
+ if (!this.hasListTarget || typeof MutationObserver === "undefined") return;
97
+ const watchesItems = this.itemSelectorValue.length > 0;
98
+ this.#observer = new MutationObserver((records) => {
99
+ if (records.some((record) => this.#affectsCount(record))) this.#update();
100
+ });
101
+ this.#observer.observe(this.listTarget, {
102
+ childList: true,
103
+ subtree: watchesItems,
104
+ attributes: watchesItems
105
+ });
106
+ }
107
+ #stopObserving() {
43
108
  this.#observer?.disconnect();
44
109
  this.#observer = null;
45
110
  }
46
- /** Recomputes the count and syncs visibility, hooks, and the change event. */
47
- #apply() {
48
- if (!this.hasListTarget) return;
111
+ /**
112
+ * Whether a mutation can change the item count. An attribute written on a
113
+ * target this controller owns is its own echo — `hidden` on `list` / `empty`,
114
+ * and the hooks on the controller element when the list *is* that element.
115
+ */
116
+ #affectsCount(record) {
117
+ if (record.type !== "attributes") return true;
118
+ const own = record.target === this.listTarget || this.hasEmptyTarget && record.target === this.emptyTarget;
119
+ return !own;
120
+ }
121
+ /**
122
+ * Renders the count, then reports a crossed boundary. The announcement copy is
123
+ * read here rather than while rendering: it is the wording of the report, not
124
+ * an input to what is displayed.
125
+ */
126
+ #update() {
127
+ const count = this.#render();
128
+ if (count === null) return;
129
+ const empty = count === 0;
130
+ const crossed = this.#empty !== null && empty !== this.#empty;
131
+ this.#empty = empty;
132
+ if (!crossed) return;
133
+ this.dispatch("change", { detail: { count, empty } });
134
+ announce(
135
+ fillTemplate(empty ? this.announceTextValue : this.announceFilledTextValue, { count })
136
+ );
137
+ }
138
+ /**
139
+ * Syncs visibility and the state hooks to the current item count, and returns
140
+ * it. `null` when there is no `list` target to count.
141
+ *
142
+ * @stimeoRenderRoot
143
+ */
144
+ #render() {
145
+ if (!this.hasListTarget) return null;
49
146
  const count = this.#count();
50
147
  const empty = count === 0;
51
148
  this.element.setAttribute("data-count", String(count));
@@ -56,13 +153,7 @@ var EmptyStateController = class extends Controller {
56
153
  }
57
154
  this.listTarget.hidden = empty;
58
155
  if (this.hasEmptyTarget) this.emptyTarget.hidden = !empty;
59
- if (this.#empty !== null && empty !== this.#empty) {
60
- this.dispatch("change", { detail: { count, empty } });
61
- announce(
62
- fillTemplate(empty ? this.announceTextValue : this.announceFilledTextValue, { count })
63
- );
64
- }
65
- this.#empty = empty;
156
+ return count;
66
157
  }
67
158
  /** Item count: element children matching `itemSelector`, or all element children. */
68
159
  #count() {
@@ -12,18 +12,38 @@ var FileDropzoneController = class extends Controller {
12
12
  static events = ["change", "reject"];
13
13
  /** Selected files paired with their rendered item and any preview objectURL. */
14
14
  #entries = [];
15
+ /** Prevents initial and teardown target callbacks from binding outside controller lifetime. */
16
+ #connected = false;
15
17
  /** Wires file removal as a delegated listener on the list container. */
16
18
  connect() {
17
- if (this.hasListTarget) this.listTarget.addEventListener("click", this.#onItemClick);
19
+ this.#connected = true;
20
+ if (this.hasListTarget) this.#bindList(this.listTarget);
18
21
  }
19
22
  /** Revokes any outstanding preview URLs so none leaks across navigations. */
20
23
  disconnect() {
21
- if (this.hasListTarget) this.listTarget.removeEventListener("click", this.#onItemClick);
24
+ this.#connected = false;
25
+ for (const list of this.listTargets) list.removeEventListener("click", this.#onItemClick);
22
26
  for (const entry of this.#entries) {
23
27
  if (entry.url) URL.revokeObjectURL(entry.url);
24
28
  }
25
29
  this.#entries.length = 0;
26
30
  }
31
+ /** Rebinds removal and restores client-only previews when Turbo replaces the list target. */
32
+ listTargetConnected(list) {
33
+ if (!this.#connected) return;
34
+ this.#bindList(list);
35
+ }
36
+ /** Releases only the list target that actually disconnected. */
37
+ listTargetDisconnected(list) {
38
+ list.removeEventListener("click", this.#onItemClick);
39
+ }
40
+ /** Binds delegated removal once and moves live preview items into the current list. */
41
+ #bindList(list) {
42
+ list.addEventListener("click", this.#onItemClick);
43
+ for (const entry of this.#entries) {
44
+ if (!list.contains(entry.item)) list.appendChild(entry.item);
45
+ }
46
+ }
27
47
  /** Opens the native file dialog. Bound via `data-action` (trigger click). */
28
48
  openDialog() {
29
49
  this.inputTarget.click();
@@ -55,8 +75,11 @@ var FileDropzoneController = class extends Controller {
55
75
  * an item is appended without waiting on Stimulus to wire a freshly created element.
56
76
  */
57
77
  #onItemClick = (event) => {
78
+ const list = event.currentTarget;
58
79
  const button = event.target.closest("button");
59
- if (!button || !this.hasListTarget || !this.listTarget.contains(button)) return;
80
+ if (!this.#connected || !list || !button || !this.hasListTarget || list !== this.listTarget || !list.contains(button)) {
81
+ return;
82
+ }
60
83
  const index = this.#entries.findIndex((entry) => entry.item.contains(button));
61
84
  if (index !== -1) this.#removeAt(index);
62
85
  };