stimeo-ui 0.10.0 → 0.12.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 +145 -0
- package/dist/cable/index.d.ts +79 -14
- package/dist/cable/index.js +249 -45
- package/dist/cable/index.js.map +1 -1
- package/dist/controllers/count_up_controller.d.ts +12 -10
- package/dist/controllers/count_up_controller.js +74 -35
- package/dist/controllers/count_up_controller.js.map +1 -1
- package/dist/controllers/intersection_controller.d.ts +20 -7
- package/dist/controllers/intersection_controller.js +55 -8
- package/dist/controllers/intersection_controller.js.map +1 -1
- package/dist/controllers/lazy_frame_controller.d.ts +31 -9
- package/dist/controllers/lazy_frame_controller.js +81 -19
- package/dist/controllers/lazy_frame_controller.js.map +1 -1
- package/dist/controllers/pointer_drag_controller.d.ts +20 -3
- package/dist/controllers/pointer_drag_controller.js +68 -18
- package/dist/controllers/pointer_drag_controller.js.map +1 -1
- package/dist/controllers/portal_controller.d.ts +16 -3
- package/dist/controllers/portal_controller.js +32 -11
- package/dist/controllers/portal_controller.js.map +1 -1
- package/dist/controllers/preview_guard_controller.d.ts +22 -14
- package/dist/controllers/preview_guard_controller.js +180 -20
- package/dist/controllers/preview_guard_controller.js.map +1 -1
- package/dist/controllers/reading_progress_controller.d.ts +18 -9
- package/dist/controllers/reading_progress_controller.js +177 -6
- package/dist/controllers/reading_progress_controller.js.map +1 -1
- package/dist/controllers/roving_controller.d.ts +15 -3
- package/dist/controllers/roving_controller.js +127 -14
- package/dist/controllers/roving_controller.js.map +1 -1
- package/dist/controllers/scrollspy_controller.js +13 -2
- package/dist/controllers/scrollspy_controller.js.map +1 -1
- package/dist/controllers/smart_sticky_header_controller.d.ts +17 -8
- package/dist/controllers/smart_sticky_header_controller.js +52 -10
- package/dist/controllers/smart_sticky_header_controller.js.map +1 -1
- package/dist/controllers/sortable_controller.d.ts +38 -8
- package/dist/controllers/sortable_controller.js +241 -67
- package/dist/controllers/sortable_controller.js.map +1 -1
- package/dist/controllers/stick_to_bottom_controller.d.ts +23 -7
- package/dist/controllers/stick_to_bottom_controller.js +149 -28
- package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
- package/dist/controllers/sticky_observer_controller.js +13 -2
- package/dist/controllers/sticky_observer_controller.js.map +1 -1
- package/dist/index.js +934 -285
- package/dist/index.js.map +1 -1
- package/dist/inspector/cli.js +1 -0
- package/dist/inspector/cli.js.map +1 -1
- package/dist/inspector/cli_bin.js +1 -0
- package/dist/inspector/cli_bin.js.map +1 -1
- package/dist/inspector/examples.json +4 -4
- package/dist/inspector/manifest.json +16 -56
- package/package.json +3 -3
|
@@ -69,15 +69,42 @@ var PortalController = class extends Controller {
|
|
|
69
69
|
static events = ["mount", "unmount"];
|
|
70
70
|
/** Decides whether a `disconnect()` is an in-page move or a real detach. */
|
|
71
71
|
#gate = new DetachGate();
|
|
72
|
+
/** The `to` declaration after validation; the default when it cannot be parsed. */
|
|
73
|
+
#toSelector = "body";
|
|
74
|
+
/**
|
|
75
|
+
* Validates the destination declaration once, keeping only a selector the engine can
|
|
76
|
+
* read.
|
|
77
|
+
*
|
|
78
|
+
* A selector reads back as an ordinary string, so a malformed one survives until it is
|
|
79
|
+
* handed to the DOM — where it would take the whole teleport down and leave the part
|
|
80
|
+
* silently doing nothing. Falling back to the default puts the node somewhere visible
|
|
81
|
+
* instead, which reads as a mistake and can be traced back to the declaration.
|
|
82
|
+
*/
|
|
83
|
+
toValueChanged() {
|
|
84
|
+
const selector = this.toValue;
|
|
85
|
+
if (selector.length > 0) {
|
|
86
|
+
try {
|
|
87
|
+
this.element.matches(selector);
|
|
88
|
+
this.#toSelector = selector;
|
|
89
|
+
return;
|
|
90
|
+
} catch {
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
this.#toSelector = "body";
|
|
94
|
+
}
|
|
72
95
|
connect() {
|
|
73
96
|
this.#gate.cancel();
|
|
74
|
-
|
|
97
|
+
const existing = portalState.get(this.element);
|
|
98
|
+
if (existing) {
|
|
99
|
+
existing.owner = this;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
75
102
|
const node = this.hasContentTarget ? this.contentTarget : this.element;
|
|
76
103
|
const destination = this.#destination();
|
|
77
104
|
if (!destination || destination === node || node.contains(destination)) return;
|
|
78
105
|
const placeholder = document.createComment("stimeo--portal");
|
|
79
106
|
node.parentNode?.insertBefore(placeholder, node);
|
|
80
|
-
portalState.set(this.element, { node, placeholder });
|
|
107
|
+
portalState.set(this.element, { node, placeholder, owner: this });
|
|
81
108
|
if (this.positionValue === "prepend") {
|
|
82
109
|
destination.prepend(node);
|
|
83
110
|
} else {
|
|
@@ -96,7 +123,7 @@ var PortalController = class extends Controller {
|
|
|
96
123
|
}
|
|
97
124
|
this.#gate.disconnected(this, () => {
|
|
98
125
|
const current = portalState.get(this.element);
|
|
99
|
-
if (current) this.#restore(current);
|
|
126
|
+
if (current?.owner === this) this.#restore(current);
|
|
100
127
|
});
|
|
101
128
|
}
|
|
102
129
|
/** Returns the node to its placeholder (or removes it) and clears the bookkeeping. */
|
|
@@ -112,15 +139,9 @@ var PortalController = class extends Controller {
|
|
|
112
139
|
placeholder.remove();
|
|
113
140
|
this.dispatch("unmount", { detail: {} });
|
|
114
141
|
}
|
|
115
|
-
/** Resolves the destination
|
|
142
|
+
/** Resolves the destination from the validated `to` selector. */
|
|
116
143
|
#destination() {
|
|
117
|
-
|
|
118
|
-
if (!selector) return null;
|
|
119
|
-
try {
|
|
120
|
-
return document.querySelector(selector);
|
|
121
|
-
} catch {
|
|
122
|
-
return null;
|
|
123
|
-
}
|
|
144
|
+
return document.querySelector(this.#toSelector);
|
|
124
145
|
}
|
|
125
146
|
};
|
|
126
147
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/utils/detach_gate.ts","../../src/controllers/portal_controller.ts"],"names":[],"mappings":"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","file":"portal_controller.js","sourcesContent":["/**\n * Shared \"in-page move vs real detach\" discriminator for `disconnect()`.\n *\n * A mid-session `disconnect()` whose element is still in the document is\n * ambiguous. It can be:\n *\n * - an **in-page move** — a consumer (sortable, a teleport) re-inserted the\n * element; Stimulus reconnects the SAME instance in the same mutation batch\n * and the controller's runtime state must survive;\n * - a **detach that keeps the element** — `data-controller` no longer lists the\n * identifier (a Turbo 8 morph), or the element moved outside the observed\n * root (a scoped `Application.start(root)`, a shadow root); no reconnect will\n * ever come and the controller must tear down or it leaks its sessions,\n * document listeners, and moved nodes.\n *\n * Neither available signal decides this alone. A synchronous `data-controller`\n * token check catches the morph case but misses an observed-root exit (the\n * token stays, the reconnect never comes). Deferring teardown one microtask and\n * letting `connect()` cancel it catches the root exit, but leans on Stimulus\n * reconnecting moved elements within the same mutation batch — internal\n * behavior, not a public contract. {@link DetachGate} composes both: the token\n * check is the synchronous fast path (no probe, no reliance on batching), the\n * microtask probe is the fallback for the genuinely ambiguous remainder.\n *\n * Policy stays with the consumer; this util owns the discrimination only:\n *\n * - **Probe** ({@link disconnected}) when the teardown must eventually happen —\n * live drag sessions, document listeners, teleported *content*. Call\n * {@link cancel} from `connect()` (a reconnect means in-page move) and from\n * the head of any direct teardown path (see below).\n * - **Keep** (bare {@link DetachGate.isDetached}) when undoing the ambiguous\n * case would fight the controller's own effect: a controller that moves its\n * OWN element (portal's no-`content` form) exits a scoped observed root as\n * its normal job — a probe-driven teardown would restore the element into\n * the root, reconnect, re-teleport, and disconnect again, forever. Such\n * controllers keep their state on an ambiguous disconnect and tear down only\n * on {@link DetachGate.isDetached}.\n *\n * Event convention for probe consumers: teardown into a dead (detached) tree is\n * silent — consumers restore from their own `connect()`; a detach that keeps the\n * tree alive must end an in-flight session with a `cancel` event, or composing\n * consumers strand their session bookkeeping.\n */\n\n/** The slice of a Stimulus controller the gate inspects. */\nexport interface DetachGateHost {\n readonly element: Element;\n readonly identifier: string;\n}\n\n/**\n * Per-controller-instance gate deciding whether a `disconnect()` is a real\n * detach. Hold one per controller (`#gate = new DetachGate()`) — Stimulus\n * reuses the instance across reconnects, so the pending probe state carries\n * over exactly as the protocol needs.\n *\n * @example\n * ```ts\n * #gate = new DetachGate();\n *\n * connect(): void {\n * this.#gate.cancel(); // a reconnect: the element moved in-page\n * // …\n * }\n *\n * disconnect(): void {\n * this.#gate.disconnected(this, () => this.#teardown());\n * }\n *\n * #teardown(): void {\n * this.#gate.cancel(); // disarm a still-queued probe (double-run guard)\n * // …\n * }\n * ```\n */\nexport class DetachGate {\n /** Set while a probe is queued, waiting for a reconnect to cancel it. */\n #pending = false;\n\n /**\n * True while a probe is queued — the last disconnect was ambiguous and no\n * reconnect has cancelled it yet. Read it from `connect()` to tell the\n * reconnect half of an in-page move from a first connect: a controller whose\n * initialisation restarts a measurement (a min-duration floor, an elapsed\n * counter) must skip it for the move, where nothing actually restarted.\n */\n get pending(): boolean {\n return this.#pending;\n }\n\n /**\n * True when the disconnect is definitely a real detach — the element left\n * the document, or `data-controller` no longer lists the identifier. False\n * means ambiguous (in-page move or observed-root exit), NOT \"alive\".\n */\n static isDetached(host: DetachGateHost): boolean {\n if (!host.element.isConnected) return true;\n const tokens = (host.element.getAttribute(\"data-controller\") ?? \"\").split(/\\s+/);\n return !tokens.includes(host.identifier);\n }\n\n /**\n * Call from `disconnect()`: runs `teardown` synchronously on a definite\n * detach (fast path), otherwise defers it one microtask — a reconnect\n * ({@link cancel} from `connect()`) keeps the state, no reconnect runs it.\n * One microtask is the whole probe window: Stimulus reconnects a moved\n * element within the same mutation batch, before the checkpoint drains.\n */\n disconnected(host: DetachGateHost, teardown: () => void): void {\n if (DetachGate.isDetached(host)) {\n // Also disarms a probe a previous ambiguous disconnect left queued\n // (defer, element removed, disconnect again) — exactly one teardown.\n this.#pending = false;\n teardown();\n return;\n }\n this.#pending = true;\n queueMicrotask(() => {\n if (!this.#pending) return;\n this.#pending = false;\n teardown();\n });\n }\n\n /**\n * Disarms a pending probe. Call from `connect()` (the reconnect that proves\n * an in-page move) and from the head of any teardown path not routed through\n * {@link disconnected} (disabled-toggle, Escape), so an orphaned probe can\n * never run the teardown a second time.\n */\n cancel(): void {\n this.#pending = false;\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { DetachGate } from \"../utils/detach_gate\";\n\n/**\n * Teleport bookkeeping keyed by the controller element (stable across the\n * connect/disconnect churn some DOM runtimes emit when an observed element is moved).\n * Holds the moved node and its placeholder so any controller instance for that element\n * can finish the teardown.\n */\nconst portalState = new WeakMap<Element, { node: HTMLElement; placeholder: Comment }>();\n\n/**\n * Headless **portal / teleport**: moves an element to another place in the DOM (e.g.\n * directly under `body`) on connect and cleans up on disconnect — the shared substrate\n * for overlays that must escape an ancestor's `overflow: hidden`, `transform`, or\n * stacking context (no APG pattern; a DOM utility).\n *\n * Markup contract (identifier: `stimeo--portal`):\n * <div data-controller=\"stimeo--portal\" data-stimeo--portal-to-value=\"body\">\n * <div data-stimeo--portal-target=\"content\">Teleported content</div>\n * </div>\n *\n * Moves `content` (or `this.element` when no `content` target) into the first element\n * matching `to` (default `body`), `append`ed or `prepend`ed per `position`. A comment\n * placeholder records the original spot so `disconnect()` can return the node there\n * (when `restore`) — or remove it — leaving no orphan behind. The moved node carries\n * `data-portaled`.\n *\n * `mount` dispatches `{ target }`; `unmount` dispatches `{}`.\n *\n * @remarks\n * Behavior only — no positioning (pair with `stimeo-ui/positioning`) and no focus\n * trapping (pair with `stimeo--focus` or the overlay). Moving a Stimulus element within\n * the same document does not re-fire connect/disconnect, so the move is safe. For Turbo\n * compatibility prefer the `content`-target form: the controller then stays on the\n * in-place source, so its `disconnect()` fires when the original container is replaced\n * and the teleported node is restored/removed rather than orphaned under `body`. The\n * move is idempotent (guarded by the placeholder) and reversed on `disconnect()` (Turbo\n * navigation included). The \"in-page move vs real detach\" split on `disconnect()` is\n * {@link DetachGate}; in the `content` form the source element may even leave a\n * scoped application's observed root and the content is still restored, while the\n * no-`content` form teleporting itself out of the observed root is fire-and-forget\n * by design (see `disconnect()`).\n */\nexport class PortalController extends Controller<HTMLElement> {\n static override targets = [\"content\"];\n static override values = {\n to: { type: String, default: \"body\" },\n position: { type: String, default: \"append\" },\n restore: { type: Boolean, default: true },\n };\n static events = [\"mount\", \"unmount\"] as const;\n\n declare readonly contentTarget: HTMLElement;\n declare readonly hasContentTarget: boolean;\n\n declare toValue: string;\n declare positionValue: string;\n declare restoreValue: boolean;\n\n /** Decides whether a `disconnect()` is an in-page move or a real detach. */\n readonly #gate = new DetachGate();\n\n override connect(): void {\n // A reconnect proves an in-page move of the source element: disarm the\n // probe the mid-move disconnect() deferred (see disconnect).\n this.#gate.cancel();\n if (portalState.has(this.element)) return; // already portaled here (idempotent)\n const node = this.hasContentTarget ? this.contentTarget : this.element;\n const destination = this.#destination();\n if (!destination || destination === node || node.contains(destination)) return;\n\n const placeholder = document.createComment(\"stimeo--portal\");\n node.parentNode?.insertBefore(placeholder, node);\n portalState.set(this.element, { node, placeholder });\n\n if (this.positionValue === \"prepend\") {\n destination.prepend(node);\n } else {\n destination.appendChild(node);\n }\n node.setAttribute(\"data-portaled\", \"true\");\n this.dispatch(\"mount\", { detail: { target: destination } });\n }\n\n override disconnect(): void {\n const state = portalState.get(this.element);\n if (!state) return;\n if (state.node === this.element) {\n // No-`content` form: the teleported node IS the controller element, so the\n // teleport itself may exit a scoped application's observed root — the controller\n // doing its job, not a detach. A probe-driven restore would re-enter the root,\n // reconnect, re-teleport and disconnect again, forever. So an ambiguous\n // disconnect (in the document, identifier still listed — also the churn a\n // self-move emits in some runtimes) KEEPS the teleport, and only a definite\n // detach — the element left the DOM, or `data-controller` no longer lists us\n // (a Turbo 8 morph) — restores. The cost, by design: a teleport that left the\n // observed root is fire-and-forget (Stimulus never fires for it again).\n if (!DetachGate.isDetached(this)) return;\n this.#restore(state);\n return;\n }\n // `content` form: the controller element stays put, so its own teleport emits no\n // churn — an ambiguous disconnect means the SOURCE element moved. In-page, the\n // same-batch reconnect cancels the probe and the teleport survives; out of the\n // observed root, no reconnect comes and the probe restores, so the content is\n // never stranded at the destination with a dead owner.\n this.#gate.disconnected(this, () => {\n const current = portalState.get(this.element);\n if (current) this.#restore(current);\n });\n }\n\n /** Returns the node to its placeholder (or removes it) and clears the bookkeeping. */\n #restore(state: { node: HTMLElement; placeholder: Comment }): void {\n portalState.delete(this.element);\n const { node, placeholder } = state;\n\n node.removeAttribute(\"data-portaled\");\n if (this.restoreValue && placeholder.parentNode) {\n placeholder.parentNode.insertBefore(node, placeholder);\n } else {\n node.remove();\n }\n placeholder.remove();\n this.dispatch(\"unmount\", { detail: {} });\n }\n\n /** Resolves the destination for `to`, tolerating an invalid selector. */\n #destination(): Element | null {\n const selector = this.toValue.trim();\n if (!selector) return null;\n try {\n return document.querySelector(selector);\n } catch {\n return null;\n }\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/detach_gate.ts","../../src/controllers/portal_controller.ts"],"names":[],"mappings":"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","file":"portal_controller.js","sourcesContent":["/**\n * Shared \"in-page move vs real detach\" discriminator for `disconnect()`.\n *\n * A mid-session `disconnect()` whose element is still in the document is\n * ambiguous. It can be:\n *\n * - an **in-page move** — a consumer (sortable, a teleport) re-inserted the\n * element; Stimulus reconnects the SAME instance in the same mutation batch\n * and the controller's runtime state must survive;\n * - a **detach that keeps the element** — `data-controller` no longer lists the\n * identifier (a Turbo 8 morph), or the element moved outside the observed\n * root (a scoped `Application.start(root)`, a shadow root); no reconnect will\n * ever come and the controller must tear down or it leaks its sessions,\n * document listeners, and moved nodes.\n *\n * Neither available signal decides this alone. A synchronous `data-controller`\n * token check catches the morph case but misses an observed-root exit (the\n * token stays, the reconnect never comes). Deferring teardown one microtask and\n * letting `connect()` cancel it catches the root exit, but leans on Stimulus\n * reconnecting moved elements within the same mutation batch — internal\n * behavior, not a public contract. {@link DetachGate} composes both: the token\n * check is the synchronous fast path (no probe, no reliance on batching), the\n * microtask probe is the fallback for the genuinely ambiguous remainder.\n *\n * Policy stays with the consumer; this util owns the discrimination only:\n *\n * - **Probe** ({@link disconnected}) when the teardown must eventually happen —\n * live drag sessions, document listeners, teleported *content*. Call\n * {@link cancel} from `connect()` (a reconnect means in-page move) and from\n * the head of any direct teardown path (see below).\n * - **Keep** (bare {@link DetachGate.isDetached}) when undoing the ambiguous\n * case would fight the controller's own effect: a controller that moves its\n * OWN element (portal's no-`content` form) exits a scoped observed root as\n * its normal job — a probe-driven teardown would restore the element into\n * the root, reconnect, re-teleport, and disconnect again, forever. Such\n * controllers keep their state on an ambiguous disconnect and tear down only\n * on {@link DetachGate.isDetached}.\n *\n * Event convention for probe consumers: teardown into a dead (detached) tree is\n * silent — consumers restore from their own `connect()`; a detach that keeps the\n * tree alive must end an in-flight session with a `cancel` event, or composing\n * consumers strand their session bookkeeping.\n */\n\n/** The slice of a Stimulus controller the gate inspects. */\nexport interface DetachGateHost {\n readonly element: Element;\n readonly identifier: string;\n}\n\n/**\n * Per-controller-instance gate deciding whether a `disconnect()` is a real\n * detach. Hold one per controller (`#gate = new DetachGate()`) — Stimulus\n * reuses the instance across reconnects, so the pending probe state carries\n * over exactly as the protocol needs.\n *\n * @example\n * ```ts\n * #gate = new DetachGate();\n *\n * connect(): void {\n * this.#gate.cancel(); // a reconnect: the element moved in-page\n * // …\n * }\n *\n * disconnect(): void {\n * this.#gate.disconnected(this, () => this.#teardown());\n * }\n *\n * #teardown(): void {\n * this.#gate.cancel(); // disarm a still-queued probe (double-run guard)\n * // …\n * }\n * ```\n */\nexport class DetachGate {\n /** Set while a probe is queued, waiting for a reconnect to cancel it. */\n #pending = false;\n\n /**\n * True while a probe is queued — the last disconnect was ambiguous and no\n * reconnect has cancelled it yet. Read it from `connect()` to tell the\n * reconnect half of an in-page move from a first connect: a controller whose\n * initialisation restarts a measurement (a min-duration floor, an elapsed\n * counter) must skip it for the move, where nothing actually restarted.\n */\n get pending(): boolean {\n return this.#pending;\n }\n\n /**\n * True when the disconnect is definitely a real detach — the element left\n * the document, or `data-controller` no longer lists the identifier. False\n * means ambiguous (in-page move or observed-root exit), NOT \"alive\".\n */\n static isDetached(host: DetachGateHost): boolean {\n if (!host.element.isConnected) return true;\n const tokens = (host.element.getAttribute(\"data-controller\") ?? \"\").split(/\\s+/);\n return !tokens.includes(host.identifier);\n }\n\n /**\n * Call from `disconnect()`: runs `teardown` synchronously on a definite\n * detach (fast path), otherwise defers it one microtask — a reconnect\n * ({@link cancel} from `connect()`) keeps the state, no reconnect runs it.\n * One microtask is the whole probe window: Stimulus reconnects a moved\n * element within the same mutation batch, before the checkpoint drains.\n */\n disconnected(host: DetachGateHost, teardown: () => void): void {\n if (DetachGate.isDetached(host)) {\n // Also disarms a probe a previous ambiguous disconnect left queued\n // (defer, element removed, disconnect again) — exactly one teardown.\n this.#pending = false;\n teardown();\n return;\n }\n this.#pending = true;\n queueMicrotask(() => {\n if (!this.#pending) return;\n this.#pending = false;\n teardown();\n });\n }\n\n /**\n * Disarms a pending probe. Call from `connect()` (the reconnect that proves\n * an in-page move) and from the head of any teardown path not routed through\n * {@link disconnected} (disabled-toggle, Escape), so an orphaned probe can\n * never run the teardown a second time.\n */\n cancel(): void {\n this.#pending = false;\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { DetachGate } from \"../utils/detach_gate\";\n\n/**\n * Teleport bookkeeping keyed by the controller element (stable across the\n * connect/disconnect churn some DOM runtimes emit when an observed element is moved).\n * Holds the moved node, its placeholder, and the owner — the instance that connected\n * last. Only the owner finishes a probe-driven teardown, so an instance the element has\n * already replaced cannot rewind a teleport the live one holds. The no-`content` form\n * needs no owner check: it restores only on a definite detach, where no successor comes.\n */\nconst portalState = new WeakMap<\n Element,\n { node: HTMLElement; placeholder: Comment; owner: PortalController }\n>();\n\n/**\n * Headless **portal / teleport**: moves an element to another place in the DOM (e.g.\n * directly under `body`) on connect and cleans up on disconnect — the shared substrate\n * for overlays that must escape an ancestor's `overflow: hidden`, `transform`, or\n * stacking context (no APG pattern; a DOM utility).\n *\n * Markup contract (identifier: `stimeo--portal`):\n * <div data-controller=\"stimeo--portal\" data-stimeo--portal-to-value=\"body\">\n * <div data-stimeo--portal-target=\"content\">Teleported content</div>\n * </div>\n *\n * Moves `content` (or `this.element` when no `content` target) into the first element\n * matching `to` (default `body`), `append`ed or `prepend`ed per `position`. A comment\n * placeholder records the original spot so `disconnect()` can return the node there\n * (when `restore`) — or remove it — leaving no orphan behind. The moved node carries\n * `data-portaled`. A `to` the engine cannot read — malformed or empty — falls back to\n * the default; a well-formed selector matching nothing moves nothing.\n *\n * `mount` dispatches `{ target }`; `unmount` dispatches `{}`.\n *\n * @remarks\n * Behavior only — no positioning (pair with `stimeo-ui/positioning`) and no focus\n * trapping (pair with `stimeo--focus` or the overlay). Moving a Stimulus element within\n * the same document re-fires connect/disconnect; {@link DetachGate} reads that pair as an\n * in-page move rather than a detach, which is what keeps the move safe. For Turbo\n * compatibility prefer the `content`-target form: the controller then stays on the\n * in-place source, so its `disconnect()` fires when the original container is replaced\n * and the teleported node is restored/removed rather than orphaned under `body`. The\n * move is idempotent (guarded by the element-keyed bookkeeping) and reversed on\n * `disconnect()` (Turbo\n * navigation included). The \"in-page move vs real detach\" split on `disconnect()` is\n * {@link DetachGate}; in the `content` form the source element may even leave a\n * scoped application's observed root and the content is still restored, while the\n * no-`content` form teleporting itself out of the observed root is fire-and-forget\n * by design (see `disconnect()`).\n */\nexport class PortalController extends Controller<HTMLElement> {\n static override targets = [\"content\"];\n static override values = {\n to: { type: String, default: \"body\" },\n position: { type: String, default: \"append\" },\n restore: { type: Boolean, default: true },\n };\n static events = [\"mount\", \"unmount\"] as const;\n\n declare readonly contentTarget: HTMLElement;\n declare readonly hasContentTarget: boolean;\n\n declare toValue: string;\n declare positionValue: string;\n declare restoreValue: boolean;\n\n /** Decides whether a `disconnect()` is an in-page move or a real detach. */\n readonly #gate = new DetachGate();\n\n /** The `to` declaration after validation; the default when it cannot be parsed. */\n #toSelector = \"body\";\n\n /**\n * Validates the destination declaration once, keeping only a selector the engine can\n * read.\n *\n * A selector reads back as an ordinary string, so a malformed one survives until it is\n * handed to the DOM — where it would take the whole teleport down and leave the part\n * silently doing nothing. Falling back to the default puts the node somewhere visible\n * instead, which reads as a mistake and can be traced back to the declaration.\n */\n toValueChanged(): void {\n const selector = this.toValue;\n if (selector.length > 0) {\n try {\n this.element.matches(selector);\n this.#toSelector = selector;\n return;\n } catch {\n // Unparsable selector: fall through to the default below.\n }\n }\n this.#toSelector = \"body\";\n }\n\n override connect(): void {\n // A reconnect proves an in-page move of the source element: disarm the\n // probe the mid-move disconnect() deferred (see disconnect).\n this.#gate.cancel();\n // Already portaled here (idempotent). A later instance for the same element takes\n // ownership, so the teardown belongs to whichever one is still connected.\n const existing = portalState.get(this.element);\n if (existing) {\n existing.owner = this;\n return;\n }\n const node = this.hasContentTarget ? this.contentTarget : this.element;\n const destination = this.#destination();\n if (!destination || destination === node || node.contains(destination)) return;\n\n const placeholder = document.createComment(\"stimeo--portal\");\n node.parentNode?.insertBefore(placeholder, node);\n portalState.set(this.element, { node, placeholder, owner: this });\n\n if (this.positionValue === \"prepend\") {\n destination.prepend(node);\n } else {\n destination.appendChild(node);\n }\n node.setAttribute(\"data-portaled\", \"true\");\n this.dispatch(\"mount\", { detail: { target: destination } });\n }\n\n override disconnect(): void {\n const state = portalState.get(this.element);\n if (!state) return;\n if (state.node === this.element) {\n // No-`content` form: the teleported node IS the controller element, so the\n // teleport itself may exit a scoped application's observed root — the controller\n // doing its job, not a detach. A probe-driven restore would re-enter the root,\n // reconnect, re-teleport and disconnect again, forever. So an ambiguous\n // disconnect (in the document, identifier still listed — also the churn a\n // self-move emits in some runtimes) KEEPS the teleport, and only a definite\n // detach — the element left the DOM, or `data-controller` no longer lists us\n // (a Turbo 8 morph) — restores. The cost, by design: a teleport that left the\n // observed root is fire-and-forget (Stimulus never fires for it again).\n if (!DetachGate.isDetached(this)) return;\n this.#restore(state);\n return;\n }\n // `content` form: the controller element stays put, so its own teleport emits no\n // churn — an ambiguous disconnect means the SOURCE element moved. In-page, the\n // same-batch reconnect cancels the probe and the teleport survives; out of the\n // observed root, no reconnect comes and the probe restores, so the content is\n // never stranded at the destination with a dead owner.\n this.#gate.disconnected(this, () => {\n const current = portalState.get(this.element);\n if (current?.owner === this) this.#restore(current);\n });\n }\n\n /** Returns the node to its placeholder (or removes it) and clears the bookkeeping. */\n #restore(state: { node: HTMLElement; placeholder: Comment }): void {\n portalState.delete(this.element);\n const { node, placeholder } = state;\n\n node.removeAttribute(\"data-portaled\");\n if (this.restoreValue && placeholder.parentNode) {\n placeholder.parentNode.insertBefore(node, placeholder);\n } else {\n node.remove();\n }\n placeholder.remove();\n this.dispatch(\"unmount\", { detail: {} });\n }\n\n /** Resolves the destination from the validated `to` selector. */\n #destination(): Element | null {\n return document.querySelector(this.#toSelector);\n }\n}\n"]}
|
|
@@ -5,27 +5,35 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
5
5
|
* — a balance, a notification count, a live timestamp — *only while Turbo is showing a
|
|
6
6
|
* preview* (`html[data-turbo-preview]`), so a stale cached snapshot does not briefly flash
|
|
7
7
|
* the old value on a back/restore visit. No APG pattern; it keeps displayed information
|
|
8
|
-
* accurate and
|
|
8
|
+
* accurate and hands focus back if guarding took it.
|
|
9
9
|
*
|
|
10
10
|
* Markup contract (identifier: `stimeo--preview-guard`):
|
|
11
11
|
* <span data-controller="stimeo--preview-guard"
|
|
12
|
-
* data-stimeo--preview-guard-mode-value="placeholder"
|
|
13
12
|
* data-stimeo--preview-guard-placeholder-value="—">¥123,456</span>
|
|
14
13
|
*
|
|
15
14
|
* Watches `<html>` for the `data-turbo-preview` attribute with a `MutationObserver`.
|
|
16
|
-
* While it is present the element is guarded
|
|
17
|
-
* (its box is kept, so nothing shifts
|
|
18
|
-
*
|
|
19
|
-
* preview clears it is restored and
|
|
15
|
+
* While it is present the element is guarded, and `placeholder` alone decides how: empty
|
|
16
|
+
* (the default) makes it `visibility: hidden` — its box is kept, so nothing shifts — and
|
|
17
|
+
* any other value takes its place as the element's content. The element carries
|
|
18
|
+
* `data-preview-hidden` and emits `hide`; when the preview clears it is restored and
|
|
19
|
+
* `show` fires. A `placeholder` changed while guarded re-guards to match it.
|
|
20
20
|
*
|
|
21
21
|
* `hide` and `show` dispatch `{}`.
|
|
22
22
|
*
|
|
23
23
|
* @remarks
|
|
24
24
|
* Behavior only — restoring the *fresh* value is the normal render's job, not this
|
|
25
25
|
* controller's (it just un-hides what was there). State is derived from the DOM (no
|
|
26
|
-
* module-scope state) and the
|
|
27
|
-
*
|
|
28
|
-
*
|
|
26
|
+
* module-scope state), and the guard is reversible in three ways that do not depend on
|
|
27
|
+
* each other: the child nodes a placeholder displaced are held and put back intact, the
|
|
28
|
+
* inline `visibility` is leased so an authored declaration — and one the consumer writes
|
|
29
|
+
* while guarded — survives, and a fresh `connect()` clears a `data-preview-hidden` it
|
|
30
|
+
* finds already on the element. The rewind Turbo's snapshot needs runs on
|
|
31
|
+
* `turbo:before-cache` rather than `disconnect()`, so an in-page move keeps the guard
|
|
32
|
+
* instead of flashing the stale value between the two lifecycle calls — and the instance
|
|
33
|
+
* that comes back re-forms it rather than reading the hook as someone else's leftover.
|
|
34
|
+
*
|
|
35
|
+
* Focus is only ever handed back, never taken: an element that held focus when the guard
|
|
36
|
+
* went up gets it back on show, and focus resting anywhere else is left alone.
|
|
29
37
|
*/
|
|
30
38
|
declare class PreviewGuardController extends Controller<HTMLElement> {
|
|
31
39
|
#private;
|
|
@@ -34,16 +42,16 @@ declare class PreviewGuardController extends Controller<HTMLElement> {
|
|
|
34
42
|
type: StringConstructor;
|
|
35
43
|
default: string;
|
|
36
44
|
};
|
|
37
|
-
mode: {
|
|
38
|
-
type: StringConstructor;
|
|
39
|
-
default: string;
|
|
40
|
-
};
|
|
41
45
|
};
|
|
42
46
|
static events: readonly ["hide", "show"];
|
|
43
47
|
placeholderValue: string;
|
|
44
|
-
modeValue: string;
|
|
45
48
|
connect(): void;
|
|
46
49
|
disconnect(): void;
|
|
50
|
+
/**
|
|
51
|
+
* Re-guards to match a `placeholder` changed at runtime — the value is the content on
|
|
52
|
+
* display while the guard is up, so a morph that swaps it must not leave the old one.
|
|
53
|
+
*/
|
|
54
|
+
placeholderValueChanged(): void;
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
export { PreviewGuardController };
|
|
@@ -1,19 +1,108 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/preview_guard_controller.ts
|
|
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/style_property_lease.ts
|
|
35
|
+
var StylePropertyLease = class {
|
|
36
|
+
#property;
|
|
37
|
+
#records = /* @__PURE__ */ new Map();
|
|
38
|
+
/** @param property - The CSS property whose temporary values this lease owns. */
|
|
39
|
+
constructor(property) {
|
|
40
|
+
this.#property = property;
|
|
41
|
+
}
|
|
42
|
+
/** Writes or removes the leased declaration while preserving its authored value. */
|
|
43
|
+
write(element, value, priority = "") {
|
|
44
|
+
const existing = this.#records.get(element);
|
|
45
|
+
if (existing) {
|
|
46
|
+
existing.writtenValue = value;
|
|
47
|
+
existing.writtenPriority = value === null ? "" : priority;
|
|
48
|
+
} else {
|
|
49
|
+
this.#records.set(element, {
|
|
50
|
+
originalValue: element.style.getPropertyValue(this.#property),
|
|
51
|
+
originalPriority: element.style.getPropertyPriority(this.#property),
|
|
52
|
+
writtenValue: value,
|
|
53
|
+
writtenPriority: value === null ? "" : priority
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
this.#reflect(element, value, priority);
|
|
57
|
+
}
|
|
58
|
+
/** Returns one lease without overwriting a later consumer declaration. */
|
|
59
|
+
return(element) {
|
|
60
|
+
const record = this.#records.get(element);
|
|
61
|
+
if (!record) return;
|
|
62
|
+
this.#records.delete(element);
|
|
63
|
+
const style = element.style;
|
|
64
|
+
const stillOwned = style.getPropertyValue(this.#property) === (record.writtenValue ?? "") && style.getPropertyPriority(this.#property) === record.writtenPriority;
|
|
65
|
+
if (stillOwned) {
|
|
66
|
+
this.#reflect(element, record.originalValue, record.originalPriority);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Returns every outstanding declaration lease. */
|
|
70
|
+
returnAll() {
|
|
71
|
+
for (const element of Array.from(this.#records.keys())) this.return(element);
|
|
72
|
+
}
|
|
73
|
+
/** Reflects only a real declaration transition. */
|
|
74
|
+
#reflect(element, value, priority) {
|
|
75
|
+
const style = element.style;
|
|
76
|
+
const nextValue = value ?? "";
|
|
77
|
+
const nextPriority = value === null ? "" : priority;
|
|
78
|
+
if (style.getPropertyValue(this.#property) === nextValue && style.getPropertyPriority(this.#property) === nextPriority) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (value === null) style.removeProperty(this.#property);
|
|
82
|
+
else style.setProperty(this.#property, value, priority);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
3
86
|
// src/controllers/preview_guard_controller.ts
|
|
4
87
|
var PreviewGuardController = class extends Controller {
|
|
5
88
|
static values = {
|
|
6
|
-
placeholder: { type: String, default: "" }
|
|
7
|
-
mode: { type: String, default: "hide" }
|
|
89
|
+
placeholder: { type: String, default: "" }
|
|
8
90
|
};
|
|
9
91
|
static events = ["hide", "show"];
|
|
92
|
+
#visibility = new StylePropertyLease("visibility");
|
|
93
|
+
#beforeCache = new BeforeCacheReset(() => this.#restore());
|
|
10
94
|
#observer = null;
|
|
95
|
+
#connected = false;
|
|
11
96
|
#hidden = false;
|
|
12
|
-
/**
|
|
13
|
-
#
|
|
14
|
-
/**
|
|
15
|
-
#
|
|
97
|
+
/** Child nodes a placeholder displaced; non-null marks that content — not visibility — was swapped. */
|
|
98
|
+
#savedNodes = null;
|
|
99
|
+
/** The descendant that held focus when the guard went up, so show can hand it back. */
|
|
100
|
+
#focused = null;
|
|
16
101
|
connect() {
|
|
102
|
+
this.#connected = true;
|
|
103
|
+
if (this.#hidden) this.#reguard();
|
|
104
|
+
else this.element.removeAttribute("data-preview-hidden");
|
|
105
|
+
this.#beforeCache.activate();
|
|
17
106
|
if (typeof MutationObserver !== "undefined") {
|
|
18
107
|
this.#observer = new MutationObserver(() => this.#sync());
|
|
19
108
|
this.#observer.observe(document.documentElement, {
|
|
@@ -24,44 +113,115 @@ var PreviewGuardController = class extends Controller {
|
|
|
24
113
|
this.#sync();
|
|
25
114
|
}
|
|
26
115
|
disconnect() {
|
|
116
|
+
this.#connected = false;
|
|
117
|
+
this.#beforeCache.deactivate();
|
|
27
118
|
this.#observer?.disconnect();
|
|
28
119
|
this.#observer = null;
|
|
29
|
-
this.#restore();
|
|
30
120
|
}
|
|
31
|
-
/**
|
|
121
|
+
/**
|
|
122
|
+
* Re-guards to match a `placeholder` changed at runtime — the value is the content on
|
|
123
|
+
* display while the guard is up, so a morph that swaps it must not leave the old one.
|
|
124
|
+
*/
|
|
125
|
+
placeholderValueChanged() {
|
|
126
|
+
if (!this.#connected || !this.#hidden) return;
|
|
127
|
+
this.#reguard();
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Reflects the current `data-turbo-preview` state onto the element.
|
|
131
|
+
*
|
|
132
|
+
* @stimeoRenderRoot
|
|
133
|
+
*/
|
|
32
134
|
#sync() {
|
|
33
135
|
const previewing = document.documentElement.hasAttribute("data-turbo-preview");
|
|
34
136
|
if (previewing && !this.#hidden) this.#hide();
|
|
35
137
|
else if (!previewing && this.#hidden) this.#show();
|
|
36
138
|
}
|
|
37
139
|
#hide() {
|
|
140
|
+
this.#focused = this.#focusedInside();
|
|
141
|
+
this.#applyGuard();
|
|
142
|
+
this.dispatch("hide", { detail: {} });
|
|
143
|
+
}
|
|
144
|
+
/** Puts the guard up in the form the current `placeholder` calls for. */
|
|
145
|
+
#applyGuard() {
|
|
38
146
|
this.#hidden = true;
|
|
39
|
-
if (this.
|
|
40
|
-
this.#
|
|
41
|
-
this.element.textContent = this.placeholderValue;
|
|
147
|
+
if (this.placeholderValue === "") {
|
|
148
|
+
this.#visibility.write(this.element, "hidden");
|
|
42
149
|
} else {
|
|
43
|
-
this.#
|
|
44
|
-
this.element.
|
|
150
|
+
this.#savedNodes = document.createDocumentFragment();
|
|
151
|
+
this.#savedNodes.append(...this.element.childNodes);
|
|
152
|
+
this.element.textContent = this.placeholderValue;
|
|
45
153
|
}
|
|
46
154
|
this.element.setAttribute("data-preview-hidden", "true");
|
|
47
|
-
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Re-forms a guard that is already up so it matches the current `placeholder`.
|
|
158
|
+
*
|
|
159
|
+
* No event: `hide` reports that the guard went up, and it has not come down. Swapping
|
|
160
|
+
* one stand-in text for another writes only that text — putting the held content back
|
|
161
|
+
* first would reconnect the whole subtree for an instant. Only a change of *form* —
|
|
162
|
+
* to or from the empty placeholder — has to revert, and the focus the guard is holding
|
|
163
|
+
* carries across it rather than being handed back and taken again.
|
|
164
|
+
*/
|
|
165
|
+
#reguard() {
|
|
166
|
+
if (this.#savedNodes && this.placeholderValue !== "") {
|
|
167
|
+
this.element.textContent = this.placeholderValue;
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const held = this.#focused;
|
|
171
|
+
this.#revert();
|
|
172
|
+
this.#applyGuard();
|
|
173
|
+
this.#focused = held;
|
|
48
174
|
}
|
|
49
175
|
#show() {
|
|
50
176
|
this.#restore();
|
|
51
177
|
this.dispatch("show", { detail: {} });
|
|
52
178
|
}
|
|
53
|
-
/** Reverts the guard
|
|
179
|
+
/** Reverts the guard and hands focus back. Used by show and by the snapshot rewind. */
|
|
54
180
|
#restore() {
|
|
55
|
-
|
|
181
|
+
this.#revert();
|
|
182
|
+
this.#refocus();
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Puts the element back the way the guard found it.
|
|
186
|
+
*
|
|
187
|
+
* Every step is a no-op on an element this controller never guarded: the lease returns
|
|
188
|
+
* only declarations it recorded, and the hook is removed whether or not it is there.
|
|
189
|
+
*/
|
|
190
|
+
#revert() {
|
|
56
191
|
this.#hidden = false;
|
|
57
|
-
if (this.#
|
|
58
|
-
this.element.textContent =
|
|
59
|
-
this.#
|
|
192
|
+
if (this.#savedNodes) {
|
|
193
|
+
this.element.textContent = "";
|
|
194
|
+
this.element.append(this.#savedNodes);
|
|
195
|
+
this.#savedNodes = null;
|
|
60
196
|
} else {
|
|
61
|
-
this.element
|
|
197
|
+
this.#visibility.return(this.element);
|
|
62
198
|
}
|
|
63
199
|
this.element.removeAttribute("data-preview-hidden");
|
|
64
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* The focused element the guard is about to make unfocusable, if any.
|
|
203
|
+
*
|
|
204
|
+
* The element itself counts as well as its descendants: guarding takes focus either way
|
|
205
|
+
* — a `visibility: hidden` subtree cannot hold it, and a placeholder displaces the nodes
|
|
206
|
+
* outright — so the browser drops focus to `<body>` the moment the guard goes up.
|
|
207
|
+
*/
|
|
208
|
+
#focusedInside() {
|
|
209
|
+
const active = document.activeElement;
|
|
210
|
+
return active instanceof HTMLElement && this.element.contains(active) ? active : null;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Hands focus back to the element the guard took it from.
|
|
214
|
+
*
|
|
215
|
+
* Only when that element is still in the document and focus has not moved on since —
|
|
216
|
+
* anything else is the user's or another controller's, and putting it back would be
|
|
217
|
+
* taking it.
|
|
218
|
+
*/
|
|
219
|
+
#refocus() {
|
|
220
|
+
const target = this.#focused;
|
|
221
|
+
this.#focused = null;
|
|
222
|
+
if (!target?.isConnected || document.activeElement !== document.body) return;
|
|
223
|
+
target.focus();
|
|
224
|
+
}
|
|
65
225
|
};
|
|
66
226
|
|
|
67
227
|
export { PreviewGuardController };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/controllers/preview_guard_controller.ts"],"names":[],"mappings":"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","file":"preview_guard_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless **preview guard** (Hotwire-specific): hides or placeholders a volatile element\n * — a balance, a notification count, a live timestamp — *only while Turbo is showing a\n * preview* (`html[data-turbo-preview]`), so a stale cached snapshot does not briefly flash\n * the old value on a back/restore visit. No APG pattern; it keeps displayed information\n * accurate and never moves focus.\n *\n * Markup contract (identifier: `stimeo--preview-guard`):\n * <span data-controller=\"stimeo--preview-guard\"\n * data-stimeo--preview-guard-mode-value=\"placeholder\"\n * data-stimeo--preview-guard-placeholder-value=\"—\">¥123,456</span>\n *\n * Watches `<html>` for the `data-turbo-preview` attribute with a `MutationObserver`.\n * While it is present the element is guarded: `mode=\"hide\"` makes it `visibility: hidden`\n * (its box is kept, so nothing shifts), `mode=\"placeholder\"` swaps its text for\n * `placeholder`. The element carries `data-preview-hidden` and emits `hide`; when the\n * preview clears it is restored and `show` fires.\n *\n * `hide` and `show` dispatch `{}`.\n *\n * @remarks\n * Behavior only — restoring the *fresh* value is the normal render's job, not this\n * controller's (it just un-hides what was there). State is derived from the DOM (no\n * module-scope state) and the original value is saved on hide and put back on show, so a\n * guard that connects mid-preview hides immediately and a `disconnect()` (Turbo navigation\n * included) restores the element and severs the observer. Focus is never moved.\n */\nexport class PreviewGuardController extends Controller<HTMLElement> {\n static override values = {\n placeholder: { type: String, default: \"\" },\n mode: { type: String, default: \"hide\" },\n };\n static events = [\"hide\", \"show\"] as const;\n\n declare placeholderValue: string;\n declare modeValue: string;\n\n #observer: MutationObserver | null = null;\n #hidden = false;\n /** Saved inline visibility (hide mode), restored on show. */\n #savedVisibility = \"\";\n /** Saved text (placeholder mode); non-null marks that text — not visibility — was swapped. */\n #savedText: string | null = null;\n\n override connect(): void {\n if (typeof MutationObserver !== \"undefined\") {\n this.#observer = new MutationObserver(() => this.#sync());\n this.#observer.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"data-turbo-preview\"],\n });\n }\n // Sync once in case we connect while a preview is already on screen.\n this.#sync();\n }\n\n override disconnect(): void {\n this.#observer?.disconnect();\n this.#observer = null;\n this.#restore(); // never leave the element guarded after teardown\n }\n\n /** Reflects the current `data-turbo-preview` state onto the element. */\n #sync(): void {\n const previewing = document.documentElement.hasAttribute(\"data-turbo-preview\");\n if (previewing && !this.#hidden) this.#hide();\n else if (!previewing && this.#hidden) this.#show();\n }\n\n #hide(): void {\n this.#hidden = true;\n if (this.modeValue === \"placeholder\") {\n this.#savedText = this.element.textContent;\n this.element.textContent = this.placeholderValue;\n } else {\n this.#savedVisibility = this.element.style.visibility;\n this.element.style.visibility = \"hidden\";\n }\n this.element.setAttribute(\"data-preview-hidden\", \"true\");\n this.dispatch(\"hide\", { detail: {} });\n }\n\n #show(): void {\n this.#restore();\n this.dispatch(\"show\", { detail: {} });\n }\n\n /** Reverts the guard. Safe to call when not hidden (no-op) — used by show and teardown. */\n #restore(): void {\n if (!this.#hidden) return;\n this.#hidden = false;\n if (this.#savedText !== null) {\n this.element.textContent = this.#savedText;\n this.#savedText = null;\n } else {\n this.element.style.visibility = this.#savedVisibility;\n }\n this.element.removeAttribute(\"data-preview-hidden\");\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/before_cache_reset.ts","../../src/utils/style_property_lease.ts","../../src/controllers/preview_guard_controller.ts"],"names":[],"mappings":"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","file":"preview_guard_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.\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","/** One temporarily controlled style property and the authored declaration it displaced. */\ninterface StylePropertyLeaseRecord {\n readonly originalValue: string;\n readonly originalPriority: string;\n writtenValue: string | null;\n writtenPriority: string;\n}\n\n/**\n * Temporarily controls one inline CSS property across a changing set of elements.\n *\n * Authored value and priority are restored only while the declaration still matches\n * the last leased write. A later consumer write therefore wins.\n *\n * The lease has no lifecycle of its own, so it never subscribes to document events:\n * a consumer returns its leases from its own `turbo:before-cache` rewind.\n */\nexport class StylePropertyLease<T extends HTMLElement = HTMLElement> {\n readonly #property: string;\n readonly #records = new Map<T, StylePropertyLeaseRecord>();\n\n /** @param property - The CSS property whose temporary values this lease owns. */\n constructor(property: string) {\n this.#property = property;\n }\n\n /** Writes or removes the leased declaration while preserving its authored value. */\n write(element: T, value: string | null, priority = \"\"): void {\n const existing = this.#records.get(element);\n if (existing) {\n existing.writtenValue = value;\n existing.writtenPriority = value === null ? \"\" : priority;\n } else {\n this.#records.set(element, {\n originalValue: element.style.getPropertyValue(this.#property),\n originalPriority: element.style.getPropertyPriority(this.#property),\n writtenValue: value,\n writtenPriority: value === null ? \"\" : priority,\n });\n }\n\n this.#reflect(element, value, priority);\n }\n\n /** Returns one lease without overwriting a later consumer declaration. */\n return(element: T): void {\n const record = this.#records.get(element);\n if (!record) return;\n this.#records.delete(element);\n\n const style = element.style;\n const stillOwned =\n style.getPropertyValue(this.#property) === (record.writtenValue ?? \"\") &&\n style.getPropertyPriority(this.#property) === record.writtenPriority;\n if (stillOwned) {\n this.#reflect(element, record.originalValue, record.originalPriority);\n }\n }\n\n /** Returns every outstanding declaration lease. */\n returnAll(): void {\n for (const element of Array.from(this.#records.keys())) this.return(element);\n }\n\n /** Reflects only a real declaration transition. */\n #reflect(element: T, value: string | null, priority: string): void {\n const style = element.style;\n const nextValue = value ?? \"\";\n const nextPriority = value === null ? \"\" : priority;\n if (\n style.getPropertyValue(this.#property) === nextValue &&\n style.getPropertyPriority(this.#property) === nextPriority\n ) {\n return;\n }\n if (value === null) style.removeProperty(this.#property);\n else style.setProperty(this.#property, value, priority);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { BeforeCacheReset } from \"../utils/before_cache_reset\";\nimport { StylePropertyLease } from \"../utils/style_property_lease\";\n\n/**\n * Headless **preview guard** (Hotwire-specific): hides or placeholders a volatile element\n * — a balance, a notification count, a live timestamp — *only while Turbo is showing a\n * preview* (`html[data-turbo-preview]`), so a stale cached snapshot does not briefly flash\n * the old value on a back/restore visit. No APG pattern; it keeps displayed information\n * accurate and hands focus back if guarding took it.\n *\n * Markup contract (identifier: `stimeo--preview-guard`):\n * <span data-controller=\"stimeo--preview-guard\"\n * data-stimeo--preview-guard-placeholder-value=\"—\">¥123,456</span>\n *\n * Watches `<html>` for the `data-turbo-preview` attribute with a `MutationObserver`.\n * While it is present the element is guarded, and `placeholder` alone decides how: empty\n * (the default) makes it `visibility: hidden` — its box is kept, so nothing shifts — and\n * any other value takes its place as the element's content. The element carries\n * `data-preview-hidden` and emits `hide`; when the preview clears it is restored and\n * `show` fires. A `placeholder` changed while guarded re-guards to match it.\n *\n * `hide` and `show` dispatch `{}`.\n *\n * @remarks\n * Behavior only — restoring the *fresh* value is the normal render's job, not this\n * controller's (it just un-hides what was there). State is derived from the DOM (no\n * module-scope state), and the guard is reversible in three ways that do not depend on\n * each other: the child nodes a placeholder displaced are held and put back intact, the\n * inline `visibility` is leased so an authored declaration — and one the consumer writes\n * while guarded — survives, and a fresh `connect()` clears a `data-preview-hidden` it\n * finds already on the element. The rewind Turbo's snapshot needs runs on\n * `turbo:before-cache` rather than `disconnect()`, so an in-page move keeps the guard\n * instead of flashing the stale value between the two lifecycle calls — and the instance\n * that comes back re-forms it rather than reading the hook as someone else's leftover.\n *\n * Focus is only ever handed back, never taken: an element that held focus when the guard\n * went up gets it back on show, and focus resting anywhere else is left alone.\n */\nexport class PreviewGuardController extends Controller<HTMLElement> {\n static override values = {\n placeholder: { type: String, default: \"\" },\n };\n static events = [\"hide\", \"show\"] as const;\n\n declare placeholderValue: string;\n\n readonly #visibility = new StylePropertyLease(\"visibility\");\n readonly #beforeCache = new BeforeCacheReset(() => this.#restore());\n\n #observer: MutationObserver | null = null;\n #connected = false;\n #hidden = false;\n /** Child nodes a placeholder displaced; non-null marks that content — not visibility — was swapped. */\n #savedNodes: DocumentFragment | null = null;\n /** The descendant that held focus when the guard went up, so show can hand it back. */\n #focused: HTMLElement | null = null;\n\n override connect(): void {\n this.#connected = true;\n // The hook means \"the connection now running has this element guarded\", and which of\n // the two connects is happening is only knowable from the instance. A restored\n // snapshot brings a fresh controller to an element that still carries the hook, with\n // nothing in the DOM to say what was underneath it, so that one drops it. An in-page\n // move brings the *same* instance back still holding the guard, so that one re-forms\n // instead — against the `placeholder` as it stands now, since a morph can swap it\n // while the element is detached, where the value callback has no connection to act on.\n if (this.#hidden) this.#reguard();\n else this.element.removeAttribute(\"data-preview-hidden\");\n this.#beforeCache.activate();\n if (typeof MutationObserver !== \"undefined\") {\n this.#observer = new MutationObserver(() => this.#sync());\n this.#observer.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"data-turbo-preview\"],\n });\n }\n // Sync once in case we connect while a preview is already on screen.\n this.#sync();\n }\n\n override disconnect(): void {\n this.#connected = false;\n this.#beforeCache.deactivate();\n this.#observer?.disconnect();\n this.#observer = null;\n }\n\n /**\n * Re-guards to match a `placeholder` changed at runtime — the value is the content on\n * display while the guard is up, so a morph that swaps it must not leave the old one.\n */\n placeholderValueChanged(): void {\n if (!this.#connected || !this.#hidden) return;\n this.#reguard();\n }\n\n /**\n * Reflects the current `data-turbo-preview` state onto the element.\n *\n * @stimeoRenderRoot\n */\n #sync(): void {\n const previewing = document.documentElement.hasAttribute(\"data-turbo-preview\");\n if (previewing && !this.#hidden) this.#hide();\n else if (!previewing && this.#hidden) this.#show();\n }\n\n #hide(): void {\n this.#focused = this.#focusedInside();\n this.#applyGuard();\n this.dispatch(\"hide\", { detail: {} });\n }\n\n /** Puts the guard up in the form the current `placeholder` calls for. */\n #applyGuard(): void {\n this.#hidden = true;\n if (this.placeholderValue === \"\") {\n this.#visibility.write(this.element, \"hidden\");\n } else {\n this.#savedNodes = document.createDocumentFragment();\n this.#savedNodes.append(...this.element.childNodes);\n this.element.textContent = this.placeholderValue;\n }\n this.element.setAttribute(\"data-preview-hidden\", \"true\");\n }\n\n /**\n * Re-forms a guard that is already up so it matches the current `placeholder`.\n *\n * No event: `hide` reports that the guard went up, and it has not come down. Swapping\n * one stand-in text for another writes only that text — putting the held content back\n * first would reconnect the whole subtree for an instant. Only a change of *form* —\n * to or from the empty placeholder — has to revert, and the focus the guard is holding\n * carries across it rather than being handed back and taken again.\n */\n #reguard(): void {\n if (this.#savedNodes && this.placeholderValue !== \"\") {\n this.element.textContent = this.placeholderValue;\n return;\n }\n const held = this.#focused;\n this.#revert();\n this.#applyGuard();\n this.#focused = held;\n }\n\n #show(): void {\n this.#restore();\n this.dispatch(\"show\", { detail: {} });\n }\n\n /** Reverts the guard and hands focus back. Used by show and by the snapshot rewind. */\n #restore(): void {\n this.#revert();\n this.#refocus();\n }\n\n /**\n * Puts the element back the way the guard found it.\n *\n * Every step is a no-op on an element this controller never guarded: the lease returns\n * only declarations it recorded, and the hook is removed whether or not it is there.\n */\n #revert(): void {\n this.#hidden = false;\n if (this.#savedNodes) {\n this.element.textContent = \"\";\n this.element.append(this.#savedNodes);\n this.#savedNodes = null;\n } else {\n this.#visibility.return(this.element);\n }\n this.element.removeAttribute(\"data-preview-hidden\");\n }\n\n /**\n * The focused element the guard is about to make unfocusable, if any.\n *\n * The element itself counts as well as its descendants: guarding takes focus either way\n * — a `visibility: hidden` subtree cannot hold it, and a placeholder displaces the nodes\n * outright — so the browser drops focus to `<body>` the moment the guard goes up.\n */\n #focusedInside(): HTMLElement | null {\n const active = document.activeElement;\n return active instanceof HTMLElement && this.element.contains(active) ? active : null;\n }\n\n /**\n * Hands focus back to the element the guard took it from.\n *\n * Only when that element is still in the document and focus has not moved on since —\n * anything else is the user's or another controller's, and putting it back would be\n * taking it.\n */\n #refocus(): void {\n const target = this.#focused;\n this.#focused = null;\n if (!target?.isConnected || document.activeElement !== document.body) return;\n target.focus();\n }\n}\n"]}
|
|
@@ -16,13 +16,20 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
16
16
|
*
|
|
17
17
|
* Progress is `0` before the article's top reaches the viewport top and `1`
|
|
18
18
|
* once its bottom fits the viewport: `-top / (height - viewportHeight)`,
|
|
19
|
-
* clamped.
|
|
19
|
+
* clamped. An article no taller than the viewport has no such span, so reading
|
|
20
|
+
* it is binary: `1` from the moment its top reaches the viewport top. An
|
|
21
|
+
* article with no layout box at all — a `display: none` ancestor, a collapsed
|
|
22
|
+
* `<details>`, an inactive tab panel — is not measured: an empty rect sits at
|
|
23
|
+
* the document origin and carries no reading position, so publishing anything
|
|
24
|
+
* for it would report a place the reader never reached.
|
|
25
|
+
*
|
|
26
|
+
* The property is written on the controller element **and** on
|
|
20
27
|
* `document.documentElement`, so a fixed bar anywhere in the page can consume
|
|
21
|
-
* it without being a descendant.
|
|
22
|
-
*
|
|
23
|
-
* `complete` fires on *reaching* 1
|
|
24
|
-
*
|
|
25
|
-
*
|
|
28
|
+
* it without being a descendant. Both are written through a lease, so an
|
|
29
|
+
* authored declaration comes back on teardown and a later writer is left alone.
|
|
30
|
+
* `complete` fires on *reaching* 1, and never during the baseline: the frame in
|
|
31
|
+
* which the controller connects belongs to establishing where the reader
|
|
32
|
+
* already is (a restored scroll position lands there), not to reading.
|
|
26
33
|
*
|
|
27
34
|
* `change` dispatches `{ progress }`.
|
|
28
35
|
*
|
|
@@ -30,9 +37,11 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
30
37
|
* Behavior only — the bar itself (and hiding it, e.g. before any scroll) is
|
|
31
38
|
* the consumer's CSS; the progress value carries no ARIA (a decorative
|
|
32
39
|
* indicator — mark the bar `aria-hidden`; a *semantic* progress belongs to
|
|
33
|
-
* `stimeo--progress`).
|
|
34
|
-
*
|
|
35
|
-
*
|
|
40
|
+
* `stimeo--progress`). The article's own box is watched as well as the
|
|
41
|
+
* viewport, so content that settles late (images, fonts) re-measures instead of
|
|
42
|
+
* leaving a stale span. Scroll and layout work is rAF-throttled; the listeners,
|
|
43
|
+
* the observers, any pending frame and both leased declarations are released on
|
|
44
|
+
* `disconnect()` and returned again for a `turbo:before-cache` snapshot.
|
|
36
45
|
*/
|
|
37
46
|
declare class ReadingProgressController extends Controller<HTMLElement> {
|
|
38
47
|
#private;
|