@real-router/solid 0.13.0 → 0.14.1

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/README.md CHANGED
@@ -242,7 +242,7 @@ Navigation link with automatic active state detection. Uses `classList` for acti
242
242
  <Link routeName="settings" hash="account">Account</Link>
243
243
  ```
244
244
 
245
- Tri-state: `undefined` preserves the current hash, `""` clears it, a value sets it. Active class is hash-aware — only the matching tab lights up. Setting `hash` forces the slow path (the fast-path `routeSelector` is hash-agnostic). Live demo: [`examples/web/react/link-hash/`](../../examples/web/react/link-hash/) — behavior is identical across adapters, only template syntax differs. See the [Hash Fragment Support](https://github.com/greydragon888/real-router/wiki/Hash) wiki page for the full surface.
245
+ Tri-state: `undefined` preserves the current hash, `""` clears it, a value sets it. Active class is hash-aware — only the matching tab lights up. Setting `hash` forces the slow path (the fast-path `routeSelector` is hash-agnostic). Live demo: [`examples/web/react/hash-examples/link-hash/`](../../examples/web/react/hash-examples/link-hash/) — behavior is identical across adapters, only template syntax differs. See the [Hash Fragment Support](https://github.com/greydragon888/real-router/wiki/Hash) wiki page for the full surface.
246
246
 
247
247
  ### `<RouteView>`
248
248
 
@@ -526,6 +526,25 @@ Opt-in preservation of scroll position across navigations:
526
526
 
527
527
  Restores scroll on back/forward, scrolls to top (or `#hash`) on push. Three modes: `"restore"` (default), `"top"`, `"native"`. Custom containers via `scrollContainer: () => HTMLElement | null`. Options are read once on mount — changing the prop at runtime does not reconfigure the utility (Solid `onMount` is non-reactive). Under `@real-router/browser-plugin`, replace transitions now preserve scroll position and programmatic reloads restore from `sessionStorage` (portable via `state.transition.replace` / `state.transition.reload`). See [Scroll Restoration guide](https://github.com/greydragon888/real-router/wiki/Scroll-Restoration) for the full behaviour matrix.
528
528
 
529
+ ## Scroll Spy
530
+
531
+ Opt-in router-coordinated `IntersectionObserver` scroll spy — the URL hash tracks the topmost visible anchor as the user scrolls, syncing `state.context.url.hash` so sibling `<Link hash>` highlights stay current:
532
+
533
+ ```tsx
534
+ <RouterProvider
535
+ router={router}
536
+ scrollSpy={{ selector: "[id]:is(h2,h3)" }}
537
+ >
538
+ {/* Your app */}
539
+ </RouterProvider>
540
+ ```
541
+
542
+ Emits a forced same-route transition with `{ hash, replace: true, force: true, hashChange: true }` — same write API as `<Link hash>` (#532), `replace: true` so spy doesn't pollute history. Anti-flicker via `isTransitioning` + `coolingDown` gates with `selfEmitting` guard. Hardcoded internals: rAF + 150 ms debounce, MutationObserver 250 ms.
543
+
544
+ Options: `{ selector: string, rootMargin?: string, scrollContainer?: () => HTMLElement | null }`. Empty `selector` / `undefined` = off. Read once on mount (Solid `onMount` is non-reactive). SSR / browsers without `IntersectionObserver` = NOOP. Requires `browser-plugin` or `navigation-plugin` (hash-plugin / memory-plugin → warn-once + NOOP).
545
+
546
+ Behaviour is identical to the React adapter — see the [React Scroll Spy demo](../../examples/web/react/hash-examples/scroll-spy/) (12 sections, TOC sidebar, 10 e2e scenarios) and the [Scroll Spy guide](https://github.com/greydragon888/real-router/wiki/Scroll-Spy).
547
+
529
548
  ## View Transitions
530
549
 
531
550
  Opt-in animated route transitions via the browser's [View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API):
@@ -542,7 +561,7 @@ No-op on unsupported browsers (Firefox as of 2026-04, SSR). Prop is read once on
542
561
 
543
562
  Full documentation: [Wiki](https://github.com/greydragon888/real-router/wiki)
544
563
 
545
- - [RouterProvider](https://github.com/greydragon888/real-router/wiki/RouterProvider) · [RouteView](https://github.com/greydragon888/real-router/wiki/RouteView) · [RouterErrorBoundary](https://github.com/greydragon888/real-router/wiki/RouterErrorBoundary) · [Link](https://github.com/greydragon888/real-router/wiki/Link) · [Scroll Restoration](https://github.com/greydragon888/real-router/wiki/Scroll-Restoration) · [View Transitions](https://github.com/greydragon888/real-router/wiki/View-Transitions)
564
+ - [RouterProvider](https://github.com/greydragon888/real-router/wiki/RouterProvider) · [RouteView](https://github.com/greydragon888/real-router/wiki/RouteView) · [RouterErrorBoundary](https://github.com/greydragon888/real-router/wiki/RouterErrorBoundary) · [Link](https://github.com/greydragon888/real-router/wiki/Link) · [Scroll Restoration](https://github.com/greydragon888/real-router/wiki/Scroll-Restoration) · [Scroll Spy](https://github.com/greydragon888/real-router/wiki/Scroll-Spy) · [View Transitions](https://github.com/greydragon888/real-router/wiki/View-Transitions)
546
565
  - [useRouter](https://github.com/greydragon888/real-router/wiki/useRouter) · [useRoute](https://github.com/greydragon888/real-router/wiki/useRoute) · [useRouteNode](https://github.com/greydragon888/real-router/wiki/useRouteNode) · [useNavigator](https://github.com/greydragon888/real-router/wiki/useNavigator) · [useRouteUtils](https://github.com/greydragon888/real-router/wiki/useRouteUtils) · [useRouterTransition](https://github.com/greydragon888/real-router/wiki/useRouterTransition) · [useRouteExit](https://github.com/greydragon888/real-router/wiki/useRouteExit) · [useRouteEnter](https://github.com/greydragon888/real-router/wiki/useRouteEnter)
547
566
  - Solid-specific store variants: [useRouteStore / useRouteNodeStore](https://github.com/greydragon888/real-router/wiki/Solid-Integration#store-based-granular-route-state) — `createStore` + `reconcile`, property-level reactivity
548
567
 
@@ -301,10 +301,70 @@ interface ScrollRestorationOptions {
301
301
  storageKey?: string | undefined;
302
302
  }
303
303
 
304
+ /**
305
+ * Router-coordinated scroll spy (#575).
306
+ *
307
+ * On `IntersectionObserver` notifications the utility picks the topmost
308
+ * visible anchor inside the configured scroll container and emits a forced
309
+ * same-route transition with `{ hash, replace: true, force: true, hashChange:
310
+ * true }` through `router.navigate(...)`. The URL plugin
311
+ * (`@real-router/browser-plugin` or `@real-router/navigation-plugin`) updates
312
+ * `state.context.url.hash` so sibling hash-aware `<Link hash>` re-highlights
313
+ * via the standard `createActiveRouteSource` pipeline.
314
+ *
315
+ * **Anti-flicker gates** (RFC §5.2):
316
+ * 1. `getTransitionSource(router).getSnapshot().isTransitioning` — skip emits
317
+ * while a transition is in-flight (re-entrant lock).
318
+ * 2. `coolingDown` — set on a user-driven hash transition (e.g. `<Link hash>`
319
+ * click + smooth `scrollIntoView`). Cleared on `scrollend` or after a
320
+ * 500ms safety timeout. Spy's own emits are excluded via the synchronous
321
+ * `selfEmitting` flag — required so the spy doesn't rate-limit itself.
322
+ *
323
+ * **Self-healing** (RFC §7.3): if the initial URL contains a hash without a
324
+ * matching `id` (e.g. `/page#nonexistent`), the first IO event emitted right
325
+ * after observe()-ing picks the topmost real anchor and corrects the URL.
326
+ *
327
+ * **Hash-only transition pipeline cost** (RFC §5.3): for same-route same-
328
+ * params hash-only navigations, `getTransitionPath` returns empty
329
+ * `toDeactivate` / `toActivate` arrays, so `runGuards` is a no-op. The only
330
+ * work is the URL plugin's `onTransitionSuccess` write and the
331
+ * `getTransitionSource` flip — cheap.
332
+ *
333
+ * **Architecture**: decomposed into 4 private subsystem closure factories
334
+ * (`createUrlPluginDetector`, `createCooldown`, `createDebouncer`,
335
+ * `createObserverPair`). The main `createScrollSpy` wires them together
336
+ * around the shared `silenced` / `destroyed` / `selfEmitting` flags and the
337
+ * `flush()` emit logic. Each subsystem owns its state + cleanup; `destroy()`
338
+ * delegates to each. See section banners below.
339
+ *
340
+ * @returns A `ScrollSpy` handle whose `destroy()` is idempotent.
341
+ */
342
+ interface ScrollSpyOptions {
343
+ /**
344
+ * CSS selector for anchor candidates. Empty string `""` or `undefined`
345
+ * disables the spy (returns a NOOP handle). Common values:
346
+ * `"[id]"`, `"[id]:is(h1,h2,h3)"`, `"section[id]"`.
347
+ */
348
+ selector: string;
349
+ /**
350
+ * `IntersectionObserver` `rootMargin`. Default
351
+ * `"-20% 0px -60% 0px"` — an anchor is considered "active" once it crosses
352
+ * into the top 20 % of the viewport (or scroll container).
353
+ */
354
+ rootMargin?: string | undefined;
355
+ /**
356
+ * Lazy getter for the scrollable container. Resolved on every event.
357
+ * `null` (or missing getter) falls back to the window viewport
358
+ * (`root: null` on the `IntersectionObserver`).
359
+ */
360
+ scrollContainer?: (() => HTMLElement | null) | undefined;
361
+ }
362
+
304
363
  interface RouteProviderProps {
305
364
  router: Router;
306
365
  announceNavigation?: boolean;
307
366
  scrollRestoration?: ScrollRestorationOptions;
367
+ scrollSpy?: ScrollSpyOptions;
308
368
  viewTransitions?: boolean;
309
369
  }
310
370
  declare function RouterProvider(props: ParentProps<RouteProviderProps>): JSX.Element;
package/dist/cjs/index.js CHANGED
Binary file
@@ -301,10 +301,70 @@ interface ScrollRestorationOptions {
301
301
  storageKey?: string | undefined;
302
302
  }
303
303
 
304
+ /**
305
+ * Router-coordinated scroll spy (#575).
306
+ *
307
+ * On `IntersectionObserver` notifications the utility picks the topmost
308
+ * visible anchor inside the configured scroll container and emits a forced
309
+ * same-route transition with `{ hash, replace: true, force: true, hashChange:
310
+ * true }` through `router.navigate(...)`. The URL plugin
311
+ * (`@real-router/browser-plugin` or `@real-router/navigation-plugin`) updates
312
+ * `state.context.url.hash` so sibling hash-aware `<Link hash>` re-highlights
313
+ * via the standard `createActiveRouteSource` pipeline.
314
+ *
315
+ * **Anti-flicker gates** (RFC §5.2):
316
+ * 1. `getTransitionSource(router).getSnapshot().isTransitioning` — skip emits
317
+ * while a transition is in-flight (re-entrant lock).
318
+ * 2. `coolingDown` — set on a user-driven hash transition (e.g. `<Link hash>`
319
+ * click + smooth `scrollIntoView`). Cleared on `scrollend` or after a
320
+ * 500ms safety timeout. Spy's own emits are excluded via the synchronous
321
+ * `selfEmitting` flag — required so the spy doesn't rate-limit itself.
322
+ *
323
+ * **Self-healing** (RFC §7.3): if the initial URL contains a hash without a
324
+ * matching `id` (e.g. `/page#nonexistent`), the first IO event emitted right
325
+ * after observe()-ing picks the topmost real anchor and corrects the URL.
326
+ *
327
+ * **Hash-only transition pipeline cost** (RFC §5.3): for same-route same-
328
+ * params hash-only navigations, `getTransitionPath` returns empty
329
+ * `toDeactivate` / `toActivate` arrays, so `runGuards` is a no-op. The only
330
+ * work is the URL plugin's `onTransitionSuccess` write and the
331
+ * `getTransitionSource` flip — cheap.
332
+ *
333
+ * **Architecture**: decomposed into 4 private subsystem closure factories
334
+ * (`createUrlPluginDetector`, `createCooldown`, `createDebouncer`,
335
+ * `createObserverPair`). The main `createScrollSpy` wires them together
336
+ * around the shared `silenced` / `destroyed` / `selfEmitting` flags and the
337
+ * `flush()` emit logic. Each subsystem owns its state + cleanup; `destroy()`
338
+ * delegates to each. See section banners below.
339
+ *
340
+ * @returns A `ScrollSpy` handle whose `destroy()` is idempotent.
341
+ */
342
+ interface ScrollSpyOptions {
343
+ /**
344
+ * CSS selector for anchor candidates. Empty string `""` or `undefined`
345
+ * disables the spy (returns a NOOP handle). Common values:
346
+ * `"[id]"`, `"[id]:is(h1,h2,h3)"`, `"section[id]"`.
347
+ */
348
+ selector: string;
349
+ /**
350
+ * `IntersectionObserver` `rootMargin`. Default
351
+ * `"-20% 0px -60% 0px"` — an anchor is considered "active" once it crosses
352
+ * into the top 20 % of the viewport (or scroll container).
353
+ */
354
+ rootMargin?: string | undefined;
355
+ /**
356
+ * Lazy getter for the scrollable container. Resolved on every event.
357
+ * `null` (or missing getter) falls back to the window viewport
358
+ * (`root: null` on the `IntersectionObserver`).
359
+ */
360
+ scrollContainer?: (() => HTMLElement | null) | undefined;
361
+ }
362
+
304
363
  interface RouteProviderProps {
305
364
  router: Router;
306
365
  announceNavigation?: boolean;
307
366
  scrollRestoration?: ScrollRestorationOptions;
367
+ scrollSpy?: ScrollSpyOptions;
308
368
  viewTransitions?: boolean;
309
369
  }
310
370
  declare function RouterProvider(props: ParentProps<RouteProviderProps>): JSX.Element;
Binary file
@@ -1,10 +1,11 @@
1
- import type { ScrollRestorationOptions } from "./dom-utils";
1
+ import type { ScrollRestorationOptions, ScrollSpyOptions } from "./dom-utils";
2
2
  import type { Router } from "@real-router/core";
3
3
  import type { ParentProps, JSX } from "solid-js";
4
4
  export interface RouteProviderProps {
5
5
  router: Router;
6
6
  announceNavigation?: boolean;
7
7
  scrollRestoration?: ScrollRestorationOptions;
8
+ scrollSpy?: ScrollSpyOptions;
8
9
  viewTransitions?: boolean;
9
10
  }
10
11
  export declare function isRouteActive(linkRouteName: string, currentRouteName: string): boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"RouterProvider.d.ts","sourceRoot":"","sources":["../../src/RouterProvider.tsx"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,aAAa,CAAC;AAC5D,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,KAAK,EAAE,WAAW,EAAE,GAAG,EAAE,MAAM,UAAU,CAAC;AAEjD,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,iBAAiB,CAAC,EAAE,wBAAwB,CAAC;IAC7C,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B;AAED,wBAAgB,aAAa,CAC3B,aAAa,EAAE,MAAM,EACrB,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAKT;AAwBD,wBAAgB,cAAc,CAC5B,KAAK,EAAE,WAAW,CAAC,kBAAkB,CAAC,GACrC,GAAG,CAAC,OAAO,CAuCb"}
1
+ {"version":3,"file":"RouterProvider.d.ts","sourceRoot":"","sources":["../../src/RouterProvider.tsx"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,wBAAwB,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAC9E,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,KAAK,EAAE,WAAW,EAAE,GAAG,EAAE,MAAM,UAAU,CAAC;AAEjD,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,iBAAiB,CAAC,EAAE,wBAAwB,CAAC;IAC7C,SAAS,CAAC,EAAE,gBAAgB,CAAC;IAC7B,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B;AAED,wBAAgB,aAAa,CAC3B,aAAa,EAAE,MAAM,EACrB,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAKT;AAwBD,wBAAgB,cAAc,CAC5B,KAAK,EAAE,WAAW,CAAC,kBAAkB,CAAC,GACrC,GAAG,CAAC,OAAO,CAoDb"}
@@ -1,10 +1,12 @@
1
1
  export { createDirectionTracker } from "./direction-tracker.js";
2
2
  export { createRouteAnnouncer } from "./route-announcer.js";
3
3
  export { createScrollRestoration } from "./scroll-restore.js";
4
+ export { createScrollSpy } from "./scroll-spy.js";
4
5
  export { createViewTransitions } from "./view-transitions.js";
5
6
  export { shouldNavigate, buildHref, buildActiveClassName, navigateWithHash, shallowEqual, applyLinkA11y, } from "./link-utils.js";
6
7
  export type { RouteAnnouncerOptions } from "./route-announcer.js";
7
8
  export type { ScrollRestorationOptions, ScrollRestorationMode, } from "./scroll-restore.js";
9
+ export type { ScrollSpy, ScrollSpyOptions } from "./scroll-spy.js";
8
10
  export type { DirectionTracker } from "./direction-tracker.js";
9
11
  export type { ViewTransitions } from "./view-transitions.js";
10
12
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/dom-utils/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAC;AAEhE,OAAO,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAE5D,OAAO,EAAE,uBAAuB,EAAE,MAAM,qBAAqB,CAAC;AAE9D,OAAO,EAAE,qBAAqB,EAAE,MAAM,uBAAuB,CAAC;AAE9D,OAAO,EACL,cAAc,EACd,SAAS,EACT,oBAAoB,EACpB,gBAAgB,EAChB,YAAY,EACZ,aAAa,GACd,MAAM,iBAAiB,CAAC;AAEzB,YAAY,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAElE,YAAY,EACV,wBAAwB,EACxB,qBAAqB,GACtB,MAAM,qBAAqB,CAAC;AAE7B,YAAY,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAE/D,YAAY,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/dom-utils/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAC;AAEhE,OAAO,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAE5D,OAAO,EAAE,uBAAuB,EAAE,MAAM,qBAAqB,CAAC;AAE9D,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAElD,OAAO,EAAE,qBAAqB,EAAE,MAAM,uBAAuB,CAAC;AAE9D,OAAO,EACL,cAAc,EACd,SAAS,EACT,oBAAoB,EACpB,gBAAgB,EAChB,YAAY,EACZ,aAAa,GACd,MAAM,iBAAiB,CAAC;AAEzB,YAAY,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAElE,YAAY,EACV,wBAAwB,EACxB,qBAAqB,GACtB,MAAM,qBAAqB,CAAC;AAE7B,YAAY,EAAE,SAAS,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAEnE,YAAY,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAE/D,YAAY,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"scroll-restore.d.ts","sourceRoot":"","sources":["../../../src/dom-utils/scroll-restore.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AAUvD,MAAM,MAAM,qBAAqB,GAAG,SAAS,GAAG,KAAK,GAAG,QAAQ,CAAC;AAEjE,MAAM,WAAW,wBAAwB;IACvC,IAAI,CAAC,EAAE,qBAAqB,GAAG,SAAS,CAAC;IACzC,eAAe,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACtC,eAAe,CAAC,EAAE,CAAC,MAAM,WAAW,GAAG,IAAI,CAAC,GAAG,SAAS,CAAC;IACzD;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,EAAE,cAAc,GAAG,SAAS,CAAC;IACtC;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACjC;AAOD,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,MAAM,EACd,OAAO,CAAC,EAAE,wBAAwB,GACjC;IAAE,OAAO,EAAE,MAAM,IAAI,CAAA;CAAE,CAgQzB;AA2BD,wBAAgB,KAAK,CAAC,KAAK,EAAE,KAAK,GAAG,MAAM,CAY1C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAEpD"}
1
+ {"version":3,"file":"scroll-restore.d.ts","sourceRoot":"","sources":["../../../src/dom-utils/scroll-restore.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AAkBvD,MAAM,MAAM,qBAAqB,GAAG,SAAS,GAAG,KAAK,GAAG,QAAQ,CAAC;AAEjE,MAAM,WAAW,wBAAwB;IACvC,IAAI,CAAC,EAAE,qBAAqB,GAAG,SAAS,CAAC;IACzC,eAAe,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACtC,eAAe,CAAC,EAAE,CAAC,MAAM,WAAW,GAAG,IAAI,CAAC,GAAG,SAAS,CAAC;IACzD;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,EAAE,cAAc,GAAG,SAAS,CAAC;IACtC;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACjC;AAOD,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,MAAM,EACd,OAAO,CAAC,EAAE,wBAAwB,GACjC;IAAE,OAAO,EAAE,MAAM,IAAI,CAAA;CAAE,CA+UzB;AA2BD,wBAAgB,KAAK,CAAC,KAAK,EAAE,KAAK,GAAG,MAAM,CAY1C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAEpD"}
@@ -0,0 +1,65 @@
1
+ import type { Router } from "@real-router/core";
2
+ /**
3
+ * Router-coordinated scroll spy (#575).
4
+ *
5
+ * On `IntersectionObserver` notifications the utility picks the topmost
6
+ * visible anchor inside the configured scroll container and emits a forced
7
+ * same-route transition with `{ hash, replace: true, force: true, hashChange:
8
+ * true }` through `router.navigate(...)`. The URL plugin
9
+ * (`@real-router/browser-plugin` or `@real-router/navigation-plugin`) updates
10
+ * `state.context.url.hash` so sibling hash-aware `<Link hash>` re-highlights
11
+ * via the standard `createActiveRouteSource` pipeline.
12
+ *
13
+ * **Anti-flicker gates** (RFC §5.2):
14
+ * 1. `getTransitionSource(router).getSnapshot().isTransitioning` — skip emits
15
+ * while a transition is in-flight (re-entrant lock).
16
+ * 2. `coolingDown` — set on a user-driven hash transition (e.g. `<Link hash>`
17
+ * click + smooth `scrollIntoView`). Cleared on `scrollend` or after a
18
+ * 500ms safety timeout. Spy's own emits are excluded via the synchronous
19
+ * `selfEmitting` flag — required so the spy doesn't rate-limit itself.
20
+ *
21
+ * **Self-healing** (RFC §7.3): if the initial URL contains a hash without a
22
+ * matching `id` (e.g. `/page#nonexistent`), the first IO event emitted right
23
+ * after observe()-ing picks the topmost real anchor and corrects the URL.
24
+ *
25
+ * **Hash-only transition pipeline cost** (RFC §5.3): for same-route same-
26
+ * params hash-only navigations, `getTransitionPath` returns empty
27
+ * `toDeactivate` / `toActivate` arrays, so `runGuards` is a no-op. The only
28
+ * work is the URL plugin's `onTransitionSuccess` write and the
29
+ * `getTransitionSource` flip — cheap.
30
+ *
31
+ * **Architecture**: decomposed into 4 private subsystem closure factories
32
+ * (`createUrlPluginDetector`, `createCooldown`, `createDebouncer`,
33
+ * `createObserverPair`). The main `createScrollSpy` wires them together
34
+ * around the shared `silenced` / `destroyed` / `selfEmitting` flags and the
35
+ * `flush()` emit logic. Each subsystem owns its state + cleanup; `destroy()`
36
+ * delegates to each. See section banners below.
37
+ *
38
+ * @returns A `ScrollSpy` handle whose `destroy()` is idempotent.
39
+ */
40
+ export interface ScrollSpyOptions {
41
+ /**
42
+ * CSS selector for anchor candidates. Empty string `""` or `undefined`
43
+ * disables the spy (returns a NOOP handle). Common values:
44
+ * `"[id]"`, `"[id]:is(h1,h2,h3)"`, `"section[id]"`.
45
+ */
46
+ selector: string;
47
+ /**
48
+ * `IntersectionObserver` `rootMargin`. Default
49
+ * `"-20% 0px -60% 0px"` — an anchor is considered "active" once it crosses
50
+ * into the top 20 % of the viewport (or scroll container).
51
+ */
52
+ rootMargin?: string | undefined;
53
+ /**
54
+ * Lazy getter for the scrollable container. Resolved on every event.
55
+ * `null` (or missing getter) falls back to the window viewport
56
+ * (`root: null` on the `IntersectionObserver`).
57
+ */
58
+ scrollContainer?: (() => HTMLElement | null) | undefined;
59
+ }
60
+ export interface ScrollSpy {
61
+ /** Tear down observer + listeners. Idempotent. */
62
+ destroy: () => void;
63
+ }
64
+ export declare function createScrollSpy(router: Router, options: ScrollSpyOptions): ScrollSpy;
65
+ //# sourceMappingURL=scroll-spy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scroll-spy.d.ts","sourceRoot":"","sources":["../../../src/dom-utils/scroll-spy.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAqB,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAEnE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,MAAM,WAAW,gBAAgB;IAC/B;;;;OAIG;IACH,QAAQ,EAAE,MAAM,CAAC;IAEjB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAEhC;;;;OAIG;IACH,eAAe,CAAC,EAAE,CAAC,MAAM,WAAW,GAAG,IAAI,CAAC,GAAG,SAAS,CAAC;CAC1D;AAED,MAAM,WAAW,SAAS;IACxB,kDAAkD;IAClD,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAuaD,wBAAgB,eAAe,CAC7B,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,gBAAgB,GACxB,SAAS,CAiMX"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@real-router/solid",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "type": "commonjs",
5
5
  "description": "Solid.js integration for Real-Router",
6
6
  "main": "./dist/cjs/index.js",
@@ -67,27 +67,27 @@
67
67
  "license": "MIT",
68
68
  "sideEffects": false,
69
69
  "dependencies": {
70
- "@real-router/core": "^0.54.1",
70
+ "@real-router/core": "^0.55.0",
71
71
  "@real-router/route-utils": "^0.2.2",
72
- "@real-router/sources": "^0.8.3"
72
+ "@real-router/sources": "^0.8.4"
73
73
  },
74
74
  "devDependencies": {
75
- "@babel/core": "7.29.0",
76
- "@babel/preset-typescript": "7.28.5",
77
- "@rollup/plugin-babel": "7.0.0",
75
+ "@babel/core": "7.29.7",
76
+ "@babel/preset-typescript": "7.29.7",
77
+ "@rollup/plugin-babel": "7.1.0",
78
78
  "@rollup/plugin-node-resolve": "16.0.3",
79
79
  "@solidjs/testing-library": "0.8.10",
80
80
  "@testing-library/dom": "10.4.1",
81
81
  "@testing-library/jest-dom": "6.9.1",
82
82
  "@testing-library/user-event": "14.6.1",
83
- "babel-preset-solid": "1.9.3",
83
+ "babel-preset-solid": "1.9.12",
84
84
  "rimraf": "6.1.3",
85
- "rollup": "4.60.2",
85
+ "rollup": "4.61.0",
86
86
  "rollup-plugin-dts": "6.4.1",
87
87
  "solid-js": "1.9.12",
88
88
  "vite-plugin-solid": "2.11.11",
89
- "vitest": "4.1.6",
90
- "@real-router/browser-plugin": "^0.17.4"
89
+ "vitest": "4.1.8",
90
+ "@real-router/browser-plugin": "^0.17.5"
91
91
  },
92
92
  "peerDependencies": {
93
93
  "solid-js": ">=1.7.0"
@@ -7,10 +7,11 @@ import { createSignalFromSource } from "./createSignalFromSource";
7
7
  import {
8
8
  createRouteAnnouncer,
9
9
  createScrollRestoration,
10
+ createScrollSpy,
10
11
  createViewTransitions,
11
12
  } from "./dom-utils";
12
13
 
13
- import type { ScrollRestorationOptions } from "./dom-utils";
14
+ import type { ScrollRestorationOptions, ScrollSpyOptions } from "./dom-utils";
14
15
  import type { Router } from "@real-router/core";
15
16
  import type { ParentProps, JSX } from "solid-js";
16
17
 
@@ -18,6 +19,7 @@ export interface RouteProviderProps {
18
19
  router: Router;
19
20
  announceNavigation?: boolean;
20
21
  scrollRestoration?: ScrollRestorationOptions;
22
+ scrollSpy?: ScrollSpyOptions;
21
23
  viewTransitions?: boolean;
22
24
  }
23
25
 
@@ -81,6 +83,19 @@ export function RouterProvider(
81
83
  mountFeature(props.scrollRestoration, () =>
82
84
  createScrollRestoration(props.router, props.scrollRestoration),
83
85
  );
86
+ onMount(() => {
87
+ const spyOpts = props.scrollSpy;
88
+
89
+ if (spyOpts === undefined || spyOpts.selector === "") {
90
+ return;
91
+ }
92
+
93
+ const spy = createScrollSpy(props.router, spyOpts);
94
+
95
+ onCleanup(() => {
96
+ spy.destroy();
97
+ });
98
+ });
84
99
  mountFeature(props.viewTransitions, () =>
85
100
  createViewTransitions(props.router),
86
101
  );