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.
- package/CHANGELOG.md +56 -0
- package/dist/controllers/alert_dialog_controller.js +32 -5
- package/dist/controllers/alert_dialog_controller.js.map +1 -1
- package/dist/controllers/announcer_controller.d.ts +32 -6
- package/dist/controllers/announcer_controller.js +255 -20
- package/dist/controllers/announcer_controller.js.map +1 -1
- package/dist/controllers/color_picker_controller.d.ts +2 -1
- package/dist/controllers/color_picker_controller.js +46 -0
- package/dist/controllers/color_picker_controller.js.map +1 -1
- package/dist/controllers/command_palette_controller.js +32 -5
- package/dist/controllers/command_palette_controller.js.map +1 -1
- package/dist/controllers/confirm_controller.js +32 -5
- package/dist/controllers/confirm_controller.js.map +1 -1
- package/dist/controllers/countdown_controller.d.ts +19 -5
- package/dist/controllers/countdown_controller.js +112 -12
- package/dist/controllers/countdown_controller.js.map +1 -1
- package/dist/controllers/date_range_picker_controller.d.ts +4 -1
- package/dist/controllers/date_range_picker_controller.js +50 -0
- package/dist/controllers/date_range_picker_controller.js.map +1 -1
- package/dist/controllers/dialog_controller.js +32 -5
- package/dist/controllers/dialog_controller.js.map +1 -1
- package/dist/controllers/dismissible_controller.js.map +1 -1
- package/dist/controllers/drawer_controller.js +32 -5
- package/dist/controllers/drawer_controller.js.map +1 -1
- package/dist/controllers/empty_state_controller.d.ts +9 -4
- package/dist/controllers/empty_state_controller.js +24 -10
- package/dist/controllers/empty_state_controller.js.map +1 -1
- package/dist/controllers/focus_controller.js +32 -5
- package/dist/controllers/focus_controller.js.map +1 -1
- package/dist/controllers/form_validation_controller.js.map +1 -1
- package/dist/controllers/frame_loading_controller.d.ts +14 -3
- package/dist/controllers/frame_loading_controller.js +177 -15
- package/dist/controllers/frame_loading_controller.js.map +1 -1
- package/dist/controllers/local_time_controller.d.ts +19 -3
- package/dist/controllers/local_time_controller.js +100 -6
- package/dist/controllers/local_time_controller.js.map +1 -1
- package/dist/controllers/meter_controller.d.ts +22 -2
- package/dist/controllers/meter_controller.js +145 -26
- package/dist/controllers/meter_controller.js.map +1 -1
- package/dist/controllers/network_status_controller.d.ts +13 -1
- package/dist/controllers/network_status_controller.js +28 -8
- package/dist/controllers/network_status_controller.js.map +1 -1
- package/dist/controllers/overflow_menu_controller.js +33 -6
- package/dist/controllers/overflow_menu_controller.js.map +1 -1
- package/dist/controllers/progress_controller.d.ts +16 -1
- package/dist/controllers/progress_controller.js +116 -9
- package/dist/controllers/progress_controller.js.map +1 -1
- package/dist/controllers/range_slider_controller.d.ts +4 -1
- package/dist/controllers/range_slider_controller.js +68 -3
- package/dist/controllers/range_slider_controller.js.map +1 -1
- package/dist/controllers/rating_controller.d.ts +4 -1
- package/dist/controllers/rating_controller.js +53 -0
- package/dist/controllers/rating_controller.js.map +1 -1
- package/dist/controllers/relative_time_controller.d.ts +13 -0
- package/dist/controllers/relative_time_controller.js +133 -12
- package/dist/controllers/relative_time_controller.js.map +1 -1
- package/dist/controllers/sidebar_controller.d.ts +1 -11
- package/dist/controllers/sidebar_controller.js +37 -8
- package/dist/controllers/sidebar_controller.js.map +1 -1
- package/dist/controllers/skeleton_controller.d.ts +7 -2
- package/dist/controllers/skeleton_controller.js +73 -20
- package/dist/controllers/skeleton_controller.js.map +1 -1
- package/dist/controllers/slider_controller.js +17 -2
- package/dist/controllers/slider_controller.js.map +1 -1
- package/dist/controllers/spinner_controller.d.ts +37 -4
- package/dist/controllers/spinner_controller.js +228 -27
- package/dist/controllers/spinner_controller.js.map +1 -1
- package/dist/controllers/step_indicator_controller.d.ts +12 -1
- package/dist/controllers/step_indicator_controller.js +82 -5
- package/dist/controllers/step_indicator_controller.js.map +1 -1
- package/dist/controllers/stick_to_bottom_controller.d.ts +26 -2
- package/dist/controllers/stick_to_bottom_controller.js +60 -10
- package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
- package/dist/index.js +1056 -281
- package/dist/index.js.map +1 -1
- package/dist/inspector/cli.js +785 -76
- package/dist/inspector/cli.js.map +1 -1
- package/dist/inspector/cli_bin.js +785 -76
- package/dist/inspector/cli_bin.js.map +1 -1
- package/dist/inspector/examples.json +5 -5
- package/dist/inspector/manifest.json +21 -8
- package/package.json +1 -1
|
@@ -2,6 +2,68 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/relative_time_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
|
+
|
|
34
|
+
// src/utils/microtask_coalescer.ts
|
|
35
|
+
var MicrotaskCoalescer = class {
|
|
36
|
+
#run;
|
|
37
|
+
#queued = false;
|
|
38
|
+
#active = false;
|
|
39
|
+
#generation = 0;
|
|
40
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
41
|
+
constructor(run) {
|
|
42
|
+
this.#run = run;
|
|
43
|
+
}
|
|
44
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
45
|
+
activate() {
|
|
46
|
+
this.#active = true;
|
|
47
|
+
}
|
|
48
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
49
|
+
cancel() {
|
|
50
|
+
this.#active = false;
|
|
51
|
+
this.#queued = false;
|
|
52
|
+
this.#generation += 1;
|
|
53
|
+
}
|
|
54
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
55
|
+
schedule() {
|
|
56
|
+
if (!this.#active || this.#queued) return;
|
|
57
|
+
this.#queued = true;
|
|
58
|
+
const generation = this.#generation;
|
|
59
|
+
queueMicrotask(() => {
|
|
60
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
61
|
+
this.#queued = false;
|
|
62
|
+
this.#run();
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
5
67
|
// src/utils/safe_timeout.ts
|
|
6
68
|
var TimerRegistry = class {
|
|
7
69
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -73,11 +135,16 @@ var RelativeTimeController = class extends Controller {
|
|
|
73
135
|
tickInterval: { type: Number, default: 6e4 }
|
|
74
136
|
};
|
|
75
137
|
#timers = new SafeTimeout();
|
|
138
|
+
/** Collapses a morph that swaps several render inputs at once into one repaint. */
|
|
139
|
+
#resync = new MicrotaskCoalescer(() => this.#resyncToValues());
|
|
140
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
76
141
|
/** Epoch ms parsed from `datetime`; `NaN` when absent or invalid. */
|
|
77
142
|
#targetMs = Number.NaN;
|
|
78
143
|
/** The authored absolute text, restored when the threshold fallback kicks in. */
|
|
79
144
|
#absoluteText = "";
|
|
80
145
|
connect() {
|
|
146
|
+
this.#resync.activate();
|
|
147
|
+
this.#beforeCache.activate();
|
|
81
148
|
if (this.element.getAttribute("data-state") !== "relative") {
|
|
82
149
|
this.#absoluteText = (this.element.textContent ?? "").trim();
|
|
83
150
|
}
|
|
@@ -86,9 +153,50 @@ var RelativeTimeController = class extends Controller {
|
|
|
86
153
|
this.#schedule();
|
|
87
154
|
}
|
|
88
155
|
disconnect() {
|
|
156
|
+
this.#resync.cancel();
|
|
157
|
+
this.#beforeCache.deactivate();
|
|
158
|
+
this.#timers.clearAll();
|
|
159
|
+
}
|
|
160
|
+
/** Repaints when application code (or a Turbo morph) changes `locale` at runtime. */
|
|
161
|
+
localeValueChanged() {
|
|
162
|
+
this.#resync.schedule();
|
|
163
|
+
}
|
|
164
|
+
/** Repaints when application code (or a Turbo morph) changes `threshold` at runtime. */
|
|
165
|
+
thresholdValueChanged() {
|
|
166
|
+
this.#resync.schedule();
|
|
167
|
+
}
|
|
168
|
+
/** Repaints when application code (or a Turbo morph) changes `tickInterval` at runtime. */
|
|
169
|
+
tickIntervalValueChanged() {
|
|
170
|
+
this.#resync.schedule();
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Renders against the current Values and re-arms the poll from now.
|
|
174
|
+
*
|
|
175
|
+
* Render only: it emits no event, and clearing first keeps the single self-arming
|
|
176
|
+
* timer single — scheduling on top of a pending one would double the poll rate for
|
|
177
|
+
* the rest of the session. A stamp whose `datetime` never parsed has nothing to
|
|
178
|
+
* render, and one that already reached its terminal fallback simply renders it
|
|
179
|
+
* again and stops.
|
|
180
|
+
*/
|
|
181
|
+
#resyncToValues() {
|
|
182
|
+
if (Number.isNaN(this.#targetMs)) return;
|
|
89
183
|
this.#timers.clearAll();
|
|
184
|
+
this.#schedule();
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Restores the authored absolute text and the pre-render state for the snapshot
|
|
188
|
+
* Turbo is about to take, leaving the live page's poll timer alone.
|
|
189
|
+
*
|
|
190
|
+
* With no authored text held there is nothing to restore, and `data-state` has to
|
|
191
|
+
* stay as it is: that marker is what tells the next `connect()` the visible text is
|
|
192
|
+
* a rendered relative form rather than an absolute fallback to hold on to.
|
|
193
|
+
*/
|
|
194
|
+
#rewindForCache() {
|
|
195
|
+
if (!this.#absoluteText) return;
|
|
196
|
+
this.element.textContent = this.#absoluteText;
|
|
197
|
+
this.element.removeAttribute("data-state");
|
|
90
198
|
}
|
|
91
|
-
/** Renders the current representation and reschedules unless
|
|
199
|
+
/** Renders the current representation and reschedules unless polling can stop. */
|
|
92
200
|
#schedule() {
|
|
93
201
|
const nextDelay = this.#applyAndComputeDelay();
|
|
94
202
|
if (nextDelay !== null) {
|
|
@@ -96,31 +204,44 @@ var RelativeTimeController = class extends Controller {
|
|
|
96
204
|
}
|
|
97
205
|
}
|
|
98
206
|
/**
|
|
99
|
-
* Updates the visible text and returns the next poll delay (ms), or `null`
|
|
100
|
-
*
|
|
207
|
+
* Updates the visible text and returns the next poll delay (ms), or `null` when
|
|
208
|
+
* polling can stop: a *past* timestamp that fell back to the absolute text can
|
|
209
|
+
* never leave it again, and a locale the runtime rejects has nothing to render
|
|
210
|
+
* until that value is corrected.
|
|
101
211
|
*/
|
|
102
212
|
#applyAndComputeDelay() {
|
|
103
213
|
const deltaMs = this.#targetMs - Date.now();
|
|
104
214
|
const absSeconds = Math.abs(deltaMs) / 1e3;
|
|
215
|
+
const scale = UNITS.find((u) => absSeconds < u.limit) ?? YEAR_SCALE;
|
|
216
|
+
const unitFloor = scale.unit === "second" || scale.unit === "minute" ? 6e4 : scale.ms;
|
|
217
|
+
const nextDelay = Math.max(this.tickIntervalValue, Math.min(unitFloor, 864e5));
|
|
105
218
|
if (this.thresholdValue > 0 && absSeconds >= this.thresholdValue && this.#absoluteText) {
|
|
106
219
|
this.element.textContent = this.#absoluteText;
|
|
107
220
|
this.element.setAttribute("data-state", "absolute");
|
|
108
|
-
return null;
|
|
221
|
+
if (deltaMs <= 0) return null;
|
|
222
|
+
return Math.min(nextDelay, deltaMs - this.thresholdValue * 1e3 + 1);
|
|
109
223
|
}
|
|
110
|
-
const
|
|
224
|
+
const formatter = this.#formatter;
|
|
225
|
+
if (formatter === null) return null;
|
|
111
226
|
const value = Math.round(deltaMs / scale.ms);
|
|
112
|
-
this.element.textContent =
|
|
227
|
+
this.element.textContent = formatter.format(value, scale.unit);
|
|
113
228
|
this.element.setAttribute("data-state", "relative");
|
|
114
|
-
|
|
115
|
-
return Math.max(this.tickIntervalValue, Math.min(unitFloor, 864e5));
|
|
229
|
+
return nextDelay;
|
|
116
230
|
}
|
|
117
|
-
/**
|
|
231
|
+
/**
|
|
232
|
+
* A `RelativeTimeFormat` for the resolved locale (`numeric: "auto"`), or `null`
|
|
233
|
+
* when the runtime rejects that locale.
|
|
234
|
+
*/
|
|
118
235
|
get #formatter() {
|
|
119
|
-
|
|
236
|
+
try {
|
|
237
|
+
return new Intl.RelativeTimeFormat(this.#locale, { numeric: "auto" });
|
|
238
|
+
} catch {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
120
241
|
}
|
|
121
|
-
/** Locale precedence: the value, then the
|
|
242
|
+
/** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */
|
|
122
243
|
get #locale() {
|
|
123
|
-
return this.localeValue || this.element.lang
|
|
244
|
+
return this.localeValue || this.element.closest("[lang]")?.getAttribute("lang") || void 0;
|
|
124
245
|
}
|
|
125
246
|
};
|
|
126
247
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/utils/safe_timeout.ts","../../src/controllers/relative_time_controller.ts"],"names":[],"mappings":"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","file":"relative_time_controller.js","sourcesContent":["/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\n\n/** A time scale: the upper bound (seconds) it covers and its `Intl` unit/divisor. */\ninterface TimeScale {\n limit: number;\n unit: Intl.RelativeTimeFormatUnit;\n ms: number;\n}\n\n/** Coarsest scale, used as the fallback for anything a year or older. */\nconst YEAR_SCALE: TimeScale = { limit: Number.POSITIVE_INFINITY, unit: \"year\", ms: 31_557_600_000 };\n\n/** Boundaries (in seconds) and the `Intl` unit/divisor used at each scale. */\nconst UNITS: readonly TimeScale[] = [\n { limit: 60, unit: \"second\", ms: 1000 },\n { limit: 3600, unit: \"minute\", ms: 60_000 },\n { limit: 86_400, unit: \"hour\", ms: 3_600_000 },\n { limit: 604_800, unit: \"day\", ms: 86_400_000 },\n { limit: 2_629_800, unit: \"week\", ms: 604_800_000 },\n { limit: 31_557_600, unit: \"month\", ms: 2_629_800_000 },\n YEAR_SCALE,\n];\n\n/**\n * Headless relative-time behavior: renders an absolute timestamp as \"3 minutes\n * ago\" / \"in 2 days\" and keeps it fresh. No dedicated APG pattern; it follows\n * the HTML `<time>` semantics.\n *\n * Markup contract (identifier: `stimeo--relative-time`):\n * <time data-controller=\"stimeo--relative-time\"\n * datetime=\"2026-05-30T12:00:00+09:00\" title=\"2026-05-30 12:00\"\n * data-stimeo--relative-time-locale-value=\"ja\">2026-05-30 12:00</time>\n *\n * Computes the difference from `datetime` to now and formats it with\n * `Intl.RelativeTimeFormat` (a browser standard — no added dependency). The\n * polling interval widens as the timestamp ages (seconds → minutes → hours →\n * days). Past a `threshold`, it falls back to the authored absolute text.\n *\n * @remarks\n * Behavior only. The machine-readable `datetime` attribute is left untouched\n * while only the visible text updates, and the element is intentionally **not**\n * a live region (silent updates, no announcement interruptions). The polling\n * timer is owned by {@link SafeTimeout} and torn down on `disconnect()` (Turbo\n * navigation included).\n */\nexport class RelativeTimeController extends Controller<HTMLElement> {\n static override values = {\n locale: { type: String, default: \"\" },\n threshold: { type: Number, default: 0 },\n tickInterval: { type: Number, default: 60_000 },\n };\n\n declare localeValue: string;\n declare thresholdValue: number;\n declare tickIntervalValue: number;\n\n readonly #timers = new SafeTimeout();\n\n /** Epoch ms parsed from `datetime`; `NaN` when absent or invalid. */\n #targetMs = Number.NaN;\n /** The authored absolute text, restored when the threshold fallback kicks in. */\n #absoluteText = \"\";\n\n override connect(): void {\n // Don't adopt already-rendered relative text as the absolute fallback: after a\n // Turbo morph that preserved the live \"3 minutes ago\" text, a fresh re-connect\n // would otherwise capture that relative string as `#absoluteText`. Only read the\n // authored textContent before the element has been rendered to a relative form.\n if (this.element.getAttribute(\"data-state\") !== \"relative\") {\n this.#absoluteText = (this.element.textContent ?? \"\").trim();\n }\n this.#targetMs = Date.parse(this.element.getAttribute(\"datetime\") ?? \"\");\n if (Number.isNaN(this.#targetMs)) return;\n this.#schedule();\n }\n\n override disconnect(): void {\n this.#timers.clearAll();\n }\n\n /** Renders the current representation and reschedules unless it is now absolute. */\n #schedule(): void {\n const nextDelay = this.#applyAndComputeDelay();\n if (nextDelay !== null) {\n this.#timers.set(() => this.#schedule(), nextDelay);\n }\n }\n\n /**\n * Updates the visible text and returns the next poll delay (ms), or `null`\n * once the absolute fallback is shown (which never changes, so stop polling).\n */\n #applyAndComputeDelay(): number | null {\n const deltaMs = this.#targetMs - Date.now();\n const absSeconds = Math.abs(deltaMs) / 1000;\n\n // Only switch to the absolute fallback when we actually hold authored absolute\n // text; otherwise (e.g. it could not be recovered after a morph) keep rendering\n // the relative form rather than blanking the element.\n if (this.thresholdValue > 0 && absSeconds >= this.thresholdValue && this.#absoluteText) {\n this.element.textContent = this.#absoluteText;\n this.element.setAttribute(\"data-state\", \"absolute\");\n return null;\n }\n\n const scale = UNITS.find((u) => absSeconds < u.limit) ?? YEAR_SCALE;\n const value = Math.round(deltaMs / scale.ms);\n this.element.textContent = this.#formatter.format(value, scale.unit);\n this.element.setAttribute(\"data-state\", \"relative\");\n\n // Poll no finer than the configured minimum; widen for coarser units so an\n // hours-old stamp is not re-rendered every minute.\n const unitFloor = scale.unit === \"second\" || scale.unit === \"minute\" ? 60_000 : scale.ms;\n return Math.max(this.tickIntervalValue, Math.min(unitFloor, 86_400_000));\n }\n\n /** A `RelativeTimeFormat` for the resolved locale (`numeric: \"auto\"`). */\n get #formatter(): Intl.RelativeTimeFormat {\n return new Intl.RelativeTimeFormat(this.#locale, { numeric: \"auto\" });\n }\n\n /** Locale precedence: the value, then the element's `lang`, then the document's. */\n get #locale(): string | undefined {\n return this.localeValue || this.element.lang || document.documentElement.lang || undefined;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/before_cache_reset.ts","../../src/utils/microtask_coalescer.ts","../../src/utils/safe_timeout.ts","../../src/controllers/relative_time_controller.ts"],"names":[],"mappings":"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","file":"relative_time_controller.js","sourcesContent":["/**\n * Runs a controller's \"return to the initial state\" pass just before Turbo\n * caches the page.\n *\n * **`disconnect()` cannot do this job, for two independent reasons.** Turbo\n * queues the clone from this event rather than taking it here, and the body swap\n * that runs the controller's `disconnect()` is queued separately — so which of\n * the two lands first is not something a controller can rely on, and a rewind\n * written in `disconnect()` may reach only the DOM being thrown away. In the\n * other direction, `disconnect()` also fires on an in-page move (Stimulus tears\n * down and reconnects the same element), where rewinding would wipe a\n * legitimately in-progress interaction — a spinner mid-load would vanish. One\n * timing is unreliable, the other is too eager; `turbo:before-cache` is the only\n * point that is exactly \"the page is about to be frozen\".\n *\n * Scope is the subscription only: registering on `activate()`, unregistering on\n * `deactivate()`, and one shared document listener no matter how many instances\n * are live. *What* to return to its initial state — which `data-state`, which\n * `hidden`, which `aria-busy` — stays in the controller, because no two\n * consumers answer it the same way (the `MicrotaskCoalescer` split).\n *\n * **Rewind state, not appearance.** The pass writes attributes the controller\n * itself owns; the visual result of those attributes is the consumer's CSS, and\n * a library that reached for style or class names would be guessing at markup\n * it does not own.\n *\n * Both entry points are idempotent, so the lifecycle hooks can call them\n * unconditionally: a second `activate()` does not double-subscribe and does not\n * make the callback run twice, and `deactivate()` on an instance that never\n * subscribed is a no-op.\n *\n * This file's own doc block is dropped from `dist`, but every member comment is\n * inlined into each consumer entry (`tsup` builds with `splitting: false`), so\n * rationale belongs here and only the contract belongs on the members.\n *\n * @example\n * ```ts\n * readonly #beforeCache = new BeforeCacheReset(() => this.#rewind());\n *\n * connect() { this.#beforeCache.activate(); }\n * disconnect() { this.#beforeCache.deactivate(); }\n * ```\n */\nexport class BeforeCacheReset {\n /** Every subscribed instance, iterated by the one shared document listener. */\n static readonly #subscribers = new Set<BeforeCacheReset>();\n\n /** The shared listener; installed while at least one instance is subscribed. */\n static readonly #onBeforeCache = (): void => {\n for (const subscriber of BeforeCacheReset.#subscribers) subscriber.#rewind();\n };\n\n readonly #rewind: () => void;\n\n /** @param rewind - the pass that returns this controller's state to its initial form. */\n constructor(rewind: () => void) {\n this.#rewind = rewind;\n }\n\n /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */\n activate(): void {\n const first = BeforeCacheReset.#subscribers.size === 0;\n BeforeCacheReset.#subscribers.add(this);\n if (first) {\n document.addEventListener(\"turbo:before-cache\", BeforeCacheReset.#onBeforeCache);\n }\n }\n\n /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */\n deactivate(): void {\n BeforeCacheReset.#subscribers.delete(this);\n if (BeforeCacheReset.#subscribers.size > 0) return;\n document.removeEventListener(\"turbo:before-cache\", BeforeCacheReset.#onBeforeCache);\n }\n}\n","/**\n * Collapses many target callbacks from one DOM mutation into a single pass.\n *\n * Stimulus fires `<name>TargetConnected` / `Disconnected` once per element, so\n * replacing a list of N options delivers N callbacks — but the useful unit of\n * work is \"reconcile against the DOM that resulted\", once, after the batch has\n * settled. Every controller that owns a reconcilable target set needs the same\n * shape: a `queued` flag plus `queueMicrotask`.\n *\n * **A microtask is the right horizon, and the reason is specific.** Stimulus\n * drives these callbacks from a `MutationObserver`, whose own callback already\n * runs as a microtask with the whole batch in hand; scheduling one more lands\n * after the last sibling callback of that batch and still before paint or any\n * event handler. A timer would be later than it needs to be, and reconciling\n * synchronously would run once per element against a half-applied DOM.\n *\n * **The two guards are not the same guard.** Scheduling is refused before the\n * controller connects, and running is refused after it disconnects:\n *\n * - **Before `connect()`** — Stimulus delivers the *initial* target callbacks\n * ahead of `connect()`. Reconciling there would compute a fallback against a\n * controller whose own state has not been initialised, and `connect()` is\n * about to do a full pass anyway.\n * - **After `disconnect()`** — Stimulus fires a callback for **every** target\n * during teardown, and a microtask queued just before it would otherwise run\n * against a detached tree. {@link MicrotaskCoalescer.cancel} exists for the\n * teardown path to drop the pending pass outright.\n *\n * Both guards are part of one contract here rather than something each consumer\n * has to remember separately.\n *\n * Scope is the scheduling only. *What* to reconcile — keep the surviving active\n * option, fall back to the next / previous / first visible one, rebuild derived\n * chips or hidden fields — stays in the controller, because no two consumers\n * answer it the same way.\n *\n * This file's own doc block is dropped from `dist`, but every member comment is\n * inlined into each consumer entry (`tsup` builds with `splitting: false`), so\n * rationale belongs here and only the contract belongs on the members.\n *\n * @example\n * ```ts\n * readonly #reconcile = new MicrotaskCoalescer(() => this.#reconcileOptions());\n *\n * connect() { this.#reconcile.activate(); }\n * disconnect() { this.#reconcile.cancel(); }\n *\n * optionTargetConnected() { this.#reconcile.schedule(); }\n * optionTargetDisconnected() { this.#reconcile.schedule(); }\n * ```\n */\nexport class MicrotaskCoalescer {\n readonly #run: () => void;\n #queued = false;\n #active = false;\n #generation = 0;\n\n /** @param run - the single reconciliation pass, invoked at most once per batch. */\n constructor(run: () => void) {\n this.#run = run;\n }\n\n /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */\n activate(): void {\n this.#active = true;\n }\n\n /** Closes the window and drops any pending pass; call from `disconnect()`. */\n cancel(): void {\n this.#active = false;\n this.#queued = false;\n this.#generation += 1;\n }\n\n /** Requests one pass after the batch settles. Idempotent; inert outside the window. */\n schedule(): void {\n if (!this.#active || this.#queued) return;\n this.#queued = true;\n const generation = this.#generation;\n queueMicrotask(() => {\n // A cancelled callback must not consume a pass queued after reconnect.\n if (generation !== this.#generation || !this.#queued || !this.#active) return;\n this.#queued = false;\n this.#run();\n });\n }\n}\n","/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { BeforeCacheReset } from \"../utils/before_cache_reset\";\nimport { MicrotaskCoalescer } from \"../utils/microtask_coalescer\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\n\n/** A time scale: the upper bound (seconds) it covers and its `Intl` unit/divisor. */\ninterface TimeScale {\n limit: number;\n unit: Intl.RelativeTimeFormatUnit;\n ms: number;\n}\n\n/** Coarsest scale, used as the fallback for anything a year or older. */\nconst YEAR_SCALE: TimeScale = { limit: Number.POSITIVE_INFINITY, unit: \"year\", ms: 31_557_600_000 };\n\n/** Boundaries (in seconds) and the `Intl` unit/divisor used at each scale. */\nconst UNITS: readonly TimeScale[] = [\n { limit: 60, unit: \"second\", ms: 1000 },\n { limit: 3600, unit: \"minute\", ms: 60_000 },\n { limit: 86_400, unit: \"hour\", ms: 3_600_000 },\n { limit: 604_800, unit: \"day\", ms: 86_400_000 },\n { limit: 2_629_800, unit: \"week\", ms: 604_800_000 },\n { limit: 31_557_600, unit: \"month\", ms: 2_629_800_000 },\n YEAR_SCALE,\n];\n\n/**\n * Headless relative-time behavior: renders an absolute timestamp as \"3 minutes\n * ago\" / \"in 2 days\" and keeps it fresh. No dedicated APG pattern; it follows\n * the HTML `<time>` semantics.\n *\n * Markup contract (identifier: `stimeo--relative-time`):\n * <time data-controller=\"stimeo--relative-time\"\n * datetime=\"2026-05-30T12:00:00+09:00\" title=\"2026-05-30 12:00\"\n * data-stimeo--relative-time-locale-value=\"ja\">2026-05-30 12:00</time>\n *\n * Computes the difference from `datetime` to now and formats it with\n * `Intl.RelativeTimeFormat` (a browser standard — no added dependency). The\n * polling interval widens as the timestamp ages (seconds → minutes → hours →\n * days). Past a `threshold`, it falls back to the authored absolute text.\n *\n * @remarks\n * Behavior only. The machine-readable `datetime` attribute is left untouched\n * while only the visible text updates, and the element is intentionally **not**\n * a live region (silent updates, no announcement interruptions). The polling\n * timer is owned by {@link SafeTimeout} and torn down on `disconnect()` (Turbo\n * navigation included).\n *\n * Render inputs are followed at runtime: a morph that swaps `locale`, `threshold`,\n * or `tickInterval` on the live element repaints through one coalesced pass\n * ({@link MicrotaskCoalescer}) rather than leaving the reading frozen. The authored\n * absolute text goes back on the element via {@link BeforeCacheReset} before Turbo\n * caches the page — a snapshot taken mid-render would carry the relative form\n * alone, and that is a form `connect()` must not adopt as the fallback.\n */\nexport class RelativeTimeController extends Controller<HTMLElement> {\n static override values = {\n locale: { type: String, default: \"\" },\n threshold: { type: Number, default: 0 },\n tickInterval: { type: Number, default: 60_000 },\n };\n\n declare localeValue: string;\n declare thresholdValue: number;\n declare tickIntervalValue: number;\n\n readonly #timers = new SafeTimeout();\n /** Collapses a morph that swaps several render inputs at once into one repaint. */\n readonly #resync = new MicrotaskCoalescer(() => this.#resyncToValues());\n readonly #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());\n\n /** Epoch ms parsed from `datetime`; `NaN` when absent or invalid. */\n #targetMs = Number.NaN;\n /** The authored absolute text, restored when the threshold fallback kicks in. */\n #absoluteText = \"\";\n\n override connect(): void {\n this.#resync.activate();\n this.#beforeCache.activate();\n // Don't adopt already-rendered relative text as the absolute fallback: on a\n // re-connect against a tree that still holds the live \"3 minutes ago\" text, that\n // relative string would otherwise become `#absoluteText`. Only read the authored\n // textContent before the element has been rendered to a relative form.\n if (this.element.getAttribute(\"data-state\") !== \"relative\") {\n this.#absoluteText = (this.element.textContent ?? \"\").trim();\n }\n this.#targetMs = Date.parse(this.element.getAttribute(\"datetime\") ?? \"\");\n if (Number.isNaN(this.#targetMs)) return;\n this.#schedule();\n }\n\n override disconnect(): void {\n this.#resync.cancel();\n this.#beforeCache.deactivate();\n this.#timers.clearAll();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `locale` at runtime. */\n localeValueChanged(): void {\n this.#resync.schedule();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `threshold` at runtime. */\n thresholdValueChanged(): void {\n this.#resync.schedule();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `tickInterval` at runtime. */\n tickIntervalValueChanged(): void {\n this.#resync.schedule();\n }\n\n /**\n * Renders against the current Values and re-arms the poll from now.\n *\n * Render only: it emits no event, and clearing first keeps the single self-arming\n * timer single — scheduling on top of a pending one would double the poll rate for\n * the rest of the session. A stamp whose `datetime` never parsed has nothing to\n * render, and one that already reached its terminal fallback simply renders it\n * again and stops.\n */\n #resyncToValues(): void {\n if (Number.isNaN(this.#targetMs)) return;\n this.#timers.clearAll();\n this.#schedule();\n }\n\n /**\n * Restores the authored absolute text and the pre-render state for the snapshot\n * Turbo is about to take, leaving the live page's poll timer alone.\n *\n * With no authored text held there is nothing to restore, and `data-state` has to\n * stay as it is: that marker is what tells the next `connect()` the visible text is\n * a rendered relative form rather than an absolute fallback to hold on to.\n */\n #rewindForCache(): void {\n if (!this.#absoluteText) return;\n this.element.textContent = this.#absoluteText;\n this.element.removeAttribute(\"data-state\");\n }\n\n /** Renders the current representation and reschedules unless polling can stop. */\n #schedule(): void {\n const nextDelay = this.#applyAndComputeDelay();\n if (nextDelay !== null) {\n this.#timers.set(() => this.#schedule(), nextDelay);\n }\n }\n\n /**\n * Updates the visible text and returns the next poll delay (ms), or `null` when\n * polling can stop: a *past* timestamp that fell back to the absolute text can\n * never leave it again, and a locale the runtime rejects has nothing to render\n * until that value is corrected.\n */\n #applyAndComputeDelay(): number | null {\n const deltaMs = this.#targetMs - Date.now();\n const absSeconds = Math.abs(deltaMs) / 1000;\n\n const scale = UNITS.find((u) => absSeconds < u.limit) ?? YEAR_SCALE;\n // Poll no finer than the configured minimum; widen for coarser units so an\n // hours-old stamp is not re-rendered every minute.\n const unitFloor = scale.unit === \"second\" || scale.unit === \"minute\" ? 60_000 : scale.ms;\n const nextDelay = Math.max(this.tickIntervalValue, Math.min(unitFloor, 86_400_000));\n\n // Only switch to the absolute fallback when we actually hold authored absolute\n // text; otherwise (e.g. it could not be recovered after a morph) keep rendering\n // the relative form rather than blanking the element.\n if (this.thresholdValue > 0 && absSeconds >= this.thresholdValue && this.#absoluteText) {\n this.element.textContent = this.#absoluteText;\n this.element.setAttribute(\"data-state\", \"absolute\");\n // A past stamp only ages further, so its fallback is final and polling stops. A\n // future one moves back under the threshold, so keep polling and land 1ms past\n // the crossing: the poll cadence is far coarser than the instant the fallback\n // stops being the correct representation, and the floor does not apply to a hop\n // that exists to leave the fallback rather than to refresh a reading.\n if (deltaMs <= 0) return null;\n return Math.min(nextDelay, deltaMs - this.thresholdValue * 1000 + 1);\n }\n\n const formatter = this.#formatter;\n // Nothing to render in a locale the runtime rejects: leaving the element as\n // authored beats blanking it or guessing at another language.\n if (formatter === null) return null;\n const value = Math.round(deltaMs / scale.ms);\n this.element.textContent = formatter.format(value, scale.unit);\n this.element.setAttribute(\"data-state\", \"relative\");\n return nextDelay;\n }\n\n /**\n * A `RelativeTimeFormat` for the resolved locale (`numeric: \"auto\"`), or `null`\n * when the runtime rejects that locale.\n */\n get #formatter(): Intl.RelativeTimeFormat | null {\n try {\n return new Intl.RelativeTimeFormat(this.#locale, { numeric: \"auto\" });\n } catch {\n // A malformed locale must not break the page: the authored absolute text stays\n // as the graceful fallback, and a corrected value renders on the next pass.\n return null;\n }\n }\n\n /** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */\n get #locale(): string | undefined {\n return this.localeValue || this.element.closest(\"[lang]\")?.getAttribute(\"lang\") || undefined;\n }\n}\n"]}
|
|
@@ -5,7 +5,6 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
5
5
|
*
|
|
6
6
|
* Markup contract (identifier: `stimeo--sidebar`):
|
|
7
7
|
* <div data-controller="stimeo--sidebar"
|
|
8
|
-
* data-action="turbo:before-cache@document->stimeo--sidebar#beforeCache"
|
|
9
8
|
* data-stimeo--sidebar-breakpoint-value="768"
|
|
10
9
|
* data-stimeo--sidebar-key-value="main-nav">
|
|
11
10
|
* <header>
|
|
@@ -63,7 +62,7 @@ declare class SidebarController extends Controller<HTMLElement> {
|
|
|
63
62
|
default: boolean;
|
|
64
63
|
};
|
|
65
64
|
};
|
|
66
|
-
static actions: readonly ["
|
|
65
|
+
static actions: readonly ["close", "open", "toggle"];
|
|
67
66
|
readonly triggerTarget: HTMLElement;
|
|
68
67
|
readonly panelTarget: HTMLElement;
|
|
69
68
|
readonly backdropTarget: HTMLElement;
|
|
@@ -87,15 +86,6 @@ declare class SidebarController extends Controller<HTMLElement> {
|
|
|
87
86
|
* existing listener instead of allocating duplicate `MediaQueryList` objects.
|
|
88
87
|
*/
|
|
89
88
|
breakpointValueChanged(): void;
|
|
90
|
-
/**
|
|
91
|
-
* Sanitizes overlay markup before Turbo snapshots the page.
|
|
92
|
-
*
|
|
93
|
-
* Inline state is durable and remains untouched. Overlay state is transient:
|
|
94
|
-
* pending transitions are cancelled, controller-owned open attributes are
|
|
95
|
-
* closed immediately, and modal side effects are released without moving
|
|
96
|
-
* focus during navigation.
|
|
97
|
-
*/
|
|
98
|
-
beforeCache(): void;
|
|
99
89
|
/** Toggles the panel: inline flips collapsed/expanded, overlay flips open/closed. */
|
|
100
90
|
toggle(): void;
|
|
101
91
|
/** Shows the panel (inline: expand; overlay: open). */
|
|
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/sidebar_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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
@@ -474,7 +501,7 @@ var SidebarController = class extends Controller {
|
|
|
474
501
|
key: { type: String, default: "" },
|
|
475
502
|
collapsed: { type: Boolean, default: false }
|
|
476
503
|
};
|
|
477
|
-
static actions = ["
|
|
504
|
+
static actions = ["close", "open", "toggle"];
|
|
478
505
|
/** Exact panel currently owned by the modal lifecycle (survives target churn safely). */
|
|
479
506
|
#activePanel = null;
|
|
480
507
|
/** Owns the overlay modal side effects; Escape closes, focus falls to trigger. */
|
|
@@ -496,6 +523,7 @@ var SidebarController = class extends Controller {
|
|
|
496
523
|
#connected = false;
|
|
497
524
|
connect() {
|
|
498
525
|
this.#connected = true;
|
|
526
|
+
this.#beforeCache.activate();
|
|
499
527
|
this.#activePanel = this.hasPanelTarget ? this.panelTarget : null;
|
|
500
528
|
this.#collapsed = this.#restoreCollapsed();
|
|
501
529
|
this.#mqlQuery = this.#breakpointQuery;
|
|
@@ -505,6 +533,7 @@ var SidebarController = class extends Controller {
|
|
|
505
533
|
}
|
|
506
534
|
disconnect() {
|
|
507
535
|
this.#connected = false;
|
|
536
|
+
this.#beforeCache.deactivate();
|
|
508
537
|
this.#mql?.removeEventListener("change", this.#onMediaChange);
|
|
509
538
|
this.#mql = null;
|
|
510
539
|
this.#mqlQuery = null;
|
|
@@ -556,12 +585,12 @@ var SidebarController = class extends Controller {
|
|
|
556
585
|
* closed immediately, and modal side effects are released without moving
|
|
557
586
|
* focus during navigation.
|
|
558
587
|
*/
|
|
559
|
-
beforeCache() {
|
|
588
|
+
#beforeCache = new BeforeCacheReset(() => {
|
|
560
589
|
if (!this.#connected || !this.#isOverlay) return;
|
|
561
590
|
this.#transition.cancel();
|
|
562
591
|
this.#trap.deactivate({ restoreFocus: false });
|
|
563
592
|
this.#setOverlayClosedImmediate();
|
|
564
|
-
}
|
|
593
|
+
});
|
|
565
594
|
/** Toggles the panel: inline flips collapsed/expanded, overlay flips open/closed. */
|
|
566
595
|
toggle() {
|
|
567
596
|
if (this.#isOverlay) {
|