stimeo-ui 0.4.0 → 0.5.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -0
  3. data/dist/controllers/aspect_ratio_controller.js +1 -1
  4. data/dist/controllers/breadcrumb_controller.js +5 -1
  5. data/dist/controllers/carousel_controller.js +5 -1
  6. data/dist/controllers/clipboard_controller.js +8 -3
  7. data/dist/controllers/collapsible_controller.js +4 -1
  8. data/dist/controllers/color_picker_controller.js +6 -2
  9. data/dist/controllers/context_menu_controller.js +2 -2
  10. data/dist/controllers/countdown_controller.js +5 -1
  11. data/dist/controllers/date_range_picker_controller.js +5 -1
  12. data/dist/controllers/direct_upload_controller.js +3 -3
  13. data/dist/controllers/empty_state_controller.js +107 -16
  14. data/dist/controllers/flash_controller.js +161 -21
  15. data/dist/controllers/form_validation_controller.js +8 -2
  16. data/dist/controllers/frame_loading_controller.js +94 -22
  17. data/dist/controllers/highlight_controller.js +38 -1
  18. data/dist/controllers/idle_controller.js +13 -2
  19. data/dist/controllers/local_time_controller.js +2 -0
  20. data/dist/controllers/masonry_controller.js +1 -1
  21. data/dist/controllers/meter_controller.js +3 -1
  22. data/dist/controllers/network_status_controller.js +1 -3
  23. data/dist/controllers/number_input_controller.js +191 -24
  24. data/dist/controllers/overflow_menu_controller.js +1 -1
  25. data/dist/controllers/pagination_controller.js +5 -1
  26. data/dist/controllers/password_strength_controller.js +1 -1
  27. data/dist/controllers/pointer_drag_controller.js +10 -0
  28. data/dist/controllers/portal_controller.js +10 -0
  29. data/dist/controllers/progress_controller.js +7 -3
  30. data/dist/controllers/range_slider_controller.js +385 -94
  31. data/dist/controllers/rating_controller.js +2 -0
  32. data/dist/controllers/relative_time_controller.js +2 -0
  33. data/dist/controllers/scroll_area_controller.js +1 -1
  34. data/dist/controllers/separator_controller.js +13 -17
  35. data/dist/controllers/skeleton_controller.js +71 -3
  36. data/dist/controllers/slider_controller.js +325 -48
  37. data/dist/controllers/spinner_controller.js +18 -3
  38. data/dist/controllers/step_indicator_controller.js +3 -1
  39. data/dist/controllers/stepper_controller.js +2 -0
  40. data/dist/controllers/switch_controller.js +162 -18
  41. data/dist/controllers/textarea_autosize_controller.js +1 -1
  42. data/dist/controllers/time_picker_controller.js +6 -3
  43. data/dist/controllers/tree_view_controller.js +19 -1
  44. data/dist/index.js +1214 -307
  45. data/lib/stimeo/ui/version.rb +1 -1
  46. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d5c989b2c56e2578e6c05c5e01c29e31cdba141e9a49110701ba4a40dc91198e
4
- data.tar.gz: 6b123f7cad0a54ef52b13c525bb53cf2eb43f0cc09748ab0d8cbf7d738e1fca9
3
+ metadata.gz: a78d7192424e66f85cd4fbc901f75f260178f890fbcf040724fd5e518314dda7
4
+ data.tar.gz: d14ba0559fd6659b4b1edc860c653815bb949439768f26e3540cb758dabee2f7
5
5
  SHA512:
6
- metadata.gz: b95db793fc2a866c4c099f98ca1a689a36b5f15419d6b89b1c2f6423f694e03421c68d1bf930c0920c6d8feeb0c554a430e03523367fdabfa24578769a922c9b
7
- data.tar.gz: 06ee52f5e9a6a56c9fea57f9e2f291e58336228e17a5bc5685238be3d98e00718a81837eb4196ddd08a230006492cd5e23d950537285f1e088c6bb2767b4fc12
6
+ metadata.gz: e02be89ae227eec783f0eb7f21c492a00ff75174c5285de3d8cf8418b3a826b6d9fb42d779b63a47845b77679d4b61adcade253746cf39ea83d7c924651f8a8e
7
+ data.tar.gz: 7661666a77da707d43ad07bf9e7bb4a8b0cb63caea75f2d0d8abf9f43fd6b43421bfe8d24203f814e70042a506177995df72e14487c4c11aa49016129bd44f02
data/CHANGELOG.md CHANGED
@@ -7,6 +7,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
  While the version is `0.x`, the public API (the `stimeo--*` data attributes) may
8
8
  change between releases.
9
9
 
10
+ ## [0.5.0] - 2026-08-13
11
+
12
+ ### Changed
13
+
14
+ - **Breaking** — every CSS custom property the library writes now carries the
15
+ `--stimeo--` namespace (two hyphens). Rename them in your stylesheets: a name
16
+ you miss raises no error, it just falls back to `var()`'s default and the bar
17
+ or thumb stops moving.
18
+ - `--stimeo-range-start` / `--stimeo-range-end` →
19
+ `--stimeo--range-slider-start` / `--stimeo--range-slider-end`
20
+ - `--stimeo-<name>` → `--stimeo--<name>` for `aspect-ratio`,
21
+ `collapsible-content-height`, `color`, `context-menu-x`, `context-menu-y`,
22
+ `masonry-columns`, `meter-ratio`, `password-strength`, `progress-ratio`,
23
+ `scroll-progress`, `step-indicator-ratio`, `textarea-rows`,
24
+ `upload-progress`
25
+ - **Breaking** — spinner's `indicator`, the network-status banners, and
26
+ countdown's `status` slot are no longer live regions. Drop `role="status"` /
27
+ `role="alert"` / `aria-live` from them and set `announceText` (and its
28
+ siblings) with a `stimeo--announcer` on the page; keeping the role reads the
29
+ change twice.
30
+ - time-picker: committing a field change dispatches a native `change` from the
31
+ `field` target, and `stimeo--time-picker:change` reports the composed value
32
+ even when no `field` target is present.
33
+ - tree-view: more native elements count as nested-interactive (`label`,
34
+ `summary`, `details`, `area[href]`, media with `controls`, `iframe`, `object`,
35
+ `embed`), so a click or key inside one is left alone instead of moving the
36
+ tree selection.
37
+
38
+ ### Added
39
+
40
+ - Inspector: the manifest advances from schema v8 to **v11** — tools that read it
41
+ raw must accept the new `valueConstraints`, `hosts`, and `valueRelations`
42
+ fields. Two diagnostics are new, so **re-run `stimeo check` before deploying;
43
+ it can report errors on views that passed under 0.4.0**:
44
+ - `invalid-value` — slider, range-slider, and number-input need a finite,
45
+ positive `step`; range-slider needs finite `min`, `max`, `start`, and `end`
46
+ with `min` at or below `max`.
47
+ - `invalid-host` — switch, time-picker, and tree-view report an unsupported
48
+ host element before it reaches the browser.
49
+
50
+ ### Fixed
51
+
52
+ - range-slider: two thumbs resting on the same value can be pulled apart with
53
+ the pointer again, and invalid or reversed bounds fall back to a finite
54
+ ordered range.
55
+ - slider, range-slider, number-input: an endpoint that does not sit on the step
56
+ grid is reachable, and an invalid `step` falls back to `1`.
57
+ - slider, range-slider: a Turbo morph that swaps a value repaints instead of
58
+ freezing, a replaced thumb or track picks up the current state, and a drag
59
+ stays with the pointer that started it.
60
+ - switch: holding Space no longer scrolls a non-`<button>` host, and an ancestor
61
+ `fieldset[disabled]` or `aria-disabled` blocks activation.
62
+ - flash: hover and focus pause the auto-dismiss independently, a flash paused
63
+ after its deadline dismisses instead of staying forever, a second dismiss on a
64
+ leaving flash is ignored, and a replaced `region` target is picked up.
65
+ - frame-loading: `aria-busy`, the skeleton, and `inert` are rolled back when the
66
+ controller is detached, instead of burning in with nothing left to finish the
67
+ load.
68
+ - empty-state: a replaced `list` or `empty` element and a runtime `itemSelector`
69
+ change are followed, so the placeholder no longer sticks beside a filled list.
70
+ - highlight: a highlight that outlives a Turbo navigation is cleaned up on the
71
+ way back instead of staying marked.
72
+ - idle: `data-idle` is cleared when a new measurement cycle connects, and
73
+ `disconnect()` removes the listeners it actually registered even when `events`
74
+ changed while connected.
75
+ - skeleton: a skeleton moved within the page keeps its ready intent.
76
+
10
77
  ## [0.4.0] - 2026-08-11
11
78
 
12
79
  The feedback and status components announce through the shared
@@ -391,6 +458,7 @@ Initial public alpha: 101 behavior-only, accessible Stimulus controllers driven
391
458
  by `data-*` attributes, shipping no CSS. Published to npm (with provenance) and
392
459
  RubyGems.
393
460
 
461
+ [0.5.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.5.0
394
462
  [0.4.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.4.0
395
463
  [0.3.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.3.0
396
464
  [0.2.1]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.2.1
@@ -8,7 +8,7 @@ var AspectRatioController = class extends Controller {
8
8
  };
9
9
  /** Applies the ratio on connect and whenever the value changes. */
10
10
  ratioValueChanged() {
11
- this.element.style.setProperty("--stimeo-aspect-ratio", this.#normalizeRatio(this.ratioValue));
11
+ this.element.style.setProperty("--stimeo--aspect-ratio", this.#normalizeRatio(this.ratioValue));
12
12
  }
13
13
  /**
14
14
  * Normalizes a ratio string to a valid CSS `<ratio>`:
@@ -287,7 +287,11 @@ var BreadcrumbController = class extends Controller {
287
287
  this.ellipsisTarget.hidden = true;
288
288
  return this.listTarget.scrollWidth > this.listTarget.clientWidth + OVERFLOW_EPSILON;
289
289
  }
290
- /** Applies the collapsed/expanded state to the items, ellipsis, and trigger. */
290
+ /**
291
+ * Applies the collapsed/expanded state to the items, ellipsis, and trigger.
292
+ *
293
+ * @stimeoRenderRoot
294
+ */
291
295
  #render() {
292
296
  const collapsed = this.#overflowing && !this.#expanded;
293
297
  const showEllipsis = this.#overflowing && this.collapsibleTargets.length > 0;
@@ -271,7 +271,11 @@ var CarouselController = class extends Controller {
271
271
  this.#syncTimer();
272
272
  if (changed) this.dispatch("change", { detail: { index, total: this.slideTargets.length } });
273
273
  }
274
- /** Reflects `this.#index` onto slides and pickers (state hooks + roving). */
274
+ /**
275
+ * Reflects `this.#index` onto slides and pickers (state hooks + roving).
276
+ *
277
+ * @stimeoRenderRoot
278
+ */
275
279
  #render({ focus }) {
276
280
  this.slideTargets.forEach((slide, i) => {
277
281
  const active = i === this.#index;
@@ -2,6 +2,13 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/clipboard_controller.ts
4
4
 
5
+ // src/utils/default_attribute.ts
6
+ function setDefaultAttribute(element, name, value) {
7
+ if (element.hasAttribute(name)) return false;
8
+ element.setAttribute(name, value);
9
+ return true;
10
+ }
11
+
5
12
  // src/utils/safe_timeout.ts
6
13
  var TimerRegistry = class {
7
14
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -75,9 +82,7 @@ var ClipboardController = class extends Controller {
75
82
  */
76
83
  #resetTimerId = null;
77
84
  connect() {
78
- if (!this.element.hasAttribute("data-state")) {
79
- this.element.setAttribute("data-state", "idle");
80
- }
85
+ setDefaultAttribute(this.element, "data-state", "idle");
81
86
  }
82
87
  disconnect() {
83
88
  this.#timers.clearAll();
@@ -297,7 +297,10 @@ var CollapsibleController = class extends Controller {
297
297
  #applyContent(content, open, waitForCloseTransition) {
298
298
  if (open) {
299
299
  content.hidden = false;
300
- content.style.setProperty("--stimeo-collapsible-content-height", `${content.scrollHeight}px`);
300
+ content.style.setProperty(
301
+ "--stimeo--collapsible-content-height",
302
+ `${content.scrollHeight}px`
303
+ );
301
304
  content.setAttribute("data-state", "open");
302
305
  return;
303
306
  }
@@ -59,7 +59,7 @@ var MicrotaskCoalescer = class {
59
59
  };
60
60
 
61
61
  // src/controllers/color_picker_controller.ts
62
- var COLOR_PROPERTY = "--stimeo-color";
62
+ var COLOR_PROPERTY = "--stimeo--color";
63
63
  var CHANNEL_RANGE = {
64
64
  hue: [0, 360],
65
65
  saturation: [0, 100],
@@ -188,7 +188,11 @@ var ColorPickerController = class extends Controller {
188
188
  this.#color[channel] = Math.round(Math.min(max, Math.max(min, raw)));
189
189
  this.#render();
190
190
  }
191
- /** Reflects the model onto sliders, the hex input, preview, and form field. */
191
+ /**
192
+ * Reflects the model onto sliders, the hex input, preview, and form field.
193
+ *
194
+ * @stimeoRenderRoot
195
+ */
192
196
  #render() {
193
197
  for (const slider of this.sliderTargets) {
194
198
  const channel = this.#channelOf(slider);
@@ -237,8 +237,8 @@ var ContextMenuController = class extends Controller {
237
237
  onDismiss: () => this.#closeAndRestore(),
238
238
  claims: claimsWhileFocusWithin(this.element)
239
239
  });
240
- this.menuTarget.style.setProperty("--stimeo-context-menu-x", `${x}px`);
241
- this.menuTarget.style.setProperty("--stimeo-context-menu-y", `${y}px`);
240
+ this.menuTarget.style.setProperty("--stimeo--context-menu-x", `${x}px`);
241
+ this.menuTarget.style.setProperty("--stimeo--context-menu-y", `${y}px`);
242
242
  this.menuTarget.hidden = false;
243
243
  if (this.hasRegionTarget) this.regionTarget.setAttribute("data-state", "open");
244
244
  this.#navigableItems[0]?.focus();
@@ -268,7 +268,11 @@ var CountdownController = class extends Controller {
268
268
  const raw = this.#isDown ? this.#reference - now : now - this.#reference;
269
269
  return Math.max(0, raw);
270
270
  }
271
- /** Writes the amount into the day/hour/minute/second slots. */
271
+ /**
272
+ * Writes the amount into the day/hour/minute/second slots.
273
+ *
274
+ * @stimeoRenderRoot
275
+ */
272
276
  #render(amount) {
273
277
  const totalSeconds = Math.floor(amount / SECOND_MS);
274
278
  this.#renderedAmount = totalSeconds * SECOND_MS;
@@ -355,7 +355,11 @@ var DateRangePickerController = class extends Controller {
355
355
  this.#focusedDate = target;
356
356
  this.#render();
357
357
  }
358
- /** Builds the six-week grid and binds range/roving/disabled state per cell. */
358
+ /**
359
+ * Builds the six-week grid and binds range/roving/disabled state per cell.
360
+ *
361
+ * @stimeoRenderRoot
362
+ */
359
363
  #render() {
360
364
  const info = parseISOMonthString(this.#viewMonth);
361
365
  if (!info) return;
@@ -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() {
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/flash_controller.ts
4
4
 
5
+ // src/utils/before_cache_reset.ts
6
+ var BeforeCacheReset = class _BeforeCacheReset {
7
+ /** Every subscribed instance, iterated by the one shared document listener. */
8
+ static #subscribers = /* @__PURE__ */ new Set();
9
+ /** The shared listener; installed while at least one instance is subscribed. */
10
+ static #onBeforeCache = () => {
11
+ for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
12
+ };
13
+ #rewind;
14
+ /** @param rewind - the pass that returns this controller's state to its initial form. */
15
+ constructor(rewind) {
16
+ this.#rewind = rewind;
17
+ }
18
+ /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
19
+ activate() {
20
+ const first = _BeforeCacheReset.#subscribers.size === 0;
21
+ _BeforeCacheReset.#subscribers.add(this);
22
+ if (first) {
23
+ document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
24
+ }
25
+ }
26
+ /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
27
+ deactivate() {
28
+ _BeforeCacheReset.#subscribers.delete(this);
29
+ if (_BeforeCacheReset.#subscribers.size > 0) return;
30
+ document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
31
+ }
32
+ };
33
+
5
34
  // src/utils/safe_timeout.ts
6
35
  var TimerRegistry = class {
7
36
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -103,29 +132,119 @@ var FlashController = class extends Controller {
103
132
  static events = ["show", "dismiss"];
104
133
  #timers = new SafeTimeout();
105
134
  #observer = null;
135
+ /** Whether the controller is between `connect()` and `disconnect()`. */
136
+ #connected = false;
106
137
  /** Auto-dismiss timer state keyed by message element. */
107
138
  #state = /* @__PURE__ */ new Map();
108
139
  /** Messages already processed, in insertion order, to enforce `max` and avoid double work. */
109
140
  #order = [];
110
- #onEnter = (event) => this.#pause(event.currentTarget);
111
- #onLeave = (event) => this.#resume(event.currentTarget);
141
+ /**
142
+ * Messages between `leaving` and their removal. {@link FlashController.#beginDismiss}
143
+ * releases the bookkeeping above *before* the transition wait, so for that window the
144
+ * element is in the DOM but in neither collection — without this set a re-scan would
145
+ * read it as a brand-new flash and show it a second time.
146
+ */
147
+ #leaving = /* @__PURE__ */ new Set();
148
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
149
+ #onEnter = (event) => this.#pause(event.currentTarget, event.type === "focusin" ? "focus" : "hover");
150
+ #onLeave = (event) => this.#resume(event.currentTarget, event.type === "focusout" ? "focus" : "hover");
112
151
  connect() {
113
- if (!this.hasRegionTarget) return;
152
+ this.#connected = true;
114
153
  for (const message of this.messageTargets) {
115
- this.#process(message, true);
116
- }
117
- if (typeof MutationObserver !== "undefined") {
118
- this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
119
- this.#observer.observe(this.regionTarget, { childList: true, subtree: true });
154
+ if (this.#owns(message)) this.#process(message, true);
120
155
  }
156
+ this.#syncObservation();
157
+ this.#beforeCache.activate();
121
158
  }
122
159
  disconnect() {
123
- this.#observer?.disconnect();
124
- this.#observer = null;
160
+ this.#connected = false;
161
+ this.#beforeCache.deactivate();
162
+ this.#stopObserving();
125
163
  this.#timers.clearAll();
126
164
  for (const message of this.#order) this.#unbindPause(message);
127
165
  this.#state.clear();
128
166
  this.#order.length = 0;
167
+ this.#leaving.clear();
168
+ }
169
+ /**
170
+ * Takes the managed flashes out of the page just before Turbo freezes it, so a
171
+ * restored snapshot carries no notification the visitor has already received: the
172
+ * fresh `connect()` there reads a leftover flash as a brand-new one and announces it
173
+ * a second time. A message that never auto-dismisses (`duration: 0`) is one of these
174
+ * too — that value governs the timer, not what belongs in a cached page. Removal
175
+ * only: `dismiss` reports a dismissal, and freezing the page is not one.
176
+ */
177
+ #rewindForCache() {
178
+ for (const message of [...this.#order]) {
179
+ message.remove();
180
+ this.#forget(message);
181
+ }
182
+ for (const message of this.#leaving) message.remove();
183
+ this.#leaving.clear();
184
+ }
185
+ /** Follows a `region` element swapped in — or arriving — at runtime (Turbo Stream). */
186
+ regionTargetConnected() {
187
+ this.#resync();
188
+ }
189
+ /** Releases the observation when the `region` element leaves the target set. */
190
+ regionTargetDisconnected() {
191
+ this.#resync();
192
+ }
193
+ /**
194
+ * Whether this controller owns `message`. Ownership is the current `region`'s
195
+ * subtree: a message target anywhere else in the controller's scope is the
196
+ * consumer's, and so is one in a region that has gone away. The initial scan, a
197
+ * re-scan after a `region` swap, and a departure from the target set all resolve
198
+ * ownership through this one test; the observation gets it structurally, by watching
199
+ * that subtree and nothing else.
200
+ */
201
+ #owns(message) {
202
+ return this.hasRegionTarget && this.regionTarget.contains(message);
203
+ }
204
+ /**
205
+ * Releases a message that left the target set (a Turbo Stream `remove`, the consumer
206
+ * detaching the node, or a morph that rewrote the target attribute in place): it
207
+ * stops occupying a `max` slot, and both its pending auto-dismiss and an already
208
+ * scheduled removal are cancelled. A move *within* the region keeps all of them —
209
+ * which is why the element must still be a message to be treated as one: ownership
210
+ * alone reads an in-place attribute rewrite as a move, and a node outside the target
211
+ * set belongs to the consumer, so nothing here may dismiss it.
212
+ */
213
+ messageTargetDisconnected(message) {
214
+ if (!this.#connected) return;
215
+ const moved = this.#owns(message) && message.matches(MESSAGE_SELECTOR);
216
+ if (moved) return;
217
+ this.#forget(message);
218
+ this.#leaving.delete(message);
219
+ }
220
+ /**
221
+ * Re-points the observation after a `region` swap and picks up the messages the
222
+ * new element brought with it (dynamic inserts, so their own `role` announces
223
+ * them). The `#connected` guard is load-bearing: Stimulus runs target callbacks
224
+ * for the initial markup *before* `connect()` and again during teardown *after*
225
+ * `disconnect()`, and re-observing there would outlive the controller.
226
+ */
227
+ #resync() {
228
+ if (!this.#connected) return;
229
+ this.#syncObservation();
230
+ for (const message of this.messageTargets) {
231
+ if (this.#owns(message)) this.#process(message, false);
232
+ }
233
+ }
234
+ /**
235
+ * Points the mutation observation at the current `region` target, re-resolved on
236
+ * every sync rather than captured at connect, so an element swapped in at runtime
237
+ * is observed instead of the detached original.
238
+ */
239
+ #syncObservation() {
240
+ this.#stopObserving();
241
+ if (!this.hasRegionTarget || typeof MutationObserver === "undefined") return;
242
+ this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
243
+ this.#observer.observe(this.regionTarget, { childList: true, subtree: true });
244
+ }
245
+ #stopObserving() {
246
+ this.#observer?.disconnect();
247
+ this.#observer = null;
129
248
  }
130
249
  /**
131
250
  * Pause-on-hover/focus listeners, bound and unbound as a pair so the two sides
@@ -171,6 +290,7 @@ var FlashController = class extends Controller {
171
290
  */
172
291
  #process(message, bridge) {
173
292
  if (this.#state.has(message) || this.#order.includes(message)) return;
293
+ if (this.#leaving.has(message)) return;
174
294
  const type = message.getAttribute("data-flash-type") ?? "";
175
295
  const assertive = ASSERTIVE_TYPES.has(type);
176
296
  if (!message.hasAttribute("role")) {
@@ -203,33 +323,53 @@ var FlashController = class extends Controller {
203
323
  const existing = this.#state.get(message);
204
324
  if (existing?.id) this.#timers.clear(existing.id);
205
325
  const id = this.#timers.set(() => this.#beginDismiss(message, "timeout"), duration);
206
- this.#state.set(message, { id, startedAt: Date.now(), remaining: duration });
326
+ this.#state.set(message, {
327
+ id,
328
+ startedAt: Date.now(),
329
+ remaining: duration,
330
+ paused: existing?.paused ?? /* @__PURE__ */ new Set()
331
+ });
207
332
  }
208
- /** Pauses a message's auto-dismiss, banking the time left (hover/focus, WCAG 2.2.1). */
209
- #pause(message) {
333
+ /**
334
+ * Pauses a message's auto-dismiss, banking the time left (hover/focus, WCAG 2.2.1).
335
+ * Hover and focus are independent reasons: the remaining time is banked on the
336
+ * first of them, and {@link FlashController.#resume} waits for the last one.
337
+ */
338
+ #pause(message, reason) {
210
339
  const timer = this.#state.get(message);
211
- if (!timer || timer.id === 0) return;
340
+ if (!timer) return;
341
+ timer.paused.add(reason);
342
+ if (timer.id === 0) return;
212
343
  this.#timers.clear(timer.id);
213
- const remaining = Math.max(0, timer.remaining - (Date.now() - timer.startedAt));
214
- this.#state.set(message, { id: 0, startedAt: 0, remaining });
344
+ const remaining = Math.max(1, timer.remaining - (Date.now() - timer.startedAt));
345
+ this.#state.set(message, { id: 0, startedAt: 0, remaining, paused: timer.paused });
215
346
  }
216
347
  /** Resumes a paused message's auto-dismiss with the banked time. */
217
- #resume(message) {
348
+ #resume(message, reason) {
218
349
  const timer = this.#state.get(message);
219
350
  if (!timer) return;
220
- if (timer.id !== 0 || timer.remaining <= 0) return;
351
+ timer.paused.delete(reason);
352
+ if (timer.paused.size > 0) return;
353
+ if (timer.id !== 0) return;
221
354
  this.#startTimer(message, timer.remaining);
222
355
  }
223
- /** Marks a message leaving, then removes it after its CSS transition and emits dismiss. */
224
- #beginDismiss(message, reason) {
356
+ /** Releases every per-message resource: timer, stacking slot, pause listeners. */
357
+ #forget(message) {
225
358
  const timer = this.#state.get(message);
226
359
  if (timer?.id) this.#timers.clear(timer.id);
227
360
  this.#state.delete(message);
228
361
  const index = this.#order.indexOf(message);
229
362
  if (index !== -1) this.#order.splice(index, 1);
363
+ this.#unbindPause(message);
364
+ }
365
+ /** Marks a message leaving, then removes it after its CSS transition and emits dismiss. */
366
+ #beginDismiss(message, reason) {
367
+ if (!this.#state.has(message) && !this.#order.includes(message)) return;
368
+ this.#forget(message);
369
+ this.#leaving.add(message);
230
370
  message.setAttribute("data-flash-state", "leaving");
231
371
  const finalize = () => {
232
- this.#unbindPause(message);
372
+ if (!this.#leaving.delete(message)) return;
233
373
  message.remove();
234
374
  this.dispatch("dismiss", { detail: { element: message, reason } });
235
375
  };