stimeo-ui 0.3.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +124 -0
  3. data/dist/controllers/alert_dialog_controller.js +32 -5
  4. data/dist/controllers/announcer_controller.js +255 -20
  5. data/dist/controllers/aspect_ratio_controller.js +1 -1
  6. data/dist/controllers/breadcrumb_controller.js +5 -1
  7. data/dist/controllers/carousel_controller.js +5 -1
  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 +52 -2
  11. data/dist/controllers/command_palette_controller.js +32 -5
  12. data/dist/controllers/confirm_controller.js +32 -5
  13. data/dist/controllers/context_menu_controller.js +2 -2
  14. data/dist/controllers/countdown_controller.js +117 -13
  15. data/dist/controllers/date_range_picker_controller.js +55 -1
  16. data/dist/controllers/dialog_controller.js +32 -5
  17. data/dist/controllers/direct_upload_controller.js +3 -3
  18. data/dist/controllers/drawer_controller.js +32 -5
  19. data/dist/controllers/empty_state_controller.js +128 -23
  20. data/dist/controllers/flash_controller.js +161 -21
  21. data/dist/controllers/focus_controller.js +32 -5
  22. data/dist/controllers/form_validation_controller.js +8 -2
  23. data/dist/controllers/frame_loading_controller.js +261 -27
  24. data/dist/controllers/highlight_controller.js +38 -1
  25. data/dist/controllers/idle_controller.js +13 -2
  26. data/dist/controllers/local_time_controller.js +102 -6
  27. data/dist/controllers/masonry_controller.js +1 -1
  28. data/dist/controllers/meter_controller.js +147 -26
  29. data/dist/controllers/network_status_controller.js +29 -11
  30. data/dist/controllers/number_input_controller.js +191 -24
  31. data/dist/controllers/overflow_menu_controller.js +34 -7
  32. data/dist/controllers/pagination_controller.js +5 -1
  33. data/dist/controllers/password_strength_controller.js +1 -1
  34. data/dist/controllers/pointer_drag_controller.js +10 -0
  35. data/dist/controllers/portal_controller.js +10 -0
  36. data/dist/controllers/progress_controller.js +123 -12
  37. data/dist/controllers/range_slider_controller.js +449 -93
  38. data/dist/controllers/rating_controller.js +55 -0
  39. data/dist/controllers/relative_time_controller.js +135 -12
  40. data/dist/controllers/scroll_area_controller.js +1 -1
  41. data/dist/controllers/separator_controller.js +13 -17
  42. data/dist/controllers/sidebar_controller.js +37 -8
  43. data/dist/controllers/skeleton_controller.js +143 -22
  44. data/dist/controllers/slider_controller.js +342 -50
  45. data/dist/controllers/spinner_controller.js +244 -28
  46. data/dist/controllers/step_indicator_controller.js +85 -6
  47. data/dist/controllers/stepper_controller.js +2 -0
  48. data/dist/controllers/stick_to_bottom_controller.js +60 -10
  49. data/dist/controllers/switch_controller.js +162 -18
  50. data/dist/controllers/textarea_autosize_controller.js +1 -1
  51. data/dist/controllers/time_picker_controller.js +6 -3
  52. data/dist/controllers/tree_view_controller.js +19 -1
  53. data/dist/index.js +2278 -596
  54. data/lib/stimeo/ui/version.rb +1 -1
  55. metadata +2 -2
@@ -1,35 +1,148 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
+ // src/controllers/empty_state_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
+
3
22
  // src/controllers/empty_state_controller.ts
4
23
  var EmptyStateController = class extends Controller {
5
24
  static targets = ["list", "empty"];
6
25
  static values = {
7
26
  itemSelector: { type: String, default: "" },
8
- announce: { type: Boolean, default: false }
27
+ announceText: { type: String, default: "" },
28
+ announceFilledText: { type: String, default: "" }
9
29
  };
10
30
  static events = ["change"];
11
31
  #observer = null;
32
+ /** Whether the controller is between `connect()` and `disconnect()`. */
33
+ #connected = false;
12
34
  /** Last applied empty state; `null` until the first sync so connect emits nothing. */
13
35
  #empty = null;
14
36
  connect() {
15
- if (!this.hasListTarget) return;
16
- if (this.announceValue && this.hasEmptyTarget && !this.#isLiveRegion(this.emptyTarget)) {
17
- this.emptyTarget.setAttribute("role", "status");
18
- this.emptyTarget.setAttribute("aria-live", "polite");
19
- }
20
- if (typeof MutationObserver !== "undefined") {
21
- this.#observer = new MutationObserver(() => this.#apply());
22
- this.#observer.observe(this.listTarget, { childList: true });
23
- }
24
- this.#apply();
37
+ this.#connected = true;
38
+ this.#syncObservation();
39
+ this.#update();
25
40
  }
26
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() {
27
108
  this.#observer?.disconnect();
28
109
  this.#observer = null;
29
110
  }
30
- /** Recomputes the count and syncs visibility, hooks, and the change event. */
31
- #apply() {
32
- 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;
33
146
  const count = this.#count();
34
147
  const empty = count === 0;
35
148
  this.element.setAttribute("data-count", String(count));
@@ -40,10 +153,7 @@ var EmptyStateController = class extends Controller {
40
153
  }
41
154
  this.listTarget.hidden = empty;
42
155
  if (this.hasEmptyTarget) this.emptyTarget.hidden = !empty;
43
- if (this.#empty !== null && empty !== this.#empty) {
44
- this.dispatch("change", { detail: { count, empty } });
45
- }
46
- this.#empty = empty;
156
+ return count;
47
157
  }
48
158
  /** Item count: element children matching `itemSelector`, or all element children. */
49
159
  #count() {
@@ -55,11 +165,6 @@ var EmptyStateController = class extends Controller {
55
165
  return this.listTarget.childElementCount;
56
166
  }
57
167
  }
58
- #isLiveRegion(el) {
59
- if (el.hasAttribute("aria-live")) return true;
60
- const role = el.getAttribute("role");
61
- return role === "status" || role === "alert";
62
- }
63
168
  };
64
169
 
65
170
  export { EmptyStateController };
@@ -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
  };
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/focus_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/escape_layer.ts
6
35
  var EscapeLayer = class _EscapeLayer {
7
36
  static #registries = /* @__PURE__ */ new WeakMap();
@@ -135,7 +164,7 @@ var FocusTrap = class {
135
164
  }
136
165
  if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
137
166
  document.addEventListener("keydown", this.#onKeydown);
138
- document.addEventListener("turbo:before-cache", this.#onBeforeCache);
167
+ this.#beforeCache.activate();
139
168
  const onEscape = this.#options.onEscape;
140
169
  if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
141
170
  if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
@@ -152,7 +181,7 @@ var FocusTrap = class {
152
181
  this.#activeState = false;
153
182
  this.#escapeLayer.deactivate();
154
183
  document.removeEventListener("keydown", this.#onKeydown);
155
- document.removeEventListener("turbo:before-cache", this.#onBeforeCache);
184
+ this.#beforeCache.deactivate();
156
185
  if (this.#scrollLocked) {
157
186
  document.body.style.overflow = this.#previousBodyOverflow;
158
187
  this.#scrollLocked = false;
@@ -176,9 +205,7 @@ var FocusTrap = class {
176
205
  * untouched (restore-open designs reopen against a clean baseline), and focus
177
206
  * is left alone mid-navigation. The listener lives only while active.
178
207
  */
179
- #onBeforeCache = () => {
180
- this.deactivate({ restoreFocus: false });
181
- };
208
+ #beforeCache = new BeforeCacheReset(() => this.deactivate({ restoreFocus: false }));
182
209
  /**
183
210
  * Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
184
211
  * shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
@@ -2,6 +2,13 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/form_validation_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/focus_trap.ts
6
13
  var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
7
14
 
@@ -79,8 +86,7 @@ var FormValidationController = class _FormValidationController extends Controlle
79
86
  };
80
87
  /** Suppresses native bubbles and binds the submit / blur / input listeners. */
81
88
  connect() {
82
- if (!this.element.hasAttribute("novalidate")) {
83
- this.element.setAttribute("novalidate", "");
89
+ if (setDefaultAttribute(this.element, "novalidate", "")) {
84
90
  this.element.setAttribute(_FormValidationController.#NOVALIDATE_MARKER, "");
85
91
  }
86
92
  document.addEventListener("submit", this.#onSubmit, true);