stimeo-ui 0.13.0 → 0.15.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 +73 -5
- package/dist/cable/index.d.ts +1 -1
- package/dist/cable/index.js +60 -14
- package/dist/cable/index.js.map +1 -1
- package/dist/controllers/accordion_controller.d.ts +1 -1
- package/dist/controllers/accordion_controller.js.map +1 -1
- package/dist/controllers/announcer_controller.js +88 -61
- package/dist/controllers/announcer_controller.js.map +1 -1
- package/dist/controllers/auto_submit_controller.js +40 -5
- package/dist/controllers/auto_submit_controller.js.map +1 -1
- package/dist/controllers/breadcrumb_controller.js +29 -3
- package/dist/controllers/breadcrumb_controller.js.map +1 -1
- package/dist/controllers/calendar_controller.d.ts +20 -4
- package/dist/controllers/calendar_controller.js +51 -10
- package/dist/controllers/calendar_controller.js.map +1 -1
- package/dist/controllers/carousel_controller.js +8 -6
- package/dist/controllers/carousel_controller.js.map +1 -1
- package/dist/controllers/checkbox_controller.js +27 -2
- package/dist/controllers/checkbox_controller.js.map +1 -1
- package/dist/controllers/clipboard_controller.d.ts +1 -1
- package/dist/controllers/clipboard_controller.js.map +1 -1
- package/dist/controllers/collapsible_controller.d.ts +1 -1
- package/dist/controllers/collapsible_controller.js.map +1 -1
- package/dist/controllers/conditional_fields_controller.js +36 -7
- package/dist/controllers/conditional_fields_controller.js.map +1 -1
- package/dist/controllers/count_up_controller.js.map +1 -1
- package/dist/controllers/currency_input_controller.d.ts +4 -2
- package/dist/controllers/currency_input_controller.js +77 -25
- package/dist/controllers/currency_input_controller.js.map +1 -1
- package/dist/controllers/date_range_picker_controller.d.ts +10 -3
- package/dist/controllers/date_range_picker_controller.js +43 -10
- package/dist/controllers/date_range_picker_controller.js.map +1 -1
- package/dist/controllers/dialog_controller.d.ts +2 -2
- package/dist/controllers/dialog_controller.js.map +1 -1
- package/dist/controllers/direct_upload_controller.js +21 -10
- package/dist/controllers/direct_upload_controller.js.map +1 -1
- package/dist/controllers/dirty_form_controller.js +32 -12
- package/dist/controllers/dirty_form_controller.js.map +1 -1
- package/dist/controllers/dismissible_controller.d.ts +1 -1
- package/dist/controllers/dismissible_controller.js.map +1 -1
- package/dist/controllers/dropdown_controller.d.ts +1 -1
- package/dist/controllers/dropdown_controller.js.map +1 -1
- package/dist/controllers/flash_controller.js +145 -43
- package/dist/controllers/flash_controller.js.map +1 -1
- package/dist/controllers/form_field_controller.js +7 -1
- package/dist/controllers/form_field_controller.js.map +1 -1
- package/dist/controllers/frame_loading_controller.js +29 -6
- package/dist/controllers/frame_loading_controller.js.map +1 -1
- package/dist/controllers/highlight_controller.js +63 -23
- package/dist/controllers/highlight_controller.js.map +1 -1
- package/dist/controllers/input_mask_controller.d.ts +3 -2
- package/dist/controllers/input_mask_controller.js +33 -12
- package/dist/controllers/input_mask_controller.js.map +1 -1
- package/dist/controllers/intersection_controller.js +10 -5
- package/dist/controllers/intersection_controller.js.map +1 -1
- package/dist/controllers/lazy_frame_controller.js +10 -5
- package/dist/controllers/lazy_frame_controller.js.map +1 -1
- package/dist/controllers/local_time_controller.d.ts +3 -2
- package/dist/controllers/local_time_controller.js +43 -11
- package/dist/controllers/local_time_controller.js.map +1 -1
- package/dist/controllers/masonry_controller.d.ts +4 -4
- package/dist/controllers/masonry_controller.js +32 -14
- package/dist/controllers/masonry_controller.js.map +1 -1
- package/dist/controllers/menu_controller.js +10 -2
- package/dist/controllers/menu_controller.js.map +1 -1
- package/dist/controllers/menubar_controller.js +10 -2
- package/dist/controllers/menubar_controller.js.map +1 -1
- package/dist/controllers/navigation_menu_controller.js +8 -2
- package/dist/controllers/navigation_menu_controller.js.map +1 -1
- package/dist/controllers/optimistic_controller.js +28 -4
- package/dist/controllers/optimistic_controller.js.map +1 -1
- package/dist/controllers/otp_controller.js +23 -10
- package/dist/controllers/otp_controller.js.map +1 -1
- package/dist/controllers/overflow_indicator_controller.js +31 -8
- package/dist/controllers/overflow_indicator_controller.js.map +1 -1
- package/dist/controllers/overflow_menu_controller.js +39 -4
- package/dist/controllers/overflow_menu_controller.js.map +1 -1
- package/dist/controllers/password_reveal_controller.d.ts +1 -1
- package/dist/controllers/password_reveal_controller.js.map +1 -1
- package/dist/controllers/pointer_drag_controller.js +10 -3
- package/dist/controllers/pointer_drag_controller.js.map +1 -1
- package/dist/controllers/portal_controller.js +21 -10
- package/dist/controllers/portal_controller.js.map +1 -1
- package/dist/controllers/radio_group_controller.js +11 -3
- package/dist/controllers/radio_group_controller.js.map +1 -1
- package/dist/controllers/read_more_controller.d.ts +1 -1
- package/dist/controllers/read_more_controller.js +31 -5
- package/dist/controllers/read_more_controller.js.map +1 -1
- package/dist/controllers/reading_progress_controller.js +65 -19
- package/dist/controllers/reading_progress_controller.js.map +1 -1
- package/dist/controllers/relative_time_controller.js +40 -9
- package/dist/controllers/relative_time_controller.js.map +1 -1
- package/dist/controllers/reset_before_cache_controller.js +23 -10
- package/dist/controllers/reset_before_cache_controller.js.map +1 -1
- package/dist/controllers/resizable_controller.js +7 -2
- package/dist/controllers/resizable_controller.js.map +1 -1
- package/dist/controllers/roving_controller.js +7 -3
- package/dist/controllers/roving_controller.js.map +1 -1
- package/dist/controllers/scroll_area_controller.js +70 -26
- package/dist/controllers/scroll_area_controller.js.map +1 -1
- package/dist/controllers/scroll_restore_controller.js +37 -16
- package/dist/controllers/scroll_restore_controller.js.map +1 -1
- package/dist/controllers/scroll_visibility_controller.d.ts +1 -1
- package/dist/controllers/scroll_visibility_controller.js +71 -42
- package/dist/controllers/scroll_visibility_controller.js.map +1 -1
- package/dist/controllers/scrollspy_controller.d.ts +1 -1
- package/dist/controllers/scrollspy_controller.js +76 -32
- package/dist/controllers/scrollspy_controller.js.map +1 -1
- package/dist/controllers/smart_sticky_header_controller.js +72 -37
- package/dist/controllers/smart_sticky_header_controller.js.map +1 -1
- package/dist/controllers/stick_to_bottom_controller.js +29 -3
- package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
- package/dist/controllers/sticky_observer_controller.js +10 -5
- package/dist/controllers/sticky_observer_controller.js.map +1 -1
- package/dist/controllers/submit_once_controller.js +29 -6
- package/dist/controllers/submit_once_controller.js.map +1 -1
- package/dist/controllers/switch_controller.d.ts +1 -1
- package/dist/controllers/switch_controller.js.map +1 -1
- package/dist/controllers/tabs_controller.d.ts +1 -1
- package/dist/controllers/tabs_controller.js.map +1 -1
- package/dist/controllers/textarea_autosize_controller.js +29 -3
- package/dist/controllers/textarea_autosize_controller.js.map +1 -1
- package/dist/controllers/theme_controller.d.ts +2 -3
- package/dist/controllers/theme_controller.js +23 -13
- package/dist/controllers/theme_controller.js.map +1 -1
- package/dist/controllers/toast_controller.d.ts +3 -0
- package/dist/controllers/toast_controller.js +136 -73
- package/dist/controllers/toast_controller.js.map +1 -1
- package/dist/controllers/toggle_group_controller.js +11 -3
- package/dist/controllers/toggle_group_controller.js.map +1 -1
- package/dist/controllers/toolbar_controller.js +7 -3
- package/dist/controllers/toolbar_controller.js.map +1 -1
- package/dist/controllers/transition_controller.js.map +1 -1
- package/dist/index.js +629 -597
- package/dist/index.js.map +1 -1
- package/dist/inspector/cli.d.ts +38 -1
- package/dist/inspector/cli.js +80 -7
- package/dist/inspector/cli.js.map +1 -1
- package/dist/inspector/cli_bin.js +103 -7
- package/dist/inspector/cli_bin.js.map +1 -1
- package/dist/inspector/examples.json +5 -5
- package/dist/inspector/manifest.json +131 -3
- package/package.json +9 -11
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/controllers/scroll_restore_controller.ts"],"names":[],"mappings":"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","file":"scroll_restore_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/** Persisted scroll position; fields are present only for the saved axis. */\ninterface StoredScroll {\n top?: number;\n left?: number;\n}\n\n/**\n * Reads one persisted entry, or `null` when there is nothing usable to read.\n *\n * `JSON.parse` returns `null` for the literal `null` without throwing, so the\n * parse guard alone would hand back a value with no fields to read. Every field the\n * caller applies is validated there, which is what makes a bare literal harmless here.\n */\nfunction parseStored(raw: string | null): StoredScroll | null {\n if (raw === null) return null;\n try {\n return JSON.parse(raw) as StoredScroll | null;\n } catch {\n return null;\n }\n}\n\n/** One axis offset from a persisted entry, or `null` when it cannot be applied. */\nfunction storedOffset(value: unknown): number | null {\n return typeof value === \"number\" && Number.isFinite(value) ? value : null;\n}\n\n/**\n * Headless **Scroll Restore** behavior: persists and restores the scroll\n * position of an inner scrolling region across Turbo Drive navigations (and full\n * reloads within the same tab session). No APG widget — a pure state-preservation\n * utility, like {@link import(\"./scroll_visibility_controller\").ScrollVisibilityController}\n * and the Sticky State Observer.\n *\n * Markup contract (identifier: `stimeo--scroll-restore`):\n * <div data-controller=\"stimeo--scroll-restore\"\n * data-stimeo--scroll-restore-key-value=\"sidebar\"\n * style=\"overflow: auto\">\n * …long content…\n * </div>\n *\n * Turbo swaps the whole `<body>` on navigation, so an inner scroll container is\n * rebuilt with `scrollTop` reset to 0. Rather than have every app hand-write a\n * controller for this (which contradicts a \"ship the behavior\" library), this\n * persists the offset under a stable key in `sessionStorage` and restores it on\n * `connect()`.\n *\n * Values:\n * - `key` — the `sessionStorage` namespace. Empty falls back to the element `id`;\n * with neither, nothing is saved or restored, so positions from unrelated pages\n * cannot mix.\n * - `axis` — which offset is tracked: `vertical` (default, `scrollTop`),\n * `horizontal` (`scrollLeft`) or `both`. An offset stored for an axis that is\n * not tracked is neither applied nor discarded.\n *\n * Both Values are re-derived when they change at runtime, so a morph that\n * rewrites the attribute in place switches namespace or axis instead of going on\n * writing under the one that held at connect. A `key` left empty resolves through\n * the element `id`, which no Value callback observes; runtime `id` changes are\n * out of scope.\n *\n * @remarks\n * Behavior only — it sets no ARIA/`data-*`/CSS and never moves focus. Restoring\n * goes through `scrollTo({ behavior: \"instant\" })` so that a consumer's\n * `scroll-behavior: smooth` cannot turn it into an animation whose intermediate\n * offsets would be recorded as the reader's own scrolling. The `scroll` listener\n * is internal and `passive` (no consumer `data-action` needed): each event\n * records the live offset synchronously, and a `requestAnimationFrame` coalesces\n * the writes to `sessionStorage`. On `disconnect()` (Turbo navigation included)\n * it flushes the **last captured** offset rather than re-reading the element — by\n * the time Turbo fires `disconnect` it has already detached the node, whose\n * `scrollTop` then reads `0`, so a fresh read would clobber the saved position\n * with `0`. Keying by `key` (falling back to the element `id`) in\n * `sessionStorage` makes it multi-instance safe and survives full reloads —\n * unlike a module-scope variable, which only survives Turbo Drive and assumes a\n * single instance. Storage access is best-effort: a browser that refuses it\n * (private mode, quota) leaves the element working, just without persistence.\n */\nexport class ScrollRestoreController extends Controller<HTMLElement> {\n static override values = {\n key: { type: String, default: \"\" },\n axis: { type: String, default: \"vertical\" },\n };\n\n declare keyValue: string;\n declare axisValue: string;\n\n /** Pending rAF id that coalesces scroll bursts into one save. */\n #rafId: number | null = null;\n /** Resolved storage key; empty disables persistence (no key and no id). */\n #storageKey = \"\";\n /** Last offset captured while the element was live; persisted as-is on teardown. */\n #lastTop = 0;\n #lastLeft = 0;\n /**\n * The offsets this controller's own restore produced, awaiting the `scroll`\n * the engine fires for them. A restore the layout cannot reach yet lands short,\n * so treating that echo as the reader's position would cut the saved offset\n * down to whatever the unfinished layout allowed.\n */\n #echoTop: number | null = null;\n #echoLeft: number | null = null;\n /** Offsets stored for an axis that is not tracked, carried through saves. */\n #carried: StoredScroll = {};\n /**\n * Whether each axis holds an offset worth saving — one this namespace restored\n * or the reader moved. A save writes only the axes that are marked, so an\n * offset taken under a different key or axis is never re-published as if the\n * reader had left it there.\n */\n #capturedTop = false;\n #capturedLeft = false;\n /** Whether the controller is connected; Value callbacks outside that window do not resync. */\n #connected = false;\n\n readonly #onScroll = (): void => {\n // Capture synchronously while the element is still connected and measurable;\n // the rAF only debounces the sessionStorage write, never the read.\n this.#capture();\n if (this.#rafId !== null) return;\n this.#rafId = requestAnimationFrame(() => {\n this.#rafId = null;\n this.#persist();\n });\n };\n\n override connect(): void {\n this.#connected = true;\n this.#storageKey = this.#resolveKey();\n this.element.addEventListener(\"scroll\", this.#onScroll, { passive: true });\n if (this.#storageKey) this.#restore();\n }\n\n override disconnect(): void {\n this.#connected = false;\n this.element.removeEventListener(\"scroll\", this.#onScroll);\n this.#cancelFrame();\n // Flush the last captured offset (a throttled burst may have left the final\n // frame unsaved). We do NOT re-read the element here: Turbo detaches the node\n // before disconnect, so its scrollTop is 0 and would overwrite the real value.\n this.#persist();\n }\n\n /** Re-derives the namespace when application code or a Turbo morph moves `key`. */\n keyValueChanged(): void {\n this.#resync();\n }\n\n /** Re-derives the tracked axes when application code or a Turbo morph moves `axis`. */\n axisValueChanged(): void {\n this.#resync();\n }\n\n /** Rebuilds the persistence state around the Values as they now read. */\n #resync(): void {\n if (!this.#connected) return;\n // The pending frame holds an offset belonging to the previous namespace and\n // axis, so it is dropped rather than written under the new ones.\n this.#cancelFrame();\n this.#storageKey = this.#resolveKey();\n this.#echoTop = null;\n this.#echoLeft = null;\n this.#carried = {};\n // An offset is only ever written for an axis this namespace seeded or the\n // reader moved. Clearing both marks drops the ones taken under the previous\n // key and axis, which the restore below re-establishes only where the new\n // key actually holds a value.\n this.#capturedTop = false;\n this.#capturedLeft = false;\n if (this.#storageKey) this.#restore();\n }\n\n /** Drops the pending coalesced save, if one is queued. */\n #cancelFrame(): void {\n if (this.#rafId !== null) {\n cancelAnimationFrame(this.#rafId);\n this.#rafId = null;\n }\n }\n\n /**\n * Records the live scroll offset for the configured axis.\n *\n * An axis holds its echo until that axis actually moves. A scroll event names\n * no axis, so consuming both on the first one to arrive would leave the other\n * unguarded: on `both`, moving one axis would take the clamped reading of the\n * untouched axis as the reader's own position and save it, cutting the stored\n * offset down to whatever the layout could reach at restore time.\n */\n #capture(): void {\n if (this.#tracksVertical) {\n const top = this.element.scrollTop;\n if (top !== this.#echoTop) {\n this.#echoTop = null;\n this.#lastTop = top;\n this.#capturedTop = true;\n }\n }\n if (this.#tracksHorizontal) {\n const left = this.element.scrollLeft;\n if (left !== this.#echoLeft) {\n this.#echoLeft = null;\n this.#lastLeft = left;\n this.#capturedLeft = true;\n }\n }\n }\n\n /**\n * Writes the marked axes, carrying through any offset held for an untracked one.\n * Without a key, or with neither axis marked, it writes nothing.\n */\n #persist(): void {\n if (!this.#storageKey || !(this.#capturedTop || this.#capturedLeft)) return;\n const data: StoredScroll = { ...this.#carried };\n if (this.#tracksVertical && this.#capturedTop) data.top = this.#lastTop;\n if (this.#tracksHorizontal && this.#capturedLeft) data.left = this.#lastLeft;\n try {\n window.sessionStorage.setItem(this.#storageKey, JSON.stringify(data));\n } catch {\n // sessionStorage can throw (private mode / quota); persistence is best-effort.\n }\n }\n\n /** Applies the persisted scroll offset, if any, without moving focus. */\n #restore(): void {\n let raw: string | null;\n try {\n raw = window.sessionStorage.getItem(this.#storageKey);\n } catch {\n return;\n }\n const data = parseStored(raw);\n if (data === null) return;\n\n // A restore writes its own echoes below; one left over from an earlier pass\n // would otherwise swallow the first matching move the reader makes.\n this.#echoTop = null;\n this.#echoLeft = null;\n\n const top = storedOffset(data.top);\n const left = storedOffset(data.left);\n const options: ScrollToOptions = { behavior: \"instant\" };\n let requested = false;\n // Seed the capture cache from the restored value so an immediate teardown\n // (before any scroll) re-persists the restored position, not 0. Guard by the\n // configured axis so a stale field from a different axis is never applied —\n // and keep it, since the next save would otherwise drop it.\n if (this.#tracksVertical) {\n if (top !== null) {\n options.top = top;\n this.#lastTop = top;\n this.#capturedTop = true;\n requested = true;\n }\n } else if (data.top !== undefined) {\n this.#carried.top = data.top;\n }\n if (this.#tracksHorizontal) {\n if (left !== null) {\n options.left = left;\n this.#lastLeft = left;\n this.#capturedLeft = true;\n requested = true;\n }\n } else if (data.left !== undefined) {\n this.#carried.left = data.left;\n }\n if (!requested) return;\n\n const beforeTop = this.element.scrollTop;\n const beforeLeft = this.element.scrollLeft;\n this.element.scrollTo(options);\n if (this.element.scrollTop !== beforeTop) this.#echoTop = this.element.scrollTop;\n if (this.element.scrollLeft !== beforeLeft) this.#echoLeft = this.element.scrollLeft;\n }\n\n /** The `sessionStorage` key: explicit `key`, else the element `id`, else none. */\n #resolveKey(): string {\n const base = this.keyValue || this.element.id;\n return base ? `stimeo--scroll-restore:${base}` : \"\";\n }\n\n get #tracksVertical(): boolean {\n return this.axisValue !== \"horizontal\";\n }\n\n get #tracksHorizontal(): boolean {\n return this.axisValue === \"horizontal\" || this.axisValue === \"both\";\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/frame_coalescer.ts","../../src/controllers/scroll_restore_controller.ts"],"names":[],"mappings":"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","file":"scroll_restore_controller.js","sourcesContent":["/**\n * Runs one piece of scroll-driven work per animation frame.\n *\n * A `scroll` listener fires many times between two paints, and each firing would\n * measure the same layout. Requesting a frame on the first firing and ignoring\n * the rest until it runs keeps the reads to one per frame; running on the frame\n * — rather than on a timer — keeps the measurement in step with what the reader\n * sees.\n *\n * **The first request of a burst wins.** Work handed in while a frame is already\n * pending is dropped, so a caller that queues a special first pass and then\n * receives scrolls gets that first pass, not the last scroll's.\n *\n * Cancelling drops the pending frame outright, and does nothing at all when none\n * is pending. A frame really is cancelled, so nothing needs a generation to tell\n * a stale callback from a fresh one, and nothing has to be refused before the\n * caller starts listening.\n *\n * Scope is the scheduling only. What to measure stays with the caller.\n *\n * @example\n * ```ts\n * readonly #frames = new FrameCoalescer();\n *\n * readonly #onScroll = (): void => this.#frames.schedule(() => this.#measure());\n *\n * disconnect(): void {\n * this.#frames.cancel();\n * }\n * ```\n */\nexport class FrameCoalescer {\n #frame: number | null = null;\n\n /**\n * Runs `run` on the next frame, unless a frame is already pending — the first\n * request of a burst wins and the rest are dropped. The pending frame is\n * released before `run`, so `run` may request the next one.\n */\n schedule(run: () => void): void {\n if (this.#frame !== null) return;\n this.#frame = requestAnimationFrame(() => {\n this.#frame = null;\n run();\n });\n }\n\n /**\n * Drops the pending frame, and reaches the platform only when there is one.\n *\n * There is no handle value that stands for \"nothing pending\":\n * `cancelAnimationFrame` takes an `unsigned long`, so a negative placeholder\n * arrives as a large positive number that the same allocator can hand out, and\n * an idle cancel would then drop a frame belonging to someone else.\n */\n cancel(): void {\n if (this.#frame === null) return;\n cancelAnimationFrame(this.#frame);\n this.#frame = null;\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { FrameCoalescer } from \"../utils/frame_coalescer\";\n\n/** Persisted scroll position; fields are present only for the saved axis. */\ninterface StoredScroll {\n top?: number;\n left?: number;\n}\n\n/**\n * Reads one persisted entry, or `null` when there is nothing usable to read.\n *\n * `JSON.parse` returns `null` for the literal `null` without throwing, so the\n * parse guard alone would hand back a value with no fields to read. Every field the\n * caller applies is validated there, which is what makes a bare literal harmless here.\n */\nfunction parseStored(raw: string | null): StoredScroll | null {\n if (raw === null) return null;\n try {\n return JSON.parse(raw) as StoredScroll | null;\n } catch {\n return null;\n }\n}\n\n/** One axis offset from a persisted entry, or `null` when it cannot be applied. */\nfunction storedOffset(value: unknown): number | null {\n return typeof value === \"number\" && Number.isFinite(value) ? value : null;\n}\n\n/**\n * Headless **Scroll Restore** behavior: persists and restores the scroll\n * position of an inner scrolling region across Turbo Drive navigations (and full\n * reloads within the same tab session). No APG widget — a pure state-preservation\n * utility, like {@link import(\"./scroll_visibility_controller\").ScrollVisibilityController}\n * and the Sticky State Observer.\n *\n * Markup contract (identifier: `stimeo--scroll-restore`):\n * <div data-controller=\"stimeo--scroll-restore\"\n * data-stimeo--scroll-restore-key-value=\"sidebar\"\n * style=\"overflow: auto\">\n * …long content…\n * </div>\n *\n * Turbo swaps the whole `<body>` on navigation, so an inner scroll container is\n * rebuilt with `scrollTop` reset to 0. Rather than have every app hand-write a\n * controller for this (which contradicts a \"ship the behavior\" library), this\n * persists the offset under a stable key in `sessionStorage` and restores it on\n * `connect()`.\n *\n * Values:\n * - `key` — the `sessionStorage` namespace. Empty falls back to the element `id`;\n * with neither, nothing is saved or restored, so positions from unrelated pages\n * cannot mix.\n * - `axis` — which offset is tracked: `vertical` (default, `scrollTop`),\n * `horizontal` (`scrollLeft`) or `both`. An offset stored for an axis that is\n * not tracked is neither applied nor discarded.\n *\n * Both Values are re-derived when they change at runtime, so a morph that\n * rewrites the attribute in place switches namespace or axis instead of going on\n * writing under the one that held at connect. A `key` left empty resolves through\n * the element `id`, which no Value callback observes; runtime `id` changes are\n * out of scope.\n *\n * @remarks\n * Behavior only — it sets no ARIA/`data-*`/CSS and never moves focus. Restoring\n * goes through `scrollTo({ behavior: \"instant\" })` so that a consumer's\n * `scroll-behavior: smooth` cannot turn it into an animation whose intermediate\n * offsets would be recorded as the reader's own scrolling. The `scroll` listener\n * is internal and `passive` (no consumer `data-action` needed): each event\n * records the live offset synchronously, and a `requestAnimationFrame` coalesces\n * the writes to `sessionStorage`. On `disconnect()` (Turbo navigation included)\n * it flushes the **last captured** offset rather than re-reading the element — by\n * the time Turbo fires `disconnect` it has already detached the node, whose\n * `scrollTop` then reads `0`, so a fresh read would clobber the saved position\n * with `0`. Keying by `key` (falling back to the element `id`) in\n * `sessionStorage` makes it multi-instance safe and survives full reloads —\n * unlike a module-scope variable, which only survives Turbo Drive and assumes a\n * single instance. Storage access is best-effort: a browser that refuses it\n * (private mode, quota) leaves the element working, just without persistence.\n */\nexport class ScrollRestoreController extends Controller<HTMLElement> {\n static override values = {\n key: { type: String, default: \"\" },\n axis: { type: String, default: \"vertical\" },\n };\n\n declare keyValue: string;\n declare axisValue: string;\n\n /** Coalesces scroll bursts into one save per frame. */\n readonly #frames = new FrameCoalescer();\n /** Resolved storage key; empty disables persistence (no key and no id). */\n #storageKey = \"\";\n /** Last offset captured while the element was live; persisted as-is on teardown. */\n #lastTop = 0;\n #lastLeft = 0;\n /**\n * The offsets this controller's own restore produced, awaiting the `scroll`\n * the engine fires for them. A restore the layout cannot reach yet lands short,\n * so treating that echo as the reader's position would cut the saved offset\n * down to whatever the unfinished layout allowed.\n */\n #echoTop: number | null = null;\n #echoLeft: number | null = null;\n /** Offsets stored for an axis that is not tracked, carried through saves. */\n #carried: StoredScroll = {};\n /**\n * Whether each axis holds an offset worth saving — one this namespace restored\n * or the reader moved. A save writes only the axes that are marked, so an\n * offset taken under a different key or axis is never re-published as if the\n * reader had left it there.\n */\n #capturedTop = false;\n #capturedLeft = false;\n /** Whether the controller is connected; Value callbacks outside that window do not resync. */\n #connected = false;\n\n readonly #onScroll = (): void => {\n // Capture synchronously while the element is still connected and measurable;\n // the rAF only debounces the sessionStorage write, never the read.\n this.#capture();\n this.#frames.schedule(() => this.#persist());\n };\n\n override connect(): void {\n this.#connected = true;\n this.#storageKey = this.#resolveKey();\n this.element.addEventListener(\"scroll\", this.#onScroll, { passive: true });\n if (this.#storageKey) this.#restore();\n }\n\n override disconnect(): void {\n this.#connected = false;\n this.element.removeEventListener(\"scroll\", this.#onScroll);\n this.#frames.cancel();\n // Flush the last captured offset (a throttled burst may have left the final\n // frame unsaved). We do NOT re-read the element here: Turbo detaches the node\n // before disconnect, so its scrollTop is 0 and would overwrite the real value.\n this.#persist();\n }\n\n /** Re-derives the namespace when application code or a Turbo morph moves `key`. */\n keyValueChanged(): void {\n this.#resync();\n }\n\n /** Re-derives the tracked axes when application code or a Turbo morph moves `axis`. */\n axisValueChanged(): void {\n this.#resync();\n }\n\n /** Rebuilds the persistence state around the Values as they now read. */\n #resync(): void {\n if (!this.#connected) return;\n // The pending frame holds an offset belonging to the previous namespace and\n // axis, so it is dropped rather than written under the new ones.\n this.#frames.cancel();\n this.#storageKey = this.#resolveKey();\n this.#echoTop = null;\n this.#echoLeft = null;\n this.#carried = {};\n // An offset is only ever written for an axis this namespace seeded or the\n // reader moved. Clearing both marks drops the ones taken under the previous\n // key and axis, which the restore below re-establishes only where the new\n // key actually holds a value.\n this.#capturedTop = false;\n this.#capturedLeft = false;\n if (this.#storageKey) this.#restore();\n }\n\n /**\n * Records the live scroll offset for the configured axis.\n *\n * An axis holds its echo until that axis actually moves. A scroll event names\n * no axis, so consuming both on the first one to arrive would leave the other\n * unguarded: on `both`, moving one axis would take the clamped reading of the\n * untouched axis as the reader's own position and save it, cutting the stored\n * offset down to whatever the layout could reach at restore time.\n */\n #capture(): void {\n if (this.#tracksVertical) {\n const top = this.element.scrollTop;\n if (top !== this.#echoTop) {\n this.#echoTop = null;\n this.#lastTop = top;\n this.#capturedTop = true;\n }\n }\n if (this.#tracksHorizontal) {\n const left = this.element.scrollLeft;\n if (left !== this.#echoLeft) {\n this.#echoLeft = null;\n this.#lastLeft = left;\n this.#capturedLeft = true;\n }\n }\n }\n\n /**\n * Writes the marked axes, carrying through any offset held for an untracked one.\n * Without a key, or with neither axis marked, it writes nothing.\n */\n #persist(): void {\n if (!this.#storageKey || !(this.#capturedTop || this.#capturedLeft)) return;\n const data: StoredScroll = { ...this.#carried };\n if (this.#tracksVertical && this.#capturedTop) data.top = this.#lastTop;\n if (this.#tracksHorizontal && this.#capturedLeft) data.left = this.#lastLeft;\n try {\n window.sessionStorage.setItem(this.#storageKey, JSON.stringify(data));\n } catch {\n // sessionStorage can throw (private mode / quota); persistence is best-effort.\n }\n }\n\n /** Applies the persisted scroll offset, if any, without moving focus. */\n #restore(): void {\n let raw: string | null;\n try {\n raw = window.sessionStorage.getItem(this.#storageKey);\n } catch {\n return;\n }\n const data = parseStored(raw);\n if (data === null) return;\n\n // A restore writes its own echoes below; one left over from an earlier pass\n // would otherwise swallow the first matching move the reader makes.\n this.#echoTop = null;\n this.#echoLeft = null;\n\n const top = storedOffset(data.top);\n const left = storedOffset(data.left);\n const options: ScrollToOptions = { behavior: \"instant\" };\n let requested = false;\n // Seed the capture cache from the restored value so an immediate teardown\n // (before any scroll) re-persists the restored position, not 0. Guard by the\n // configured axis so a stale field from a different axis is never applied —\n // and keep it, since the next save would otherwise drop it.\n if (this.#tracksVertical) {\n if (top !== null) {\n options.top = top;\n this.#lastTop = top;\n this.#capturedTop = true;\n requested = true;\n }\n } else if (data.top !== undefined) {\n this.#carried.top = data.top;\n }\n if (this.#tracksHorizontal) {\n if (left !== null) {\n options.left = left;\n this.#lastLeft = left;\n this.#capturedLeft = true;\n requested = true;\n }\n } else if (data.left !== undefined) {\n this.#carried.left = data.left;\n }\n if (!requested) return;\n\n const beforeTop = this.element.scrollTop;\n const beforeLeft = this.element.scrollLeft;\n this.element.scrollTo(options);\n if (this.element.scrollTop !== beforeTop) this.#echoTop = this.element.scrollTop;\n if (this.element.scrollLeft !== beforeLeft) this.#echoLeft = this.element.scrollLeft;\n }\n\n /** The `sessionStorage` key: explicit `key`, else the element `id`, else none. */\n #resolveKey(): string {\n const base = this.keyValue || this.element.id;\n return base ? `stimeo--scroll-restore:${base}` : \"\";\n }\n\n get #tracksVertical(): boolean {\n return this.axisValue !== \"horizontal\";\n }\n\n get #tracksHorizontal(): boolean {\n return this.axisValue === \"horizontal\" || this.axisValue === \"both\";\n }\n}\n"]}
|
|
@@ -12,7 +12,7 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
12
12
|
* data-stimeo--scroll-visibility-mode-value="offset">
|
|
13
13
|
* <button type="button" hidden
|
|
14
14
|
* data-stimeo--scroll-visibility-target="element"
|
|
15
|
-
* data-action="stimeo--scroll-visibility#toTop">Back to top</button>
|
|
15
|
+
* data-action="click->stimeo--scroll-visibility#toTop">Back to top</button>
|
|
16
16
|
* </div>
|
|
17
17
|
*
|
|
18
18
|
* In `offset` mode the element is shown once the scroll source is scrolled past
|
|
@@ -57,11 +57,73 @@ var BlurDeferral = class {
|
|
|
57
57
|
}
|
|
58
58
|
};
|
|
59
59
|
|
|
60
|
+
// src/utils/declared_value.ts
|
|
61
|
+
function parseDeclared(raw, parse, fallback) {
|
|
62
|
+
try {
|
|
63
|
+
return parse(raw);
|
|
64
|
+
} catch {
|
|
65
|
+
return fallback;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function validSelector(element, raw, fallback) {
|
|
69
|
+
if (raw.length === 0) return fallback;
|
|
70
|
+
return parseDeclared(
|
|
71
|
+
raw,
|
|
72
|
+
(selector) => {
|
|
73
|
+
element.matches(selector);
|
|
74
|
+
return selector;
|
|
75
|
+
},
|
|
76
|
+
fallback
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// src/utils/frame_coalescer.ts
|
|
81
|
+
var FrameCoalescer = class {
|
|
82
|
+
#frame = null;
|
|
83
|
+
/**
|
|
84
|
+
* Runs `run` on the next frame, unless a frame is already pending — the first
|
|
85
|
+
* request of a burst wins and the rest are dropped. The pending frame is
|
|
86
|
+
* released before `run`, so `run` may request the next one.
|
|
87
|
+
*/
|
|
88
|
+
schedule(run) {
|
|
89
|
+
if (this.#frame !== null) return;
|
|
90
|
+
this.#frame = requestAnimationFrame(() => {
|
|
91
|
+
this.#frame = null;
|
|
92
|
+
run();
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Drops the pending frame, and reaches the platform only when there is one.
|
|
97
|
+
*
|
|
98
|
+
* There is no handle value that stands for "nothing pending":
|
|
99
|
+
* `cancelAnimationFrame` takes an `unsigned long`, so a negative placeholder
|
|
100
|
+
* arrives as a large positive number that the same allocator can hand out, and
|
|
101
|
+
* an idle cancel would then drop a frame belonging to someone else.
|
|
102
|
+
*/
|
|
103
|
+
cancel() {
|
|
104
|
+
if (this.#frame === null) return;
|
|
105
|
+
cancelAnimationFrame(this.#frame);
|
|
106
|
+
this.#frame = null;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
60
110
|
// src/utils/reduced_motion.ts
|
|
61
111
|
function prefersReducedMotion() {
|
|
62
112
|
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
63
113
|
}
|
|
64
114
|
|
|
115
|
+
// src/utils/scroll_source.ts
|
|
116
|
+
function resolveScrollContainer(selector) {
|
|
117
|
+
const match = selector ? document.querySelector(selector) : null;
|
|
118
|
+
return match instanceof HTMLElement ? match : null;
|
|
119
|
+
}
|
|
120
|
+
function resolveScrollSource(selector) {
|
|
121
|
+
return resolveScrollContainer(selector) ?? window;
|
|
122
|
+
}
|
|
123
|
+
function scrollOffset(source) {
|
|
124
|
+
return source === window ? window.scrollY ?? window.pageYOffset ?? 0 : source.scrollTop;
|
|
125
|
+
}
|
|
126
|
+
|
|
65
127
|
// src/utils/before_cache_reset.ts
|
|
66
128
|
var BeforeCacheReset = class _BeforeCacheReset {
|
|
67
129
|
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
@@ -134,8 +196,8 @@ var ScrollVisibilityController = class extends Controller {
|
|
|
134
196
|
};
|
|
135
197
|
static actions = ["toTop"];
|
|
136
198
|
static events = ["change"];
|
|
137
|
-
/**
|
|
138
|
-
#
|
|
199
|
+
/** Coalesces scroll bursts into one measurement per frame. */
|
|
200
|
+
#frames = new FrameCoalescer();
|
|
139
201
|
/** Previous scroll position, for `direction` mode delta detection. */
|
|
140
202
|
#lastScrollY = 0;
|
|
141
203
|
/** Current visibility, tracked to dispatch `change` only on real transitions. */
|
|
@@ -171,16 +233,10 @@ var ScrollVisibilityController = class extends Controller {
|
|
|
171
233
|
#pendingHide = new BlurDeferral(() => {
|
|
172
234
|
if (this.#connected) this.#evaluate();
|
|
173
235
|
});
|
|
174
|
-
#onScroll = () =>
|
|
175
|
-
if (this.#rafId !== null) return;
|
|
176
|
-
this.#rafId = requestAnimationFrame(() => {
|
|
177
|
-
this.#rafId = null;
|
|
178
|
-
this.#evaluate();
|
|
179
|
-
});
|
|
180
|
-
};
|
|
236
|
+
#onScroll = () => this.#frames.schedule(() => this.#evaluate());
|
|
181
237
|
connect() {
|
|
182
|
-
this.#scrollSource = this.#
|
|
183
|
-
this.#lastScrollY = this.#
|
|
238
|
+
this.#scrollSource = resolveScrollSource(this.#rootSelector);
|
|
239
|
+
this.#lastScrollY = scrollOffset(this.#scrollSource);
|
|
184
240
|
this.#scrollSource.addEventListener("scroll", this.#onScroll, { passive: true });
|
|
185
241
|
this.#evaluate(false);
|
|
186
242
|
this.#connected = true;
|
|
@@ -188,10 +244,7 @@ var ScrollVisibilityController = class extends Controller {
|
|
|
188
244
|
disconnect() {
|
|
189
245
|
this.#connected = false;
|
|
190
246
|
this.#scrollSource.removeEventListener("scroll", this.#onScroll);
|
|
191
|
-
|
|
192
|
-
cancelAnimationFrame(this.#rafId);
|
|
193
|
-
this.#rafId = null;
|
|
194
|
-
}
|
|
247
|
+
this.#frames.cancel();
|
|
195
248
|
this.#pendingHide.releaseAll();
|
|
196
249
|
this.#tabindex.returnAll();
|
|
197
250
|
this.#visible = null;
|
|
@@ -223,11 +276,11 @@ var ScrollVisibilityController = class extends Controller {
|
|
|
223
276
|
}
|
|
224
277
|
/** Validates `root` once so connect never parses a selector that throws. */
|
|
225
278
|
rootValueChanged() {
|
|
226
|
-
this.#rootSelector =
|
|
279
|
+
this.#rootSelector = validSelector(this.element, this.rootValue, "");
|
|
227
280
|
}
|
|
228
281
|
/** Validates `focusSelector` once so `toTop` never parses a selector that throws. */
|
|
229
282
|
focusSelectorValueChanged() {
|
|
230
|
-
this.#focusSelector =
|
|
283
|
+
this.#focusSelector = validSelector(this.element, this.focusSelectorValue, "");
|
|
231
284
|
}
|
|
232
285
|
/** Scrolls the source to the top and, optionally, moves focus to a safe target. */
|
|
233
286
|
toTop() {
|
|
@@ -250,7 +303,7 @@ var ScrollVisibilityController = class extends Controller {
|
|
|
250
303
|
* @stimeoRenderRoot
|
|
251
304
|
*/
|
|
252
305
|
#evaluate(notify = true) {
|
|
253
|
-
const y = this.#
|
|
306
|
+
const y = scrollOffset(this.#scrollSource);
|
|
254
307
|
let nextVisible;
|
|
255
308
|
if (this.modeValue === "direction") {
|
|
256
309
|
if (y <= this.#offset) {
|
|
@@ -280,13 +333,6 @@ var ScrollVisibilityController = class extends Controller {
|
|
|
280
333
|
if (notify) this.dispatch("change", { detail: { visible: next } });
|
|
281
334
|
}
|
|
282
335
|
/** Resolves the scroll source from `root` (falling back to the window). */
|
|
283
|
-
#resolveScrollSource() {
|
|
284
|
-
if (this.#rootSelector) {
|
|
285
|
-
const root = document.querySelector(this.#rootSelector);
|
|
286
|
-
if (root) return root;
|
|
287
|
-
}
|
|
288
|
-
return window;
|
|
289
|
-
}
|
|
290
336
|
/**
|
|
291
337
|
* The focus owner inside the target, or `null` when focus is elsewhere.
|
|
292
338
|
*
|
|
@@ -299,23 +345,6 @@ var ScrollVisibilityController = class extends Controller {
|
|
|
299
345
|
if (focused instanceof HTMLElement && this.elementTarget.contains(focused)) return focused;
|
|
300
346
|
return null;
|
|
301
347
|
}
|
|
302
|
-
/** Returns `declared` when it parses as a selector, and `""` when it does not. */
|
|
303
|
-
#validSelector(declared) {
|
|
304
|
-
if (declared.length > 0) {
|
|
305
|
-
try {
|
|
306
|
-
this.element.matches(declared);
|
|
307
|
-
return declared;
|
|
308
|
-
} catch {
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
return "";
|
|
312
|
-
}
|
|
313
|
-
#scrollY() {
|
|
314
|
-
if (this.#scrollSource === window) {
|
|
315
|
-
return window.scrollY ?? window.pageYOffset ?? 0;
|
|
316
|
-
}
|
|
317
|
-
return this.#scrollSource.scrollTop;
|
|
318
|
-
}
|
|
319
348
|
};
|
|
320
349
|
|
|
321
350
|
export { ScrollVisibilityController };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/utils/blur_deferral.ts","../../src/utils/reduced_motion.ts","../../src/utils/before_cache_reset.ts","../../src/utils/tabindex_loan.ts","../../src/controllers/scroll_visibility_controller.ts"],"names":[],"mappings":";;;;;AAoDO,IAAM,eAAN,MAAwD;AAAA;AAAA,EAEpD,QAAA,uBAAe,GAAA,EAAmB;AAAA;AAAA,EAElC,UAAA;AAAA;AAAA,EAGT,YAAY,SAAA,EAAiC;AAC3C,IAAA,IAAA,CAAK,UAAA,GAAa,SAAA;AAAA,EACpB;AAAA;AAAA,EAGA,IAAI,IAAA,GAAe;AACjB,IAAA,OAAO,KAAK,QAAA,CAAS,IAAA;AAAA,EACvB;AAAA;AAAA,EAGA,IAAI,QAAA,GAAgB;AAClB,IAAA,OAAO,CAAC,GAAG,IAAA,CAAK,QAAA,CAAS,MAAM,CAAA;AAAA,EACjC;AAAA;AAAA,EAGA,IAAI,OAAA,EAAqB;AACvB,IAAA,OAAO,IAAA,CAAK,QAAA,CAAS,GAAA,CAAI,OAAO,CAAA;AAAA,EAClC;AAAA;AAAA,EAGA,MAAM,OAAA,EAAkB;AACtB,IAAA,IAAI,IAAA,CAAK,QAAA,CAAS,GAAA,CAAI,OAAO,CAAA,EAAG;AAChC,IAAA,MAAM,SAAS,MAAY;AACzB,MAAA,IAAA,CAAK,QAAQ,OAAO,CAAA;AACpB,MAAA,IAAA,CAAK,WAAW,OAAO,CAAA;AAAA,IACzB,CAAA;AACA,IAAA,IAAA,CAAK,QAAA,CAAS,GAAA,CAAI,OAAA,EAAS,MAAM,CAAA;AACjC,IAAA,OAAA,CAAQ,gBAAA,CAAiB,QAAQ,MAAM,CAAA;AAAA,EACzC;AAAA;AAAA,EAGA,UAAU,OAAA,EAAkB;AAC1B,IAAA,KAAA,MAAW,OAAA,IAAW,KAAK,QAAA,EAAU;AACnC,MAAA,IAAI,OAAA,KAAY,OAAA,EAAS,IAAA,CAAK,OAAA,CAAQ,OAAO,CAAA;AAAA,IAC/C;AACA,IAAA,IAAA,CAAK,MAAM,OAAO,CAAA;AAAA,EACpB;AAAA;AAAA,EAGA,QAAQ,OAAA,EAAkB;AACxB,IAAA,IAAA,CAAK,QAAQ,OAAO,CAAA;AAAA,EACtB;AAAA;AAAA,EAGA,UAAA,GAAmB;AACjB,IAAA,KAAA,MAAW,OAAA,IAAW,IAAA,CAAK,QAAA,EAAU,IAAA,CAAK,QAAQ,OAAO,CAAA;AAAA,EAC3D;AAAA;AAAA,EAGA,QAAQ,OAAA,EAAkB;AACxB,IAAA,MAAM,MAAA,GAAS,IAAA,CAAK,QAAA,CAAS,GAAA,CAAI,OAAO,CAAA;AACxC,IAAA,IAAI,MAAA,EAAQ,OAAA,CAAQ,mBAAA,CAAoB,MAAA,EAAQ,MAAM,CAAA;AACtD,IAAA,IAAA,CAAK,QAAA,CAAS,OAAO,OAAO,CAAA;AAAA,EAC9B;AACF,CAAA;;;AC/FO,SAAS,oBAAA,GAAgC;AAC9C,EAAA,OACE,OAAO,MAAA,CAAO,UAAA,KAAe,cAC7B,MAAA,CAAO,UAAA,CAAW,kCAAkC,CAAA,CAAE,OAAA;AAE1D;;;ACoBO,IAAM,gBAAA,GAAN,MAAM,iBAAA,CAAiB;AAAA;AAAA,EAE5B,OAAgB,YAAA,mBAAe,IAAI,GAAA,EAAsB;AAAA;AAAA,EAGzD,OAAgB,iBAAiB,MAAY;AAC3C,IAAA,KAAA,MAAW,UAAA,IAAc,iBAAA,CAAiB,YAAA,EAAc,UAAA,CAAW,OAAA,EAAQ;AAAA,EAC7E,CAAA;AAAA,EAES,OAAA;AAAA;AAAA,EAGT,YAAY,MAAA,EAAoB;AAC9B,IAAA,IAAA,CAAK,OAAA,GAAU,MAAA;AAAA,EACjB;AAAA;AAAA,EAGA,QAAA,GAAiB;AACf,IAAA,MAAM,KAAA,GAAQ,iBAAA,CAAiB,YAAA,CAAa,IAAA,KAAS,CAAA;AACrD,IAAA,iBAAA,CAAiB,YAAA,CAAa,IAAI,IAAI,CAAA;AACtC,IAAA,IAAI,KAAA,EAAO;AACT,MAAA,QAAA,CAAS,gBAAA,CAAiB,oBAAA,EAAsB,iBAAA,CAAiB,cAAc,CAAA;AAAA,IACjF;AAAA,EACF;AAAA;AAAA,EAGA,UAAA,GAAmB;AACjB,IAAA,iBAAA,CAAiB,YAAA,CAAa,OAAO,IAAI,CAAA;AACzC,IAAA,IAAI,iBAAA,CAAiB,YAAA,CAAa,IAAA,GAAO,CAAA,EAAG;AAC5C,IAAA,QAAA,CAAS,mBAAA,CAAoB,oBAAA,EAAsB,iBAAA,CAAiB,cAAc,CAAA;AAAA,EACpF;AACF,CAAA;;;ACtBO,IAAM,eAAN,MAAwD;AAAA,EACpD,MAAA;AAAA,EACA,KAAA,uBAAY,GAAA,EAAO;AAAA;AAAA,EAEnB,eAAe,IAAI,gBAAA,CAAiB,MAAM,IAAA,CAAK,WAAW,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOnE,WAAA,CAAY,QAAgB,IAAA,EAAM;AAChC,IAAA,IAAA,CAAK,MAAA,GAAS,KAAA;AAAA,EAChB;AAAA;AAAA,EAGA,KAAK,OAAA,EAAkB;AACrB,IAAA,IAAI,OAAA,CAAQ,YAAA,CAAa,UAAU,CAAA,EAAG;AACtC,IAAA,OAAA,CAAQ,YAAA,CAAa,UAAA,EAAY,IAAA,CAAK,MAAM,CAAA;AAC5C,IAAA,IAAA,CAAK,KAAA,CAAM,IAAI,OAAO,CAAA;AAGtB,IAAA,IAAA,CAAK,aAAa,QAAA,EAAS;AAAA,EAC7B;AAAA;AAAA,EAGA,SAAA,GAAkB;AAChB,IAAA,KAAA,MAAW,OAAA,IAAW,KAAK,KAAA,EAAO;AAChC,MAAA,IAAI,OAAA,CAAQ,aAAa,UAAU,CAAA,KAAM,KAAK,MAAA,EAAQ,OAAA,CAAQ,gBAAgB,UAAU,CAAA;AAAA,IAC1F;AACA,IAAA,IAAA,CAAK,MAAM,KAAA,EAAM;AACjB,IAAA,IAAA,CAAK,aAAa,UAAA,EAAW;AAAA,EAC/B;AACF,CAAA;;;AC/EA,IAAM,cAAA,GAAiB,GAAA;AAqDhB,IAAM,0BAAA,GAAN,cAAyC,UAAA,CAAwB;AAAA,EACtE,OAAgB,OAAA,GAAU,CAAC,SAAS,CAAA;AAAA,EACpC,OAAgB,MAAA,GAAS;AAAA,IACvB,MAAA,EAAQ,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,cAAA,EAAe;AAAA,IAChD,IAAA,EAAM,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,QAAA,EAAS;AAAA,IACxC,aAAA,EAAe,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,EAAA,EAAG;AAAA,IAC3C,IAAA,EAAM,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,EAAA;AAAG,GACpC;AAAA,EACA,OAAO,OAAA,GAAU,CAAC,OAAO,CAAA;AAAA,EACzB,OAAO,MAAA,GAAS,CAAC,QAAQ,CAAA;AAAA;AAAA,EAWzB,MAAA,GAAwB,IAAA;AAAA;AAAA,EAExB,YAAA,GAAe,CAAA;AAAA;AAAA,EAEf,QAAA,GAA2B,IAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAK3B,aAAA,GAAsC,MAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQtC,UAAA,GAAa,KAAA;AAAA;AAAA,EAEb,OAAA,GAAU,cAAA;AAAA;AAAA,EAEV,aAAA,GAAgB,EAAA;AAAA;AAAA,EAEhB,cAAA,GAAiB,EAAA;AAAA;AAAA,EAER,SAAA,GAAY,IAAI,YAAA,EAAa;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQ7B,YAAA,GAAe,IAAI,YAAA,CAAa,MAAY;AACnD,IAAA,IAAI,IAAA,CAAK,UAAA,EAAY,IAAA,CAAK,SAAA,EAAU;AAAA,EACtC,CAAC,CAAA;AAAA,EAEQ,YAAY,MAAY;AAC/B,IAAA,IAAI,IAAA,CAAK,WAAW,IAAA,EAAM;AAC1B,IAAA,IAAA,CAAK,MAAA,GAAS,sBAAsB,MAAM;AACxC,MAAA,IAAA,CAAK,MAAA,GAAS,IAAA;AACd,MAAA,IAAA,CAAK,SAAA,EAAU;AAAA,IACjB,CAAC,CAAA;AAAA,EACH,CAAA;AAAA,EAES,OAAA,GAAgB;AACvB,IAAA,IAAA,CAAK,aAAA,GAAgB,KAAK,oBAAA,EAAqB;AAC/C,IAAA,IAAA,CAAK,YAAA,GAAe,KAAK,QAAA,EAAS;AAClC,IAAA,IAAA,CAAK,aAAA,CAAc,iBAAiB,QAAA,EAAU,IAAA,CAAK,WAAW,EAAE,OAAA,EAAS,MAAM,CAAA;AAC/E,IAAA,IAAA,CAAK,UAAU,KAAK,CAAA;AACpB,IAAA,IAAA,CAAK,UAAA,GAAa,IAAA;AAAA,EACpB;AAAA,EAES,UAAA,GAAmB;AAC1B,IAAA,IAAA,CAAK,UAAA,GAAa,KAAA;AAClB,IAAA,IAAA,CAAK,aAAA,CAAc,mBAAA,CAAoB,QAAA,EAAU,IAAA,CAAK,SAAS,CAAA;AAC/D,IAAA,IAAI,IAAA,CAAK,WAAW,IAAA,EAAM;AACxB,MAAA,oBAAA,CAAqB,KAAK,MAAM,CAAA;AAChC,MAAA,IAAA,CAAK,MAAA,GAAS,IAAA;AAAA,IAChB;AACA,IAAA,IAAA,CAAK,aAAa,UAAA,EAAW;AAC7B,IAAA,IAAA,CAAK,UAAU,SAAA,EAAU;AACzB,IAAA,IAAA,CAAK,QAAA,GAAW,IAAA;AAAA,EAClB;AAAA;AAAA,EAGA,uBAAuB,OAAA,EAA4B;AACjD,IAAA,IAAI,KAAK,QAAA,KAAa,IAAA,EAAM,OAAA,CAAQ,MAAA,GAAS,CAAC,IAAA,CAAK,QAAA;AAAA,EACrD;AAAA;AAAA,EAGA,yBAAA,GAAkC;AAIhC,IAAA,IAAA,CAAK,aAAa,UAAA,EAAW;AAAA,EAC/B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAWA,kBAAA,GAA2B;AACzB,IAAA,IAAA,CAAK,UAAU,MAAA,CAAO,QAAA,CAAS,KAAK,WAAW,CAAA,GAAI,KAAK,WAAA,GAAc,cAAA;AACtE,IAAA,IAAI,IAAA,CAAK,UAAA,EAAY,IAAA,CAAK,SAAA,EAAU;AAAA,EACtC;AAAA;AAAA,EAGA,gBAAA,GAAyB;AACvB,IAAA,IAAI,IAAA,CAAK,UAAA,EAAY,IAAA,CAAK,SAAA,EAAU;AAAA,EACtC;AAAA;AAAA,EAGA,gBAAA,GAAyB;AACvB,IAAA,IAAA,CAAK,aAAA,GAAgB,IAAA,CAAK,cAAA,CAAe,IAAA,CAAK,SAAS,CAAA;AAAA,EACzD;AAAA;AAAA,EAGA,yBAAA,GAAkC;AAChC,IAAA,IAAA,CAAK,cAAA,GAAiB,IAAA,CAAK,cAAA,CAAe,IAAA,CAAK,kBAAkB,CAAA;AAAA,EACnE;AAAA;AAAA,EAGA,KAAA,GAAc;AACZ,IAAA,MAAM,QAAA,GAA2B,oBAAA,EAAqB,GAAI,SAAA,GAAY,QAAA;AACtE,IAAA,IAAA,CAAK,cAAc,QAAA,CAAS,EAAE,GAAA,EAAK,CAAA,EAAG,UAAU,CAAA;AAChD,IAAA,IAAI,KAAK,cAAA,EAAgB;AACvB,MAAA,MAAM,MAAA,GAAS,QAAA,CAAS,aAAA,CAA2B,IAAA,CAAK,cAAc,CAAA;AACtE,MAAA,IAAI,MAAA,EAAQ;AACV,QAAA,IAAA,CAAK,SAAA,CAAU,KAAK,MAAM,CAAA;AAG1B,QAAA,MAAA,CAAO,KAAA,CAAM,EAAE,aAAA,EAAe,IAAA,EAAM,CAAA;AAAA,MACtC;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUA,SAAA,CAAU,SAAS,IAAA,EAAY;AAC7B,IAAA,MAAM,CAAA,GAAI,KAAK,QAAA,EAAS;AACxB,IAAA,IAAI,WAAA;AACJ,IAAA,IAAI,IAAA,CAAK,cAAc,WAAA,EAAa;AAGlC,MAAA,IAAI,CAAA,IAAK,KAAK,OAAA,EAAS;AACrB,QAAA,WAAA,GAAc,IAAA;AAAA,MAChB,CAAA,MAAA,IAAW,CAAA,KAAM,IAAA,CAAK,YAAA,EAAc;AAElC,QAAA;AAAA,MACF,CAAA,MAAO;AACL,QAAA,WAAA,GAAc,IAAI,IAAA,CAAK,YAAA;AAAA,MACzB;AAAA,IACF,CAAA,MAAO;AACL,MAAA,WAAA,GAAc,IAAI,IAAA,CAAK,OAAA;AAAA,IACzB;AACA,IAAA,IAAA,CAAK,YAAA,GAAe,CAAA;AACpB,IAAA,IAAA,CAAK,WAAA,CAAY,aAAa,MAAM,CAAA;AAAA,EACtC;AAAA;AAAA,EAGA,WAAA,CAAY,MAAe,MAAA,EAAuB;AAChD,IAAA,IAAI,IAAA,KAAS,KAAK,QAAA,EAAU;AAC5B,IAAA,MAAM,UAAU,CAAC,IAAA,IAAQ,KAAK,gBAAA,GAAmB,IAAA,CAAK,gBAAe,GAAI,IAAA;AACzE,IAAA,IAAI,OAAA,EAAS;AAIX,MAAA,IAAA,CAAK,YAAA,CAAa,UAAU,OAAO,CAAA;AACnC,MAAA;AAAA,IACF;AACA,IAAA,IAAA,CAAK,QAAA,GAAW,IAAA;AAChB,IAAA,IAAI,IAAA,CAAK,gBAAA,EAAkB,IAAA,CAAK,aAAA,CAAc,SAAS,CAAC,IAAA;AACxD,IAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,YAAA,EAAc,IAAA,GAAO,YAAY,QAAQ,CAAA;AACnE,IAAA,IAAI,MAAA,EAAQ,IAAA,CAAK,QAAA,CAAS,QAAA,EAAU,EAAE,QAAQ,EAAE,OAAA,EAAS,IAAA,EAAK,EAAG,CAAA;AAAA,EACnE;AAAA;AAAA,EAGA,oBAAA,GAA6C;AAC3C,IAAA,IAAI,KAAK,aAAA,EAAe;AACtB,MAAA,MAAM,IAAA,GAAO,QAAA,CAAS,aAAA,CAA2B,IAAA,CAAK,aAAa,CAAA;AACnE,MAAA,IAAI,MAAM,OAAO,IAAA;AAAA,IACnB;AACA,IAAA,OAAO,MAAA;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,cAAA,GAAqC;AACnC,IAAA,MAAM,UAAU,QAAA,CAAS,aAAA;AACzB,IAAA,IAAI,mBAAmB,WAAA,IAAe,IAAA,CAAK,cAAc,QAAA,CAAS,OAAO,GAAG,OAAO,OAAA;AACnF,IAAA,OAAO,IAAA;AAAA,EACT;AAAA;AAAA,EAGA,eAAe,QAAA,EAA0B;AACvC,IAAA,IAAI,QAAA,CAAS,SAAS,CAAA,EAAG;AACvB,MAAA,IAAI;AACF,QAAA,IAAA,CAAK,OAAA,CAAQ,QAAQ,QAAQ,CAAA;AAC7B,QAAA,OAAO,QAAA;AAAA,MACT,CAAA,CAAA,MAAQ;AAAA,MAER;AAAA,IACF;AACA,IAAA,OAAO,EAAA;AAAA,EACT;AAAA,EAEA,QAAA,GAAmB;AACjB,IAAA,IAAI,IAAA,CAAK,kBAAkB,MAAA,EAAQ;AACjC,MAAA,OAAO,MAAA,CAAO,OAAA,IAAW,MAAA,CAAO,WAAA,IAAe,CAAA;AAAA,IACjD;AACA,IAAA,OAAQ,KAAK,aAAA,CAA8B,SAAA;AAAA,EAC7C;AACF","file":"scroll_visibility_controller.js","sourcesContent":["/**\n * Shared bookkeeping for updates that must wait until an element loses focus.\n *\n * A control that becomes redundant while the user is standing on it cannot be\n * removed from the page immediately: hiding a focused element (or turning it\n * into a native `disabled` one) drops focus to `<body>`, stranding a keyboard\n * user mid-widget. The fix every affected controller reached for is the same —\n * keep the element reachable, hold the destructive update, listen for `blur`,\n * and apply it then.\n *\n * {@link BlurDeferral} owns *only* the registry part of that: which elements are\n * currently holding an update back, attaching and detaching the `blur` listener,\n * and guaranteeing teardown. It is deliberately **policy-free** — what the\n * interim state looks like (`aria-disabled`, an ownership marker, nothing at\n * all), and what the deferred update actually is, stay in the controller, which\n * receives the element back through its release callback.\n *\n * Three contracts are worth stating up front, because each one is a hazard the\n * consumers would otherwise hit:\n *\n * - **The release callback fires only on a real `blur`.** `release()` /\n * `releaseAll()` *cancel* a deferral; they do not complete it. Conflating the\n * two makes `overflow-indicator` disable a button that just left the boundary.\n * - **`elements` is a snapshot.** Iterating the live key set while releasing\n * inside the loop is the failure this registry exists to prevent.\n * - **The entry is detached before the callback runs**, so a controller that\n * decides the update is still unsafe can simply defer again.\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 contracts belong on the members.\n *\n * @example\n * ```ts\n * readonly #deferred = new BlurDeferral<HTMLElement>((trigger) => {\n * if (this.#connected) this.#evaluate();\n * });\n *\n * #hide(trigger: HTMLElement) {\n * if (document.activeElement === trigger) {\n * this.#deferred.deferOnly(trigger); // stays visible until blur\n * return;\n * }\n * this.#deferred.releaseAll();\n * trigger.hidden = true;\n * }\n *\n * disconnect() {\n * this.#deferred.releaseAll();\n * }\n * ```\n */\nexport class BlurDeferral<T extends HTMLElement = HTMLElement> {\n /** Elements currently holding an update back, mapped to their `blur` listener. */\n readonly #pending = new Map<T, () => void>();\n /** Called after a pending element blurs and has been detached. */\n readonly #onRelease: (element: T) => void;\n\n /** @param onRelease - Invoked once `element` actually blurs; never on `release`. */\n constructor(onRelease: (element: T) => void) {\n this.#onRelease = onRelease;\n }\n\n /** Number of elements currently holding an update back. */\n get size(): number {\n return this.#pending.size;\n }\n\n /** Snapshot of the pending elements, safe to iterate while releasing them. */\n get elements(): T[] {\n return [...this.#pending.keys()];\n }\n\n /** Whether `element` is currently holding an update back. */\n has(element: T): boolean {\n return this.#pending.has(element);\n }\n\n /** Holds an update back until `element` blurs. Idempotent (no stacked listeners). */\n defer(element: T): void {\n if (this.#pending.has(element)) return;\n const onBlur = (): void => {\n this.#detach(element);\n this.#onRelease(element);\n };\n this.#pending.set(element, onBlur);\n element.addEventListener(\"blur\", onBlur);\n }\n\n /** Defers `element` as the only pending entry, cancelling any others. */\n deferOnly(element: T): void {\n for (const pending of this.elements) {\n if (pending !== element) this.#detach(pending);\n }\n this.defer(element);\n }\n\n /** Cancels `element`'s deferral without completing it; no-ops when not pending. */\n release(element: T): void {\n this.#detach(element);\n }\n\n /** Cancels every deferral without completing any of them. */\n releaseAll(): void {\n for (const element of this.elements) this.#detach(element);\n }\n\n /** Removes the `blur` listener for `element` and forgets it. */\n #detach(element: T): void {\n const onBlur = this.#pending.get(element);\n if (onBlur) element.removeEventListener(\"blur\", onBlur);\n this.#pending.delete(element);\n }\n}\n","/**\n * Shared `prefers-reduced-motion` lookup for the motion-aware controllers\n *.\n *\n * This one-liner keeps the media query string and the environment guard\n * single-sourced across them. The preference is intentionally re-read on every\n * call — the controllers check it at each animation/scroll start (WCAG 2.2\n * **2.3.3**), so flipping the OS setting takes effect immediately without any\n * listener or cache bookkeeping here.\n */\n\n/**\n * Whether the user currently requests reduced motion.\n *\n * @returns `true` when `(prefers-reduced-motion: reduce)` matches; `false`\n * otherwise, including environments without `window.matchMedia` (treated as\n * \"no preference\").\n */\nexport function prefersReducedMotion(): boolean {\n return (\n typeof window.matchMedia === \"function\" &&\n window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n );\n}\n","/**\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","import { BeforeCacheReset } from \"./before_cache_reset\";\n\n/**\n * Shared bookkeeping for a `tabindex` a controller lends an element temporarily.\n *\n * A controller that must move focus somewhere the author never made focusable\n * (a landmark root, a scroll destination) reaches for the same trick: add a\n * `tabindex` just-in-time and hand it back once it is no longer needed. The\n * borrow is the easy half; the return is what the two conditions below are for.\n *\n * **Returning needs two conditions, not one.** Owning the borrow is not enough:\n * the attribute must also still hold the value this instance wrote. A consumer\n * that changed it afterwards — `tabindex=\"0\"` to make the root its own Tab stop\n * — owns it now, and removing it there silently discards authored markup. The\n * bookkeeping is dropped either way, since the loan is over regardless of who\n * ends up owning the value.\n *\n * **Never borrow over an existing value.** An element that already carries a\n * `tabindex` is the author's to control, so there is nothing to lend and nothing\n * to return.\n *\n * Every live loan also owns a shared `turbo:before-cache` subscription. The loan\n * is returned before Turbo can copy it into a snapshot, so consumers get cache\n * safety without duplicating a lifecycle hook; `returnAll()` removes the\n * subscription again as soon as no loan remains.\n *\n * The registry is keyed by element, so a controller borrowing on a single\n * element (`this.element`) and one borrowing across a changing set of targets\n * use the same API — the single-element case is a set of one. It holds no\n * opinion about *when* to borrow or where focus goes next; that stays in the\n * controller.\n *\n * **The API is deliberately two methods.** This file's own doc block is dropped\n * from `dist`, but every member comment is inlined into **each** consumer entry\n * (`tsup` builds with `splitting: false`), so rationale belongs here, only the\n * contract belongs on the members, and every method no consumer calls is still\n * paid for once per consumer entry.\n *\n * @example\n * ```ts\n * readonly #tabindex = new TabindexLoan();\n *\n * #rescueFocus() {\n * this.#tabindex.lend(this.element);\n * this.element.focus();\n * }\n *\n * disconnect() {\n * this.#tabindex.returnAll();\n * }\n * ```\n */\nexport class TabindexLoan<T extends HTMLElement = HTMLElement> {\n readonly #value: string;\n readonly #lent = new Set<T>();\n /** Returns live loans before Turbo can copy them into its page snapshot. */\n readonly #beforeCache = new BeforeCacheReset(() => this.returnAll());\n\n /**\n * @param value - the `tabindex` to lend. `\"-1\"` (the default) is\n * programmatically focusable but not a Tab stop; `\"0\"` is a real Tab stop,\n * which a scroll region with no focusable content of its own needs.\n */\n constructor(value: string = \"-1\") {\n this.#value = value;\n }\n\n /** Lends `element` the value; no-ops when it already carries a `tabindex`. */\n lend(element: T): void {\n if (element.hasAttribute(\"tabindex\")) return;\n element.setAttribute(\"tabindex\", this.#value);\n this.#lent.add(element);\n // Subscribe only while a real loan exists. Keeping this guarantee here means\n // every consumer is Turbo-safe without another lifecycle hook to remember.\n this.#beforeCache.activate();\n }\n\n /** Takes back every loan whose value is still the one that was lent. */\n returnAll(): void {\n for (const element of this.#lent) {\n if (element.getAttribute(\"tabindex\") === this.#value) element.removeAttribute(\"tabindex\");\n }\n this.#lent.clear();\n this.#beforeCache.deactivate();\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { BlurDeferral } from \"../utils/blur_deferral\";\nimport { prefersReducedMotion } from \"../utils/reduced_motion\";\nimport { TabindexLoan } from \"../utils/tabindex_loan\";\n\n/** Default scroll threshold in px, and what a non-finite declaration falls back to. */\nconst DEFAULT_OFFSET = 400;\n\n/**\n * Headless **Scroll Visibility** behavior: shows or hides an element based on\n * scroll amount or direction (back-to-top buttons, hide-on-scroll headers). No\n * dedicated APG pattern; when the element is a button it follows the Button\n * practice.\n *\n * Markup contract (identifier: `stimeo--scroll-visibility`):\n * <div data-controller=\"stimeo--scroll-visibility\"\n * data-stimeo--scroll-visibility-offset-value=\"400\"\n * data-stimeo--scroll-visibility-mode-value=\"offset\">\n * <button type=\"button\" hidden\n * data-stimeo--scroll-visibility-target=\"element\"\n * data-action=\"stimeo--scroll-visibility#toTop\">Back to top</button>\n * </div>\n *\n * In `offset` mode the element is shown once the scroll source is scrolled past\n * `offset` px; in `direction` mode it is hidden while scrolling down and shown\n * while scrolling up. A `scroll` that carries no vertical movement — a\n * horizontal scroll, an overscroll bounce — has no direction, so `direction`\n * mode leaves the current visibility alone. Visibility is reflected through the\n * `hidden` attribute (so a hidden control also leaves the focus order) and\n * `data-state`.\n *\n * By default the **window** is the scroll source. When the page itself does not\n * scroll — e.g. a fixed-height app shell whose main column scrolls in a container\n * (`overflow: auto`) — point `root` at that container (a CSS selector) so the\n * controller observes the element's scroll instead of the (never-scrolling)\n * window. `toTop` then scrolls that same container.\n *\n * `change` dispatches `{ visible: boolean }` on transitions only: connecting\n * reflects the current scroll state onto the hooks without announcing it, so a\n * Turbo restore does not replay the state the snapshot already carries.\n *\n * @remarks\n * Behavior only — the look and any transition are the consumer's CSS. The scroll\n * listener is `passive`, coalesced through `requestAnimationFrame`, and removed on\n * `disconnect()` (Turbo navigation included). `offset` / `mode` are re-read at\n * runtime, so a Turbo morph that swaps either attribute is followed without\n * waiting for the next scroll; a selector (`root`, `focusSelector`) or a\n * threshold that cannot be parsed reads as its default, keeping the rest of the\n * element alive. **A scroll never hides the control while it owns focus**: that\n * hide waits for it to blur and is decided again from the scroll position at that\n * moment. `toTop` honors `prefers-reduced-motion` by forcing an\n * instant jump independently of the consumer's CSS `scroll-behavior`, and can\n * move focus to a `focusSelector` target (given `tabindex=\"-1\"` if needed, and\n * focused without scrolling so the smooth scroll survives) to keep keyboard\n * users oriented after the scroll. A live disconnect removes only a\n * `tabindex=\"-1\"` this controller instance added; authored tabindex values\n * remain. This teardown does not claim to rewrite a Turbo cache snapshot that\n * was cloned before disconnect.\n */\nexport class ScrollVisibilityController extends Controller<HTMLElement> {\n static override targets = [\"element\"];\n static override values = {\n offset: { type: Number, default: DEFAULT_OFFSET },\n mode: { type: String, default: \"offset\" },\n focusSelector: { type: String, default: \"\" },\n root: { type: String, default: \"\" },\n };\n static actions = [\"toTop\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly elementTarget: HTMLElement;\n declare readonly hasElementTarget: boolean;\n\n declare offsetValue: number;\n declare modeValue: string;\n declare focusSelectorValue: string;\n declare rootValue: string;\n\n /** Pending rAF id that coalesces scroll bursts into one measurement. */\n #rafId: number | null = null;\n /** Previous scroll position, for `direction` mode delta detection. */\n #lastScrollY = 0;\n /** Current visibility, tracked to dispatch `change` only on real transitions. */\n #visible: boolean | null = null;\n /**\n * The observed scroll source: a container element when `root` resolves, else\n * the window. Captured on connect so teardown detaches from the same source.\n */\n #scrollSource: HTMLElement | Window = window;\n /**\n * Gates the declaration callbacks to the connected window.\n *\n * Stimulus delivers a Value callback ahead of `connect()` and again for every\n * runtime change; without the gate, merely connecting would evaluate — and\n * announce — before `connect()` runs its own first reflection.\n */\n #connected = false;\n /** Validated threshold; a non-finite declaration reads as the default. */\n #offset = DEFAULT_OFFSET;\n /** Validated `root` selector; an unparsable declaration reads as absent. */\n #rootSelector = \"\";\n /** Validated `focusSelector`; an unparsable declaration reads as absent. */\n #focusSelector = \"\";\n /** Focus targets this instance lent a `tabindex` to. */\n readonly #tabindex = new TabindexLoan();\n /**\n * The hide held back while the control itself owns focus.\n *\n * Completing the deferral re-runs the ordinary evaluation rather than applying\n * the stale decision: by the time focus leaves, the scroll position may have\n * moved back past the threshold.\n */\n readonly #pendingHide = new BlurDeferral((): void => {\n if (this.#connected) this.#evaluate();\n });\n\n readonly #onScroll = (): void => {\n if (this.#rafId !== null) return;\n this.#rafId = requestAnimationFrame(() => {\n this.#rafId = null;\n this.#evaluate();\n });\n };\n\n override connect(): void {\n this.#scrollSource = this.#resolveScrollSource();\n this.#lastScrollY = this.#scrollY();\n this.#scrollSource.addEventListener(\"scroll\", this.#onScroll, { passive: true });\n this.#evaluate(false);\n this.#connected = true;\n }\n\n override disconnect(): void {\n this.#connected = false;\n this.#scrollSource.removeEventListener(\"scroll\", this.#onScroll);\n if (this.#rafId !== null) {\n cancelAnimationFrame(this.#rafId);\n this.#rafId = null;\n }\n this.#pendingHide.releaseAll();\n this.#tabindex.returnAll();\n this.#visible = null;\n }\n\n /** Writes the current visibility onto a control that arrives after connect. */\n elementTargetConnected(element: HTMLElement): void {\n if (this.#visible !== null) element.hidden = !this.#visible;\n }\n\n /** Drops a held-back hide together with the control it was waiting on. */\n elementTargetDisconnected(): void {\n // At most one hide is ever held back, and it rides an element inside the\n // target — the focus owner, which may be a descendant rather than the\n // target itself. Losing the target ends that wait either way.\n this.#pendingHide.releaseAll();\n }\n\n /**\n * Validates `offset` once, then re-renders.\n *\n * Re-renders when application code (or a Turbo morph) changes `offset` at\n * runtime. A declaration that is not a finite number reads as the default, so\n * the comparison path never sees `NaN` — which would answer `false` to every\n * comparison and strand the element (in `direction` mode, even the guarantee\n * that the very top always reveals).\n */\n offsetValueChanged(): void {\n this.#offset = Number.isFinite(this.offsetValue) ? this.offsetValue : DEFAULT_OFFSET;\n if (this.#connected) this.#evaluate();\n }\n\n /** Re-renders when application code (or a Turbo morph) changes `mode` at runtime. */\n modeValueChanged(): void {\n if (this.#connected) this.#evaluate();\n }\n\n /** Validates `root` once so connect never parses a selector that throws. */\n rootValueChanged(): void {\n this.#rootSelector = this.#validSelector(this.rootValue);\n }\n\n /** Validates `focusSelector` once so `toTop` never parses a selector that throws. */\n focusSelectorValueChanged(): void {\n this.#focusSelector = this.#validSelector(this.focusSelectorValue);\n }\n\n /** Scrolls the source to the top and, optionally, moves focus to a safe target. */\n toTop(): void {\n const behavior: ScrollBehavior = prefersReducedMotion() ? \"instant\" : \"smooth\";\n this.#scrollSource.scrollTo({ top: 0, behavior });\n if (this.#focusSelector) {\n const target = document.querySelector<HTMLElement>(this.#focusSelector);\n if (target) {\n this.#tabindex.lend(target);\n // Focusing scrolls the target into view by default, which lands the page\n // instantly and discards the scroll above.\n target.focus({ preventScroll: true });\n }\n }\n }\n\n /**\n * Decides the next visibility from the current scroll state and applies it.\n *\n * @param notify - whether a transition announces itself. The reflection\n * `connect()` performs is the current state, not a change.\n *\n * @stimeoRenderRoot\n */\n #evaluate(notify = true): void {\n const y = this.#scrollY();\n let nextVisible: boolean;\n if (this.modeValue === \"direction\") {\n // Near the very top, always reveal so a hide-on-scroll header is never\n // stranded off-screen when the page cannot scroll up any further.\n if (y <= this.#offset) {\n nextVisible = true;\n } else if (y === this.#lastScrollY) {\n // No vertical movement carries no direction, so it decides nothing.\n return;\n } else {\n nextVisible = y < this.#lastScrollY; // scrolling up reveals, down hides\n }\n } else {\n nextVisible = y > this.#offset;\n }\n this.#lastScrollY = y;\n this.#setVisible(nextVisible, notify);\n }\n\n /** Applies visibility to the target, syncing `hidden`, `data-state`, `change`. */\n #setVisible(next: boolean, notify: boolean): void {\n if (next === this.#visible) return;\n const focused = !next && this.hasElementTarget ? this.#focusedWithin() : null;\n if (focused) {\n // Hiding the element that holds focus drops it to the document body,\n // stranding a keyboard user mid-interaction (WCAG 2.4.7 / 2.4.11). Hold\n // the hide until it blurs; the state stays what the user can still see.\n this.#pendingHide.deferOnly(focused);\n return;\n }\n this.#visible = next;\n if (this.hasElementTarget) this.elementTarget.hidden = !next;\n this.element.setAttribute(\"data-state\", next ? \"visible\" : \"hidden\");\n if (notify) this.dispatch(\"change\", { detail: { visible: next } });\n }\n\n /** Resolves the scroll source from `root` (falling back to the window). */\n #resolveScrollSource(): HTMLElement | Window {\n if (this.#rootSelector) {\n const root = document.querySelector<HTMLElement>(this.#rootSelector);\n if (root) return root;\n }\n return window;\n }\n\n /**\n * The focus owner inside the target, or `null` when focus is elsewhere.\n *\n * `blur` does not bubble, so the deferral has to ride the focused element\n * itself: waiting on a container that never receives the event would hold the\n * hide forever.\n */\n #focusedWithin(): HTMLElement | null {\n const focused = document.activeElement;\n if (focused instanceof HTMLElement && this.elementTarget.contains(focused)) return focused;\n return null;\n }\n\n /** Returns `declared` when it parses as a selector, and `\"\"` when it does not. */\n #validSelector(declared: string): string {\n if (declared.length > 0) {\n try {\n this.element.matches(declared);\n return declared;\n } catch {\n // Unparsable selector: fall through to the default below.\n }\n }\n return \"\";\n }\n\n #scrollY(): number {\n if (this.#scrollSource === window) {\n return window.scrollY ?? window.pageYOffset ?? 0;\n }\n return (this.#scrollSource as HTMLElement).scrollTop;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/blur_deferral.ts","../../src/utils/declared_value.ts","../../src/utils/frame_coalescer.ts","../../src/utils/reduced_motion.ts","../../src/utils/scroll_source.ts","../../src/utils/before_cache_reset.ts","../../src/utils/tabindex_loan.ts","../../src/controllers/scroll_visibility_controller.ts"],"names":[],"mappings":"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","file":"scroll_visibility_controller.js","sourcesContent":["/**\n * Shared bookkeeping for updates that must wait until an element loses focus.\n *\n * A control that becomes redundant while the user is standing on it cannot be\n * removed from the page immediately: hiding a focused element (or turning it\n * into a native `disabled` one) drops focus to `<body>`, stranding a keyboard\n * user mid-widget. The fix every affected controller reached for is the same —\n * keep the element reachable, hold the destructive update, listen for `blur`,\n * and apply it then.\n *\n * {@link BlurDeferral} owns *only* the registry part of that: which elements are\n * currently holding an update back, attaching and detaching the `blur` listener,\n * and guaranteeing teardown. It is deliberately **policy-free** — what the\n * interim state looks like (`aria-disabled`, an ownership marker, nothing at\n * all), and what the deferred update actually is, stay in the controller, which\n * receives the element back through its release callback.\n *\n * Three contracts are worth stating up front, because each one is a hazard the\n * consumers would otherwise hit:\n *\n * - **The release callback fires only on a real `blur`.** `release()` /\n * `releaseAll()` *cancel* a deferral; they do not complete it. Conflating the\n * two makes `overflow-indicator` disable a button that just left the boundary.\n * - **`elements` is a snapshot.** Iterating the live key set while releasing\n * inside the loop is the failure this registry exists to prevent.\n * - **The entry is detached before the callback runs**, so a controller that\n * decides the update is still unsafe can simply defer again.\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 contracts belong on the members.\n *\n * @example\n * ```ts\n * readonly #deferred = new BlurDeferral<HTMLElement>((trigger) => {\n * if (this.#connected) this.#evaluate();\n * });\n *\n * #hide(trigger: HTMLElement) {\n * if (document.activeElement === trigger) {\n * this.#deferred.deferOnly(trigger); // stays visible until blur\n * return;\n * }\n * this.#deferred.releaseAll();\n * trigger.hidden = true;\n * }\n *\n * disconnect() {\n * this.#deferred.releaseAll();\n * }\n * ```\n */\nexport class BlurDeferral<T extends HTMLElement = HTMLElement> {\n /** Elements currently holding an update back, mapped to their `blur` listener. */\n readonly #pending = new Map<T, () => void>();\n /** Called after a pending element blurs and has been detached. */\n readonly #onRelease: (element: T) => void;\n\n /** @param onRelease - Invoked once `element` actually blurs; never on `release`. */\n constructor(onRelease: (element: T) => void) {\n this.#onRelease = onRelease;\n }\n\n /** Number of elements currently holding an update back. */\n get size(): number {\n return this.#pending.size;\n }\n\n /** Snapshot of the pending elements, safe to iterate while releasing them. */\n get elements(): T[] {\n return [...this.#pending.keys()];\n }\n\n /** Whether `element` is currently holding an update back. */\n has(element: T): boolean {\n return this.#pending.has(element);\n }\n\n /** Holds an update back until `element` blurs. Idempotent (no stacked listeners). */\n defer(element: T): void {\n if (this.#pending.has(element)) return;\n const onBlur = (): void => {\n this.#detach(element);\n this.#onRelease(element);\n };\n this.#pending.set(element, onBlur);\n element.addEventListener(\"blur\", onBlur);\n }\n\n /** Defers `element` as the only pending entry, cancelling any others. */\n deferOnly(element: T): void {\n for (const pending of this.elements) {\n if (pending !== element) this.#detach(pending);\n }\n this.defer(element);\n }\n\n /** Cancels `element`'s deferral without completing it; no-ops when not pending. */\n release(element: T): void {\n this.#detach(element);\n }\n\n /** Cancels every deferral without completing any of them. */\n releaseAll(): void {\n for (const element of this.elements) this.#detach(element);\n }\n\n /** Removes the `blur` listener for `element` and forgets it. */\n #detach(element: T): void {\n const onBlur = this.#pending.get(element);\n if (onBlur) element.removeEventListener(\"blur\", onBlur);\n this.#pending.delete(element);\n }\n}\n","/**\n * Readers for the String Values whose text can only be checked by handing it to\n * a parser: a CSS selector, a regular-expression source, a JSON object.\n *\n * Stimulus reads each of them as an ordinary string, so the controller connects\n * and the flaw surfaces later, inside the first handler that consumes the value\n * (a `SyntaxError` from `new RegExp`, a `DOMException` from the selector engine),\n * where it takes the whole handler down on every event. Parsing here, once, in\n * the `<name>ValueChanged` callback keeps the failure local to the declaration:\n * an unreadable value falls back to its default, the element stays alive, and\n * the hot path only ever receives a value that parsed.\n *\n * Which default a broken declaration falls back to is the caller's contract, so\n * every reader takes (or returns) the fallback rather than choosing one.\n */\n\n/**\n * The result of `parse(raw)`, or `fallback` when `parse` throws.\n *\n * Any exception counts as \"does not parse\": the parsers these Values feed\n * (`RegExp`, `JSON.parse`, the selector engine) all report a malformed input by\n * throwing, and none of them throws for another reason.\n */\nexport function parseDeclared<T>(raw: string, parse: (raw: string) => T, fallback: T): T {\n try {\n return parse(raw);\n } catch {\n return fallback;\n }\n}\n\n/** What {@link validSelector} needs of the element it probes with. */\nexport interface SelectorProbe {\n matches(selector: string): boolean;\n}\n\n/**\n * `raw` when it is a non-empty selector the DOM accepts, otherwise `fallback`.\n *\n * `element.matches` is the probe: the engine parses the selector and throws for\n * one it cannot read. Whether the selector actually matches `element` plays no\n * part, so a selector aimed at another element still passes.\n *\n * The parameter names the one method the probe uses rather than `Element`, so\n * this module carries no DOM type and the readers below stay importable from\n * the Node-side Inspector, which checks the same declarations statically.\n */\nexport function validSelector(element: SelectorProbe, raw: string, fallback: string): string {\n if (raw.length === 0) return fallback;\n return parseDeclared(\n raw,\n (selector) => {\n element.matches(selector);\n return selector;\n },\n fallback,\n );\n}\n\n/** How `compileRegExp` wraps a source before compiling it. */\nexport type RegExpAnchor = \"none\" | \"exact\";\n\n/**\n * `source` compiled as a `RegExp`, or `null` when it does not compile.\n *\n * `\"exact\"` wraps the source as `^(?:source)$`, so the whole input has to match\n * and an alternation inside the source cannot escape the anchors. The source is\n * compiled on its own first, because an unbalanced source can be made to parse\n * by the wrapper's own parentheses — `0)|(1` becomes `^(?:0)|(1)$` — which would\n * accept a declaration that is not a regular expression and leave half of it\n * outside the anchors. The default that replaces a broken source is the\n * caller's, so `null` is returned rather than a fallback pattern.\n */\nexport function compileRegExp(source: string, anchor: RegExpAnchor = \"none\"): RegExp | null {\n return parseDeclared(\n source,\n (text) => {\n const bare = new RegExp(text);\n return anchor === \"exact\" ? new RegExp(`^(?:${text})$`) : bare;\n },\n null,\n );\n}\n\n/**\n * The JSON object `raw` declares, or `null` when the text does not parse or\n * parses to something other than a plain object (`null`, an array, a scalar).\n * Values are returned as parsed; narrowing them is the caller's contract.\n */\nexport function parseJsonObject(raw: string): Record<string, unknown> | null {\n const parsed = parseDeclared<unknown>(raw, (text) => JSON.parse(text), null);\n if (parsed === null || typeof parsed !== \"object\" || Array.isArray(parsed)) return null;\n return parsed as Record<string, unknown>;\n}\n","/**\n * Runs one piece of scroll-driven work per animation frame.\n *\n * A `scroll` listener fires many times between two paints, and each firing would\n * measure the same layout. Requesting a frame on the first firing and ignoring\n * the rest until it runs keeps the reads to one per frame; running on the frame\n * — rather than on a timer — keeps the measurement in step with what the reader\n * sees.\n *\n * **The first request of a burst wins.** Work handed in while a frame is already\n * pending is dropped, so a caller that queues a special first pass and then\n * receives scrolls gets that first pass, not the last scroll's.\n *\n * Cancelling drops the pending frame outright, and does nothing at all when none\n * is pending. A frame really is cancelled, so nothing needs a generation to tell\n * a stale callback from a fresh one, and nothing has to be refused before the\n * caller starts listening.\n *\n * Scope is the scheduling only. What to measure stays with the caller.\n *\n * @example\n * ```ts\n * readonly #frames = new FrameCoalescer();\n *\n * readonly #onScroll = (): void => this.#frames.schedule(() => this.#measure());\n *\n * disconnect(): void {\n * this.#frames.cancel();\n * }\n * ```\n */\nexport class FrameCoalescer {\n #frame: number | null = null;\n\n /**\n * Runs `run` on the next frame, unless a frame is already pending — the first\n * request of a burst wins and the rest are dropped. The pending frame is\n * released before `run`, so `run` may request the next one.\n */\n schedule(run: () => void): void {\n if (this.#frame !== null) return;\n this.#frame = requestAnimationFrame(() => {\n this.#frame = null;\n run();\n });\n }\n\n /**\n * Drops the pending frame, and reaches the platform only when there is one.\n *\n * There is no handle value that stands for \"nothing pending\":\n * `cancelAnimationFrame` takes an `unsigned long`, so a negative placeholder\n * arrives as a large positive number that the same allocator can hand out, and\n * an idle cancel would then drop a frame belonging to someone else.\n */\n cancel(): void {\n if (this.#frame === null) return;\n cancelAnimationFrame(this.#frame);\n this.#frame = null;\n }\n}\n","/**\n * Shared `prefers-reduced-motion` lookup for the motion-aware controllers.\n *\n * This one-liner keeps the media query string and the environment guard\n * single-sourced across them. The preference is intentionally re-read on every\n * call — the controllers check it at each animation/scroll start (WCAG 2.2\n * **2.3.3**), so flipping the OS setting takes effect immediately without any\n * listener or cache bookkeeping here.\n */\n\n/**\n * Whether the user currently requests reduced motion.\n *\n * @returns `true` when `(prefers-reduced-motion: reduce)` matches; `false`\n * otherwise, including environments without `window.matchMedia` (treated as\n * \"no preference\").\n */\nexport function prefersReducedMotion(): boolean {\n return (\n typeof window.matchMedia === \"function\" &&\n window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n );\n}\n","/**\n * What a scroll-driven component listens to and measures: a scroll container\n * somewhere in the page, or the window when nothing names one.\n *\n * A component that reacts to scrolling takes a selector for the container it\n * lives in and falls back to the viewport. Resolving that, and reading how far\n * the result has scrolled, are the same two steps everywhere — and the read is\n * the one that drifts, because the window and an element expose the offset under\n * different names.\n *\n * Resolution only. A selector reaches here already validated, or empty; whether\n * a declaration parses is decided once where it is declared, not on every\n * resolve.\n */\n\n/** A scroll container, or the window. */\nexport type ScrollSource = HTMLElement | Window;\n\n/**\n * The scroll container `selector` names, or `null` for the viewport.\n *\n * `null` covers every way a container can fail to be one: an empty selector, a\n * selector that matches nothing, and a match that is not an `HTMLElement` — an\n * SVG node answers a query but does not scroll.\n *\n * `selector` must be empty or already validated: an unparsable one makes the\n * query throw rather than fall back.\n */\nexport function resolveScrollContainer(selector: string): HTMLElement | null {\n const match = selector ? document.querySelector(selector) : null;\n return match instanceof HTMLElement ? match : null;\n}\n\n/** The scroll source `selector` names: its container, else the window. */\nexport function resolveScrollSource(selector: string): ScrollSource {\n return resolveScrollContainer(selector) ?? window;\n}\n\n/**\n * How far `source` has scrolled vertically.\n *\n * The window is compared by identity rather than with `instanceof`, which a\n * cross-realm or synthetic DOM fails. Its offset is read through the modern name\n * first and the legacy one after, so a runtime that exposes only one still gives\n * a number.\n */\nexport function scrollOffset(source: ScrollSource): number {\n return source === window\n ? (window.scrollY ?? window.pageYOffset ?? 0)\n : (source as HTMLElement).scrollTop;\n}\n","/**\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","import { BeforeCacheReset } from \"./before_cache_reset\";\n\n/**\n * Shared bookkeeping for a `tabindex` a controller lends an element temporarily.\n *\n * A controller that must move focus somewhere the author never made focusable\n * (a landmark root, a scroll destination) reaches for the same trick: add a\n * `tabindex` just-in-time and hand it back once it is no longer needed. The\n * borrow is the easy half; the return is what the two conditions below are for.\n *\n * **Returning needs two conditions, not one.** Owning the borrow is not enough:\n * the attribute must also still hold the value this instance wrote. A consumer\n * that changed it afterwards — `tabindex=\"0\"` to make the root its own Tab stop\n * — owns it now, and removing it there silently discards authored markup. The\n * bookkeeping is dropped either way, since the loan is over regardless of who\n * ends up owning the value.\n *\n * **Never borrow over an existing value.** An element that already carries a\n * `tabindex` is the author's to control, so there is nothing to lend and nothing\n * to return.\n *\n * Every live loan also owns a shared `turbo:before-cache` subscription. The loan\n * is returned before Turbo can copy it into a snapshot, so consumers get cache\n * safety without duplicating a lifecycle hook; `returnAll()` removes the\n * subscription again as soon as no loan remains.\n *\n * The registry is keyed by element, so a controller borrowing on a single\n * element (`this.element`) and one borrowing across a changing set of targets\n * use the same API — the single-element case is a set of one. It holds no\n * opinion about *when* to borrow or where focus goes next; that stays in the\n * controller.\n *\n * **The API is deliberately two methods.** This file's own doc block is dropped\n * from `dist`, but every member comment is inlined into **each** consumer entry\n * (`tsup` builds with `splitting: false`), so rationale belongs here, only the\n * contract belongs on the members, and every method no consumer calls is still\n * paid for once per consumer entry.\n *\n * @example\n * ```ts\n * readonly #tabindex = new TabindexLoan();\n *\n * #rescueFocus() {\n * this.#tabindex.lend(this.element);\n * this.element.focus();\n * }\n *\n * disconnect() {\n * this.#tabindex.returnAll();\n * }\n * ```\n */\nexport class TabindexLoan<T extends HTMLElement = HTMLElement> {\n readonly #value: string;\n readonly #lent = new Set<T>();\n /** Returns live loans before Turbo can copy them into its page snapshot. */\n readonly #beforeCache = new BeforeCacheReset(() => this.returnAll());\n\n /**\n * @param value - the `tabindex` to lend. `\"-1\"` (the default) is\n * programmatically focusable but not a Tab stop; `\"0\"` is a real Tab stop,\n * which a scroll region with no focusable content of its own needs.\n */\n constructor(value: string = \"-1\") {\n this.#value = value;\n }\n\n /** Lends `element` the value; no-ops when it already carries a `tabindex`. */\n lend(element: T): void {\n if (element.hasAttribute(\"tabindex\")) return;\n element.setAttribute(\"tabindex\", this.#value);\n this.#lent.add(element);\n // Subscribe only while a real loan exists. Keeping this guarantee here means\n // every consumer is Turbo-safe without another lifecycle hook to remember.\n this.#beforeCache.activate();\n }\n\n /** Takes back every loan whose value is still the one that was lent. */\n returnAll(): void {\n for (const element of this.#lent) {\n if (element.getAttribute(\"tabindex\") === this.#value) element.removeAttribute(\"tabindex\");\n }\n this.#lent.clear();\n this.#beforeCache.deactivate();\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { BlurDeferral } from \"../utils/blur_deferral\";\nimport { validSelector } from \"../utils/declared_value\";\nimport { FrameCoalescer } from \"../utils/frame_coalescer\";\nimport { prefersReducedMotion } from \"../utils/reduced_motion\";\nimport { resolveScrollSource, scrollOffset } from \"../utils/scroll_source\";\nimport { TabindexLoan } from \"../utils/tabindex_loan\";\n\n/** Default scroll threshold in px, and what a non-finite declaration falls back to. */\nconst DEFAULT_OFFSET = 400;\n\n/**\n * Headless **Scroll Visibility** behavior: shows or hides an element based on\n * scroll amount or direction (back-to-top buttons, hide-on-scroll headers). No\n * dedicated APG pattern; when the element is a button it follows the Button\n * practice.\n *\n * Markup contract (identifier: `stimeo--scroll-visibility`):\n * <div data-controller=\"stimeo--scroll-visibility\"\n * data-stimeo--scroll-visibility-offset-value=\"400\"\n * data-stimeo--scroll-visibility-mode-value=\"offset\">\n * <button type=\"button\" hidden\n * data-stimeo--scroll-visibility-target=\"element\"\n * data-action=\"click->stimeo--scroll-visibility#toTop\">Back to top</button>\n * </div>\n *\n * In `offset` mode the element is shown once the scroll source is scrolled past\n * `offset` px; in `direction` mode it is hidden while scrolling down and shown\n * while scrolling up. A `scroll` that carries no vertical movement — a\n * horizontal scroll, an overscroll bounce — has no direction, so `direction`\n * mode leaves the current visibility alone. Visibility is reflected through the\n * `hidden` attribute (so a hidden control also leaves the focus order) and\n * `data-state`.\n *\n * By default the **window** is the scroll source. When the page itself does not\n * scroll — e.g. a fixed-height app shell whose main column scrolls in a container\n * (`overflow: auto`) — point `root` at that container (a CSS selector) so the\n * controller observes the element's scroll instead of the (never-scrolling)\n * window. `toTop` then scrolls that same container.\n *\n * `change` dispatches `{ visible: boolean }` on transitions only: connecting\n * reflects the current scroll state onto the hooks without announcing it, so a\n * Turbo restore does not replay the state the snapshot already carries.\n *\n * @remarks\n * Behavior only — the look and any transition are the consumer's CSS. The scroll\n * listener is `passive`, coalesced through `requestAnimationFrame`, and removed on\n * `disconnect()` (Turbo navigation included). `offset` / `mode` are re-read at\n * runtime, so a Turbo morph that swaps either attribute is followed without\n * waiting for the next scroll; a selector (`root`, `focusSelector`) or a\n * threshold that cannot be parsed reads as its default, keeping the rest of the\n * element alive. **A scroll never hides the control while it owns focus**: that\n * hide waits for it to blur and is decided again from the scroll position at that\n * moment. `toTop` honors `prefers-reduced-motion` by forcing an\n * instant jump independently of the consumer's CSS `scroll-behavior`, and can\n * move focus to a `focusSelector` target (given `tabindex=\"-1\"` if needed, and\n * focused without scrolling so the smooth scroll survives) to keep keyboard\n * users oriented after the scroll. A live disconnect removes only a\n * `tabindex=\"-1\"` this controller instance added; authored tabindex values\n * remain. This teardown does not claim to rewrite a Turbo cache snapshot that\n * was cloned before disconnect.\n */\nexport class ScrollVisibilityController extends Controller<HTMLElement> {\n static override targets = [\"element\"];\n static override values = {\n offset: { type: Number, default: DEFAULT_OFFSET },\n mode: { type: String, default: \"offset\" },\n focusSelector: { type: String, default: \"\" },\n root: { type: String, default: \"\" },\n };\n static actions = [\"toTop\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly elementTarget: HTMLElement;\n declare readonly hasElementTarget: boolean;\n\n declare offsetValue: number;\n declare modeValue: string;\n declare focusSelectorValue: string;\n declare rootValue: string;\n\n /** Coalesces scroll bursts into one measurement per frame. */\n readonly #frames = new FrameCoalescer();\n /** Previous scroll position, for `direction` mode delta detection. */\n #lastScrollY = 0;\n /** Current visibility, tracked to dispatch `change` only on real transitions. */\n #visible: boolean | null = null;\n /**\n * The observed scroll source: a container element when `root` resolves, else\n * the window. Captured on connect so teardown detaches from the same source.\n */\n #scrollSource: HTMLElement | Window = window;\n /**\n * Gates the declaration callbacks to the connected window.\n *\n * Stimulus delivers a Value callback ahead of `connect()` and again for every\n * runtime change; without the gate, merely connecting would evaluate — and\n * announce — before `connect()` runs its own first reflection.\n */\n #connected = false;\n /** Validated threshold; a non-finite declaration reads as the default. */\n #offset = DEFAULT_OFFSET;\n /** Validated `root` selector; an unparsable declaration reads as absent. */\n #rootSelector = \"\";\n /** Validated `focusSelector`; an unparsable declaration reads as absent. */\n #focusSelector = \"\";\n /** Focus targets this instance lent a `tabindex` to. */\n readonly #tabindex = new TabindexLoan();\n /**\n * The hide held back while the control itself owns focus.\n *\n * Completing the deferral re-runs the ordinary evaluation rather than applying\n * the stale decision: by the time focus leaves, the scroll position may have\n * moved back past the threshold.\n */\n readonly #pendingHide = new BlurDeferral((): void => {\n if (this.#connected) this.#evaluate();\n });\n\n readonly #onScroll = (): void => this.#frames.schedule(() => this.#evaluate());\n\n override connect(): void {\n this.#scrollSource = resolveScrollSource(this.#rootSelector);\n this.#lastScrollY = scrollOffset(this.#scrollSource);\n this.#scrollSource.addEventListener(\"scroll\", this.#onScroll, { passive: true });\n this.#evaluate(false);\n this.#connected = true;\n }\n\n override disconnect(): void {\n this.#connected = false;\n this.#scrollSource.removeEventListener(\"scroll\", this.#onScroll);\n this.#frames.cancel();\n this.#pendingHide.releaseAll();\n this.#tabindex.returnAll();\n this.#visible = null;\n }\n\n /** Writes the current visibility onto a control that arrives after connect. */\n elementTargetConnected(element: HTMLElement): void {\n if (this.#visible !== null) element.hidden = !this.#visible;\n }\n\n /** Drops a held-back hide together with the control it was waiting on. */\n elementTargetDisconnected(): void {\n // At most one hide is ever held back, and it rides an element inside the\n // target — the focus owner, which may be a descendant rather than the\n // target itself. Losing the target ends that wait either way.\n this.#pendingHide.releaseAll();\n }\n\n /**\n * Validates `offset` once, then re-renders.\n *\n * Re-renders when application code (or a Turbo morph) changes `offset` at\n * runtime. A declaration that is not a finite number reads as the default, so\n * the comparison path never sees `NaN` — which would answer `false` to every\n * comparison and strand the element (in `direction` mode, even the guarantee\n * that the very top always reveals).\n */\n offsetValueChanged(): void {\n this.#offset = Number.isFinite(this.offsetValue) ? this.offsetValue : DEFAULT_OFFSET;\n if (this.#connected) this.#evaluate();\n }\n\n /** Re-renders when application code (or a Turbo morph) changes `mode` at runtime. */\n modeValueChanged(): void {\n if (this.#connected) this.#evaluate();\n }\n\n /** Validates `root` once so connect never parses a selector that throws. */\n rootValueChanged(): void {\n this.#rootSelector = validSelector(this.element, this.rootValue, \"\");\n }\n\n /** Validates `focusSelector` once so `toTop` never parses a selector that throws. */\n focusSelectorValueChanged(): void {\n this.#focusSelector = validSelector(this.element, this.focusSelectorValue, \"\");\n }\n\n /** Scrolls the source to the top and, optionally, moves focus to a safe target. */\n toTop(): void {\n const behavior: ScrollBehavior = prefersReducedMotion() ? \"instant\" : \"smooth\";\n this.#scrollSource.scrollTo({ top: 0, behavior });\n if (this.#focusSelector) {\n const target = document.querySelector<HTMLElement>(this.#focusSelector);\n if (target) {\n this.#tabindex.lend(target);\n // Focusing scrolls the target into view by default, which lands the page\n // instantly and discards the scroll above.\n target.focus({ preventScroll: true });\n }\n }\n }\n\n /**\n * Decides the next visibility from the current scroll state and applies it.\n *\n * @param notify - whether a transition announces itself. The reflection\n * `connect()` performs is the current state, not a change.\n *\n * @stimeoRenderRoot\n */\n #evaluate(notify = true): void {\n const y = scrollOffset(this.#scrollSource);\n let nextVisible: boolean;\n if (this.modeValue === \"direction\") {\n // Near the very top, always reveal so a hide-on-scroll header is never\n // stranded off-screen when the page cannot scroll up any further.\n if (y <= this.#offset) {\n nextVisible = true;\n } else if (y === this.#lastScrollY) {\n // No vertical movement carries no direction, so it decides nothing.\n return;\n } else {\n nextVisible = y < this.#lastScrollY; // scrolling up reveals, down hides\n }\n } else {\n nextVisible = y > this.#offset;\n }\n this.#lastScrollY = y;\n this.#setVisible(nextVisible, notify);\n }\n\n /** Applies visibility to the target, syncing `hidden`, `data-state`, `change`. */\n #setVisible(next: boolean, notify: boolean): void {\n if (next === this.#visible) return;\n const focused = !next && this.hasElementTarget ? this.#focusedWithin() : null;\n if (focused) {\n // Hiding the element that holds focus drops it to the document body,\n // stranding a keyboard user mid-interaction (WCAG 2.4.7 / 2.4.11). Hold\n // the hide until it blurs; the state stays what the user can still see.\n this.#pendingHide.deferOnly(focused);\n return;\n }\n this.#visible = next;\n if (this.hasElementTarget) this.elementTarget.hidden = !next;\n this.element.setAttribute(\"data-state\", next ? \"visible\" : \"hidden\");\n if (notify) this.dispatch(\"change\", { detail: { visible: next } });\n }\n\n /** Resolves the scroll source from `root` (falling back to the window). */\n /**\n * The focus owner inside the target, or `null` when focus is elsewhere.\n *\n * `blur` does not bubble, so the deferral has to ride the focused element\n * itself: waiting on a container that never receives the event would hold the\n * hide forever.\n */\n #focusedWithin(): HTMLElement | null {\n const focused = document.activeElement;\n if (focused instanceof HTMLElement && this.elementTarget.contains(focused)) return focused;\n return null;\n }\n}\n"]}
|
|
@@ -47,7 +47,7 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
47
47
|
* - `link` targets added or removed — Stimulus's target callbacks.
|
|
48
48
|
* - a `link` target's `href` / `data-href` rewritten in place — a Turbo 8 morph
|
|
49
49
|
* keeps the element *and* its target marker, so no target callback fires;
|
|
50
|
-
*
|
|
50
|
+
* those two attributes are watched for exactly this case.
|
|
51
51
|
* - the reader's scroll position, including inside a stretch where no section
|
|
52
52
|
* crosses an observation threshold.
|
|
53
53
|
*
|
|
@@ -2,14 +2,60 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/scrollspy_controller.ts
|
|
4
4
|
|
|
5
|
-
// src/utils/
|
|
6
|
-
function
|
|
7
|
-
if (!selector) return null;
|
|
5
|
+
// src/utils/declared_value.ts
|
|
6
|
+
function parseDeclared(raw, parse, fallback) {
|
|
8
7
|
try {
|
|
9
|
-
return
|
|
8
|
+
return parse(raw);
|
|
10
9
|
} catch {
|
|
10
|
+
return fallback;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function validSelector(element, raw, fallback) {
|
|
14
|
+
if (raw.length === 0) return fallback;
|
|
15
|
+
return parseDeclared(
|
|
16
|
+
raw,
|
|
17
|
+
(selector) => {
|
|
18
|
+
element.matches(selector);
|
|
19
|
+
return selector;
|
|
20
|
+
},
|
|
21
|
+
fallback
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// src/utils/frame_coalescer.ts
|
|
26
|
+
var FrameCoalescer = class {
|
|
27
|
+
#frame = null;
|
|
28
|
+
/**
|
|
29
|
+
* Runs `run` on the next frame, unless a frame is already pending — the first
|
|
30
|
+
* request of a burst wins and the rest are dropped. The pending frame is
|
|
31
|
+
* released before `run`, so `run` may request the next one.
|
|
32
|
+
*/
|
|
33
|
+
schedule(run) {
|
|
34
|
+
if (this.#frame !== null) return;
|
|
35
|
+
this.#frame = requestAnimationFrame(() => {
|
|
36
|
+
this.#frame = null;
|
|
37
|
+
run();
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Drops the pending frame, and reaches the platform only when there is one.
|
|
42
|
+
*
|
|
43
|
+
* There is no handle value that stands for "nothing pending":
|
|
44
|
+
* `cancelAnimationFrame` takes an `unsigned long`, so a negative placeholder
|
|
45
|
+
* arrives as a large positive number that the same allocator can hand out, and
|
|
46
|
+
* an idle cancel would then drop a frame belonging to someone else.
|
|
47
|
+
*/
|
|
48
|
+
cancel() {
|
|
49
|
+
if (this.#frame === null) return;
|
|
50
|
+
cancelAnimationFrame(this.#frame);
|
|
51
|
+
this.#frame = null;
|
|
11
52
|
}
|
|
12
|
-
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// src/utils/intersection_watcher.ts
|
|
56
|
+
function queryRoot(selector) {
|
|
57
|
+
if (!selector) return null;
|
|
58
|
+
return parseDeclared(selector, (raw) => document.querySelector(raw), null);
|
|
13
59
|
}
|
|
14
60
|
var IntersectionWatcher = class {
|
|
15
61
|
#onEntries;
|
|
@@ -112,6 +158,15 @@ function prefersReducedMotion() {
|
|
|
112
158
|
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
113
159
|
}
|
|
114
160
|
|
|
161
|
+
// src/utils/scroll_source.ts
|
|
162
|
+
function resolveScrollContainer(selector) {
|
|
163
|
+
const match = selector ? document.querySelector(selector) : null;
|
|
164
|
+
return match instanceof HTMLElement ? match : null;
|
|
165
|
+
}
|
|
166
|
+
function scrollOffset(source) {
|
|
167
|
+
return source === window ? window.scrollY ?? window.pageYOffset ?? 0 : source.scrollTop;
|
|
168
|
+
}
|
|
169
|
+
|
|
115
170
|
// src/controllers/scrollspy_controller.ts
|
|
116
171
|
var ANCHOR_ATTRIBUTES = ["href", "data-href"];
|
|
117
172
|
var ScrollspyController = class extends Controller {
|
|
@@ -158,8 +213,10 @@ var ScrollspyController = class extends Controller {
|
|
|
158
213
|
* rather than from whatever the selector resolves to now.
|
|
159
214
|
*/
|
|
160
215
|
#scrollSource = null;
|
|
161
|
-
/**
|
|
162
|
-
#
|
|
216
|
+
/** Coalesces a scroll burst into one measurement per frame. */
|
|
217
|
+
#frames = new FrameCoalescer();
|
|
218
|
+
/** The validated `rootSelector`, or `""` when the declaration cannot be parsed. */
|
|
219
|
+
#rootSelector = "";
|
|
163
220
|
/** Watches the link targets' anchor attributes for an in-place morph rewrite. */
|
|
164
221
|
#anchorObserver = null;
|
|
165
222
|
/** True while a coalesced observation rebuild is queued; see {@link #scheduleResync}. */
|
|
@@ -176,10 +233,7 @@ var ScrollspyController = class extends Controller {
|
|
|
176
233
|
this.#anchorObserver?.disconnect();
|
|
177
234
|
this.#anchorObserver = null;
|
|
178
235
|
this.#detachScrollListener();
|
|
179
|
-
|
|
180
|
-
cancelAnimationFrame(this.#frame);
|
|
181
|
-
this.#frame = null;
|
|
182
|
-
}
|
|
236
|
+
this.#frames.cancel();
|
|
183
237
|
this.#intersectionStates.clear();
|
|
184
238
|
this.#activeSectionId = "";
|
|
185
239
|
this.#rootElement = null;
|
|
@@ -196,6 +250,7 @@ var ScrollspyController = class extends Controller {
|
|
|
196
250
|
this.#initializeObserver();
|
|
197
251
|
}
|
|
198
252
|
rootSelectorValueChanged() {
|
|
253
|
+
this.#rootSelector = validSelector(this.element, this.rootSelectorValue, "");
|
|
199
254
|
if (!this.#isConnected) return;
|
|
200
255
|
this.#initializeObserver();
|
|
201
256
|
}
|
|
@@ -239,7 +294,7 @@ var ScrollspyController = class extends Controller {
|
|
|
239
294
|
* only rewrites attributes, so Stimulus fires no target callback: without
|
|
240
295
|
* this, a link re-pointed from `#intro` to `#faq` would keep the controller
|
|
241
296
|
* observing `#intro` for the rest of the page's life. The filter is exactly
|
|
242
|
-
*
|
|
297
|
+
* `href` and `data-href`; guarding on `MutationObserver` keeps the
|
|
243
298
|
* controller usable where the API is absent, matching `IntersectionWatcher`'s
|
|
244
299
|
* own support guard.
|
|
245
300
|
*/
|
|
@@ -289,10 +344,10 @@ var ScrollspyController = class extends Controller {
|
|
|
289
344
|
const offset = this.#offset;
|
|
290
345
|
if (rootElement) {
|
|
291
346
|
const containerRect = rootElement.getBoundingClientRect();
|
|
292
|
-
const scrollPosition = rootElement
|
|
347
|
+
const scrollPosition = scrollOffset(rootElement) + (targetRect.top - containerRect.top) - offset;
|
|
293
348
|
rootElement.scrollTo({ top: scrollPosition, behavior });
|
|
294
349
|
} else {
|
|
295
|
-
const scrollPosition = window
|
|
350
|
+
const scrollPosition = scrollOffset(window) + targetRect.top - offset;
|
|
296
351
|
window.scrollTo({ top: scrollPosition, behavior });
|
|
297
352
|
}
|
|
298
353
|
if (this.focusSectionValue) this.#focusSection(targetElement);
|
|
@@ -327,19 +382,14 @@ var ScrollspyController = class extends Controller {
|
|
|
327
382
|
/**
|
|
328
383
|
* Resolves `rootSelector` to a scrollable element.
|
|
329
384
|
*
|
|
330
|
-
* @returns The container, or `null` meaning "spy the viewport" when the
|
|
331
|
-
* is empty, matches nothing, matches a non-HTML element
|
|
332
|
-
*
|
|
333
|
-
*
|
|
385
|
+
* @returns The container, or `null` meaning "spy the viewport" when the
|
|
386
|
+
* declaration is empty, matches nothing, or matches a non-HTML element. A typo
|
|
387
|
+
* reads as empty: the declaration is validated once when it changes, so a
|
|
388
|
+
* selector that cannot be parsed degrades to viewport spying rather than
|
|
389
|
+
* leaving the controller inert.
|
|
334
390
|
*/
|
|
335
391
|
#queryRootElement() {
|
|
336
|
-
|
|
337
|
-
try {
|
|
338
|
-
const root = document.querySelector(this.rootSelectorValue);
|
|
339
|
-
return root instanceof HTMLElement ? root : null;
|
|
340
|
-
} catch {
|
|
341
|
-
return null;
|
|
342
|
-
}
|
|
392
|
+
return resolveScrollContainer(this.#rootSelector);
|
|
343
393
|
}
|
|
344
394
|
/**
|
|
345
395
|
* Re-evaluates once per frame while the reader scrolls.
|
|
@@ -354,13 +404,7 @@ var ScrollspyController = class extends Controller {
|
|
|
354
404
|
* end in {@link #evaluateActiveSection} — which measures section rects and the
|
|
355
405
|
* root's top edge at that instant — they converge on the same answer.
|
|
356
406
|
*/
|
|
357
|
-
#onScroll = () =>
|
|
358
|
-
if (this.#frame !== null) return;
|
|
359
|
-
this.#frame = requestAnimationFrame(() => {
|
|
360
|
-
this.#frame = null;
|
|
361
|
-
this.#evaluateActiveSection();
|
|
362
|
-
});
|
|
363
|
-
};
|
|
407
|
+
#onScroll = () => this.#frames.schedule(() => this.#evaluateActiveSection());
|
|
364
408
|
/**
|
|
365
409
|
* Points the `scroll` listener at whatever the reader actually scrolls: the
|
|
366
410
|
* resolved root, or the window when the viewport is spied.
|