import type { Router } from "@real-router/core"; /** * Router-coordinated scroll spy (#575). * * On `IntersectionObserver` notifications the utility picks the topmost * visible anchor inside the configured scroll container and emits a forced * same-route transition with `{ hash, replace: true, force: true, hashChange: * true }` through `router.navigate(...)`. The URL plugin * (`@real-router/browser-plugin` or `@real-router/navigation-plugin`) updates * `state.context.url.hash` so sibling hash-aware `` re-highlights * via the standard `createActiveRouteSource` pipeline. * * **Anti-flicker gates** (RFC §5.2): * 1. `getTransitionSource(router).getSnapshot().isTransitioning` — skip emits * while a transition is in-flight (re-entrant lock). * 2. `coolingDown` — set on a user-driven hash transition (e.g. `` * click + smooth `scrollIntoView`). Cleared on `scrollend` or after a * 500ms safety timeout. Spy's own emits are excluded via the synchronous * `selfEmitting` flag — required so the spy doesn't rate-limit itself. * * **Self-healing** (RFC §7.3): if the initial URL contains a hash without a * matching `id` (e.g. `/page#nonexistent`), the first IO event emitted right * after observe()-ing picks the topmost real anchor and corrects the URL. * * **Hash-only transition pipeline cost** (RFC §5.3): for same-route same- * params hash-only navigations, `getTransitionPath` returns empty * `toDeactivate` / `toActivate` arrays, so `runGuards` is a no-op. The only * work is the URL plugin's `onTransitionSuccess` write and the * `getTransitionSource` flip — cheap. * * **Architecture**: decomposed into 4 private subsystem closure factories * (`createUrlPluginDetector`, `createCooldown`, `createDebouncer`, * `createObserverPair`). The main `createScrollSpy` wires them together * around the shared `silenced` / `destroyed` / `selfEmitting` flags and the * `flush()` emit logic. Each subsystem owns its state + cleanup; `destroy()` * delegates to each. See section banners below. * * @returns A `ScrollSpy` handle whose `destroy()` is idempotent. */ export interface ScrollSpyOptions { /** * CSS selector for anchor candidates. Empty string `""` or `undefined` * disables the spy (returns a NOOP handle). Common values: * `"[id]"`, `"[id]:is(h1,h2,h3)"`, `"section[id]"`. */ selector: string; /** * `IntersectionObserver` `rootMargin`. Default * `"-20% 0px -60% 0px"` — an anchor is considered "active" once it crosses * into the top 20 % of the viewport (or scroll container). */ rootMargin?: string | undefined; /** * Lazy getter for the scrollable container. Consulted at creation and * re-consulted on every reconcile (DOM mutation), so a container that * MOUNTS or CHANGES after the spy is created is honoured: the * `IntersectionObserver` root and `MutationObserver` target — both immutable * once constructed — are rebuilt to match (#780). `null` (or a missing * getter) falls back to the window viewport (`root: null` on the * `IntersectionObserver`). */ scrollContainer?: (() => HTMLElement | null) | undefined; } export interface ScrollSpy { /** Tear down observer + listeners. Idempotent. */ destroy: () => void; } export declare function createScrollSpy(router: Router, options: ScrollSpyOptions): ScrollSpy;