stimeo-ui 0.3.0 → 0.4.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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -0
  3. data/dist/controllers/alert_dialog_controller.js +32 -5
  4. data/dist/controllers/announcer_controller.js +255 -20
  5. data/dist/controllers/color_picker_controller.js +46 -0
  6. data/dist/controllers/command_palette_controller.js +32 -5
  7. data/dist/controllers/confirm_controller.js +32 -5
  8. data/dist/controllers/countdown_controller.js +112 -12
  9. data/dist/controllers/date_range_picker_controller.js +50 -0
  10. data/dist/controllers/dialog_controller.js +32 -5
  11. data/dist/controllers/drawer_controller.js +32 -5
  12. data/dist/controllers/empty_state_controller.js +24 -10
  13. data/dist/controllers/focus_controller.js +32 -5
  14. data/dist/controllers/frame_loading_controller.js +177 -15
  15. data/dist/controllers/local_time_controller.js +100 -6
  16. data/dist/controllers/meter_controller.js +145 -26
  17. data/dist/controllers/network_status_controller.js +28 -8
  18. data/dist/controllers/overflow_menu_controller.js +33 -6
  19. data/dist/controllers/progress_controller.js +116 -9
  20. data/dist/controllers/range_slider_controller.js +68 -3
  21. data/dist/controllers/rating_controller.js +53 -0
  22. data/dist/controllers/relative_time_controller.js +133 -12
  23. data/dist/controllers/sidebar_controller.js +37 -8
  24. data/dist/controllers/skeleton_controller.js +73 -20
  25. data/dist/controllers/slider_controller.js +17 -2
  26. data/dist/controllers/spinner_controller.js +228 -27
  27. data/dist/controllers/step_indicator_controller.js +82 -5
  28. data/dist/controllers/stick_to_bottom_controller.js +60 -10
  29. data/dist/index.js +1056 -281
  30. data/lib/stimeo/ui/version.rb +1 -1
  31. metadata +2 -2
@@ -2,6 +2,56 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/countdown_controller.ts
4
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/microtask_coalescer.ts
23
+ var MicrotaskCoalescer = class {
24
+ #run;
25
+ #queued = false;
26
+ #active = false;
27
+ #generation = 0;
28
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
29
+ constructor(run) {
30
+ this.#run = run;
31
+ }
32
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
33
+ activate() {
34
+ this.#active = true;
35
+ }
36
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
37
+ cancel() {
38
+ this.#active = false;
39
+ this.#queued = false;
40
+ this.#generation += 1;
41
+ }
42
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
43
+ schedule() {
44
+ if (!this.#active || this.#queued) return;
45
+ this.#queued = true;
46
+ const generation = this.#generation;
47
+ queueMicrotask(() => {
48
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
49
+ this.#queued = false;
50
+ this.#run();
51
+ });
52
+ }
53
+ };
54
+
5
55
  // src/utils/safe_timeout.ts
6
56
  var TimerRegistry = class {
7
57
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -48,6 +98,7 @@ var SafeInterval = class extends TimerRegistry {
48
98
  };
49
99
 
50
100
  // src/controllers/countdown_controller.ts
101
+ var SECOND_MS = 1e3;
51
102
  var CountdownController = class extends Controller {
52
103
  static targets = ["days", "hours", "minutes", "seconds", "status"];
53
104
  static values = {
@@ -55,34 +106,79 @@ var CountdownController = class extends Controller {
55
106
  interval: { type: Number, default: 1e3 },
56
107
  direction: { type: String, default: "down" },
57
108
  autostart: { type: Boolean, default: true },
58
- completeLabel: { type: String, default: "" }
109
+ completeLabel: { type: String, default: "" },
110
+ announceText: { type: String, default: "" }
59
111
  };
60
112
  static actions = ["pause", "reset", "resume", "start"];
61
113
  static events = ["complete", "tick"];
62
114
  #intervals = new SafeInterval();
63
115
  #intervalId = null;
116
+ /** Collapses a morph that swaps several render inputs at once into one re-derive. */
117
+ #resync = new MicrotaskCoalescer(() => this.#resyncToValues());
64
118
  /** Epoch-ms anchor: the deadline (down) or the count-up origin (up). */
65
119
  #reference = 0;
66
120
  /** Amount (ms) captured at pause, so resume can restore the same display. */
67
121
  #pausedAmount = 0;
122
+ /**
123
+ * The amount the slots are currently showing, floored to the second they render.
124
+ * It lags {@link currentAmount} by up to one tick, and it — not the live reading —
125
+ * is what a pause has to preserve: storing the fraction behind the display instead
126
+ * makes the first tick after a resume step by two units.
127
+ */
128
+ #renderedAmount = 0;
68
129
  connect() {
130
+ this.#resync.activate();
69
131
  this.#initReference();
70
- this.#render(this.#currentAmount());
71
- if (this.autostartValue && this.#isValidDeadline) {
72
- this.start();
73
- } else {
132
+ const amount = this.#currentAmount();
133
+ this.#render(amount);
134
+ this.#pausedAmount = this.#renderedAmount;
135
+ const authored = this.element.getAttribute("data-state");
136
+ if (authored === null) {
137
+ if (this.autostartValue && this.#isValidDeadline) {
138
+ this.start();
139
+ return;
140
+ }
74
141
  this.element.setAttribute("data-state", "paused");
142
+ return;
143
+ }
144
+ if (authored === "complete" && this.#isDown && amount <= 0) {
145
+ return;
75
146
  }
147
+ const wasRunning = authored === "running";
148
+ this.element.setAttribute("data-state", "paused");
149
+ if (wasRunning) this.start();
76
150
  }
77
151
  disconnect() {
152
+ this.#resync.cancel();
78
153
  this.#intervals.clearAll();
79
154
  this.#intervalId = null;
80
155
  }
156
+ /** Re-derives the display when a morph swaps the deadline in place. */
157
+ deadlineValueChanged() {
158
+ this.#resync.schedule();
159
+ }
160
+ /** Re-derives the display when a morph flips the counting direction in place. */
161
+ directionValueChanged() {
162
+ this.#resync.schedule();
163
+ }
164
+ /**
165
+ * Points the anchor at the current `deadline` / `direction` and repaints.
166
+ *
167
+ * Render only: it starts no interval and emits no event, so a morph cannot make a
168
+ * paused timer run or replay a milestone. A running one needs no restart either —
169
+ * every tick reads the anchor, so moving it is enough. While paused the stored
170
+ * amount follows the new reading, or resume would continue from the old deadline.
171
+ */
172
+ #resyncToValues() {
173
+ this.#initReference();
174
+ this.#render(this.#currentAmount());
175
+ if (this.#state !== "running") this.#pausedAmount = this.#renderedAmount;
176
+ }
81
177
  /** Starts (or restarts after pause) ticking toward the deadline. */
82
178
  start() {
83
179
  if (this.#state === "running" || !this.#isValidDeadline) return;
84
180
  if (this.#isDown && this.#currentAmount() <= 0) {
85
- this.#complete();
181
+ if (this.#state !== "complete") this.#complete();
86
182
  return;
87
183
  }
88
184
  this.#runInterval();
@@ -90,7 +186,7 @@ var CountdownController = class extends Controller {
90
186
  /** Pauses ticking, preserving the currently displayed amount. */
91
187
  pause() {
92
188
  if (this.#state !== "running") return;
93
- this.#pausedAmount = this.#currentAmount();
189
+ this.#pausedAmount = this.#renderedAmount;
94
190
  this.#teardownInterval();
95
191
  this.element.setAttribute("data-state", "paused");
96
192
  }
@@ -106,8 +202,8 @@ var CountdownController = class extends Controller {
106
202
  * run state**: a running timer keeps counting down from the reset amount, while a
107
203
  * paused (or completed) one resets the displayed amount but stays paused until the
108
204
  * user resumes — it never silently restarts. The run state is read from the DOM,
109
- * not re-derived from the declarative `autostart` Value (which only governs the
110
- * initial state on connect); re-deriving it would override a user's pause —
205
+ * not re-derived from the declarative `autostart` Value (which governs only markup
206
+ * that states no run state at all); re-deriving it would override a user's pause —
111
207
  * the DOM, not a re-run of declarative config, is the source of truth.
112
208
  */
113
209
  reset() {
@@ -116,13 +212,15 @@ var CountdownController = class extends Controller {
116
212
  this.#initReference();
117
213
  const amount = this.#currentAmount();
118
214
  this.#render(amount);
119
- if (this.hasStatusTarget) this.statusTarget.textContent = "";
215
+ if (this.hasStatusTarget && this.statusTarget.textContent === this.completeLabelValue) {
216
+ this.statusTarget.textContent = "";
217
+ }
120
218
  this.element.setAttribute("data-state", "paused");
121
219
  if (wasRunning && this.#isValidDeadline) {
122
220
  this.#pausedAmount = 0;
123
221
  this.start();
124
222
  } else {
125
- this.#pausedAmount = amount;
223
+ this.#pausedAmount = this.#renderedAmount;
126
224
  }
127
225
  }
128
226
  /** Schedules the repeating tick and marks the timer running. */
@@ -157,6 +255,7 @@ var CountdownController = class extends Controller {
157
255
  this.statusTarget.textContent = this.completeLabelValue;
158
256
  }
159
257
  this.dispatch("complete", { detail: {} });
258
+ announce(fillTemplate(this.announceTextValue, {}));
160
259
  }
161
260
  /** Sets the time anchor from the `deadline` value. */
162
261
  #initReference() {
@@ -171,7 +270,8 @@ var CountdownController = class extends Controller {
171
270
  }
172
271
  /** Writes the amount into the day/hour/minute/second slots. */
173
272
  #render(amount) {
174
- const totalSeconds = Math.floor(amount / 1e3);
273
+ const totalSeconds = Math.floor(amount / SECOND_MS);
274
+ this.#renderedAmount = totalSeconds * SECOND_MS;
175
275
  const days = Math.floor(totalSeconds / 86400);
176
276
  const hours = Math.floor(totalSeconds % 86400 / 3600);
177
277
  const minutes = Math.floor(totalSeconds % 3600 / 60);
@@ -52,6 +52,39 @@ function toISOMonthString(date) {
52
52
  return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
53
53
  }
54
54
 
55
+ // src/utils/microtask_coalescer.ts
56
+ var MicrotaskCoalescer = class {
57
+ #run;
58
+ #queued = false;
59
+ #active = false;
60
+ #generation = 0;
61
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
62
+ constructor(run) {
63
+ this.#run = run;
64
+ }
65
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
66
+ activate() {
67
+ this.#active = true;
68
+ }
69
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
70
+ cancel() {
71
+ this.#active = false;
72
+ this.#queued = false;
73
+ this.#generation += 1;
74
+ }
75
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
76
+ schedule() {
77
+ if (!this.#active || this.#queued) return;
78
+ this.#queued = true;
79
+ const generation = this.#generation;
80
+ queueMicrotask(() => {
81
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
82
+ this.#queued = false;
83
+ this.#run();
84
+ });
85
+ }
86
+ };
87
+
55
88
  // src/utils/safe_timeout.ts
56
89
  var TimerRegistry = class {
57
90
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -129,7 +162,15 @@ var DateRangePickerController = class extends Controller {
129
162
  /** Deferred focus after an async month transition (cancelled on teardown). */
130
163
  #focusTimer = new SafeTimeout();
131
164
  /** Seeds the range from any pre-filled hidden fields and renders the grid. */
165
+ /**
166
+ * Collapses a morph that swaps render inputs into one repaint, and refuses the
167
+ * pass Stimulus delivers before `connect()`.
168
+ */
169
+ #repaint = new MicrotaskCoalescer(() => {
170
+ this.#render();
171
+ });
132
172
  connect() {
173
+ this.#repaint.activate();
133
174
  this.#startDate = this.hasStartFieldTarget ? normalizeISO(this.startFieldTarget.value) : "";
134
175
  this.#endDate = this.hasEndFieldTarget ? normalizeISO(this.endFieldTarget.value) : "";
135
176
  const anchor = parseISODateString(this.#startDate) ?? this.#clampToBounds(/* @__PURE__ */ new Date()) ?? /* @__PURE__ */ new Date();
@@ -140,8 +181,17 @@ var DateRangePickerController = class extends Controller {
140
181
  }
141
182
  /** Cancels any pending deferred focus so it never fires on a detached element. */
142
183
  disconnect() {
184
+ this.#repaint.cancel();
143
185
  this.#focusTimer.clearAll();
144
186
  }
187
+ /** Repaints when application code (or a Turbo morph) changes `min` at runtime. */
188
+ minValueChanged() {
189
+ this.#repaint.schedule();
190
+ }
191
+ /** Repaints when application code (or a Turbo morph) changes `max` at runtime. */
192
+ maxValueChanged() {
193
+ this.#repaint.schedule();
194
+ }
145
195
  /** Navigates to the previous month. */
146
196
  prev(event) {
147
197
  event?.preventDefault();
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/dialog_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,35 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/drawer_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
@@ -1,11 +1,31 @@
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;
@@ -13,10 +33,6 @@ var EmptyStateController = class extends Controller {
13
33
  #empty = null;
14
34
  connect() {
15
35
  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
36
  if (typeof MutationObserver !== "undefined") {
21
37
  this.#observer = new MutationObserver(() => this.#apply());
22
38
  this.#observer.observe(this.listTarget, { childList: true });
@@ -42,6 +58,9 @@ var EmptyStateController = class extends Controller {
42
58
  if (this.hasEmptyTarget) this.emptyTarget.hidden = !empty;
43
59
  if (this.#empty !== null && empty !== this.#empty) {
44
60
  this.dispatch("change", { detail: { count, empty } });
61
+ announce(
62
+ fillTemplate(empty ? this.announceTextValue : this.announceFilledTextValue, { count })
63
+ );
45
64
  }
46
65
  this.#empty = empty;
47
66
  }
@@ -55,11 +74,6 @@ var EmptyStateController = class extends Controller {
55
74
  return this.listTarget.childElementCount;
56
75
  }
57
76
  }
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
77
  };
64
78
 
65
79
  export { EmptyStateController };
@@ -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