@real-router/solid 0.9.1 → 0.11.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/README.md +10 -1
- package/dist/cjs/index.d.ts +29 -1
- package/dist/cjs/index.js +187 -41
- package/dist/esm/index.d.mts +29 -1
- package/dist/esm/index.mjs +187 -41
- package/dist/types/components/Link.d.ts.map +1 -1
- package/dist/types/dom-utils/index.d.ts +1 -1
- package/dist/types/dom-utils/index.d.ts.map +1 -1
- package/dist/types/dom-utils/link-utils.d.ts +18 -2
- package/dist/types/dom-utils/link-utils.d.ts.map +1 -1
- package/dist/types/dom-utils/scroll-restore.d.ts +22 -1
- package/dist/types/dom-utils/scroll-restore.d.ts.map +1 -1
- package/dist/types/types.d.ts +7 -0
- package/dist/types/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/components/Link.tsx +44 -9
- package/src/types.ts +7 -0
package/README.md
CHANGED
|
@@ -191,6 +191,15 @@ Navigation link with automatic active state detection. Uses `classList` for acti
|
|
|
191
191
|
</Link>
|
|
192
192
|
```
|
|
193
193
|
|
|
194
|
+
#### `hash` prop — URL fragment / tab-style UIs
|
|
195
|
+
|
|
196
|
+
```tsx
|
|
197
|
+
<Link routeName="settings" hash="profile">Profile</Link>
|
|
198
|
+
<Link routeName="settings" hash="account">Account</Link>
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
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.
|
|
202
|
+
|
|
194
203
|
### `<RouteView>`
|
|
195
204
|
|
|
196
205
|
Declarative route matching. Renders the first matching `<RouteView.Match>` child.
|
|
@@ -363,7 +372,7 @@ Opt-in preservation of scroll position across navigations:
|
|
|
363
372
|
</RouterProvider>
|
|
364
373
|
```
|
|
365
374
|
|
|
366
|
-
Restores scroll on back/forward, scrolls to top (or `#hash`) on push. Three modes: `"restore"` (default), `"top"`, `"
|
|
375
|
+
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). See [Scroll Restoration guide](https://github.com/greydragon888/real-router/wiki/Scroll-Restoration) for details.
|
|
367
376
|
|
|
368
377
|
## View Transitions
|
|
369
378
|
|
package/dist/cjs/index.d.ts
CHANGED
|
@@ -61,6 +61,13 @@ interface LinkProps<P extends Params = Params> extends Omit<JSX.HTMLAttributes<H
|
|
|
61
61
|
activeClassName?: string;
|
|
62
62
|
activeStrict?: boolean;
|
|
63
63
|
ignoreQueryParams?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* URL fragment (decoded form, no leading "#") (#532).
|
|
66
|
+
* - omitted/`undefined` → preserve current fragment on same-route navigation
|
|
67
|
+
* - `""` → clear fragment
|
|
68
|
+
* - non-empty → set fragment
|
|
69
|
+
*/
|
|
70
|
+
hash?: string;
|
|
64
71
|
target?: string;
|
|
65
72
|
onClick?: (evt: MouseEvent) => void;
|
|
66
73
|
}
|
|
@@ -266,11 +273,32 @@ interface UseRouteEnterOptions {
|
|
|
266
273
|
*/
|
|
267
274
|
declare function useRouteEnter(handler: RouteEnterHandler, options?: UseRouteEnterOptions): void;
|
|
268
275
|
|
|
269
|
-
type ScrollRestorationMode = "restore" | "top" | "
|
|
276
|
+
type ScrollRestorationMode = "restore" | "top" | "native";
|
|
270
277
|
interface ScrollRestorationOptions {
|
|
271
278
|
mode?: ScrollRestorationMode | undefined;
|
|
272
279
|
anchorScrolling?: boolean | undefined;
|
|
273
280
|
scrollContainer?: (() => HTMLElement | null) | undefined;
|
|
281
|
+
/**
|
|
282
|
+
* Scroll behavior passed to `scrollTo({ behavior })` and
|
|
283
|
+
* `scrollIntoView({ behavior })`.
|
|
284
|
+
*
|
|
285
|
+
* - `"auto"` (default) — browser-defined, usually instant.
|
|
286
|
+
* - `"instant"` — explicit instant jump (no animation).
|
|
287
|
+
* - `"smooth"` — animated transition. Note: smooth restore on back/traverse
|
|
288
|
+
* can feel disorienting if the user expects to land at the saved position
|
|
289
|
+
* immediately. Recommended for `mode: "top"` or anchor scroll only.
|
|
290
|
+
*
|
|
291
|
+
* See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/ScrollToOptions/behavior).
|
|
292
|
+
*/
|
|
293
|
+
behavior?: ScrollBehavior | undefined;
|
|
294
|
+
/**
|
|
295
|
+
* sessionStorage key used to persist saved scroll positions. Default:
|
|
296
|
+
* `"real-router:scroll"`. Override only when multiple independent
|
|
297
|
+
* `RouterProvider` instances share the same document and you need to
|
|
298
|
+
* isolate their scroll stores (e.g. micro-frontends, embedded widgets,
|
|
299
|
+
* or testing). For a single app with one provider the default is fine.
|
|
300
|
+
*/
|
|
301
|
+
storageKey?: string | undefined;
|
|
274
302
|
}
|
|
275
303
|
|
|
276
304
|
interface RouteProviderProps {
|
package/dist/cjs/index.js
CHANGED
|
@@ -336,7 +336,7 @@ function manageFocus(h1) {
|
|
|
336
336
|
});
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
-
const
|
|
339
|
+
const DEFAULT_STORAGE_KEY = "real-router:scroll";
|
|
340
340
|
const NOOP_INSTANCE$1 = Object.freeze({
|
|
341
341
|
destroy: () => {
|
|
342
342
|
/* no-op */
|
|
@@ -348,14 +348,36 @@ function createScrollRestoration(router, options) {
|
|
|
348
348
|
}
|
|
349
349
|
const mode = options?.mode ?? "restore";
|
|
350
350
|
|
|
351
|
-
// mode "
|
|
352
|
-
// don't subscribe, don't register pagehide —
|
|
353
|
-
//
|
|
354
|
-
|
|
351
|
+
// mode "native" = utility does nothing. Don't flip history.scrollRestoration,
|
|
352
|
+
// don't subscribe, don't register pagehide — `history.scrollRestoration`
|
|
353
|
+
// stays at the browser default ("auto") so the browser handles scroll
|
|
354
|
+
// restore natively. (Note: this is the OPPOSITE of `history.scrollRestoration
|
|
355
|
+
// === "manual"` — utility's "native" leaves the DOM property at "auto" so
|
|
356
|
+
// the browser is in charge.)
|
|
357
|
+
if (mode === "native") {
|
|
355
358
|
return NOOP_INSTANCE$1;
|
|
356
359
|
}
|
|
357
360
|
const anchorEnabled = options?.anchorScrolling ?? true;
|
|
358
361
|
const getContainer = options?.scrollContainer;
|
|
362
|
+
const behavior = options?.behavior ?? "auto";
|
|
363
|
+
const storageKey = options?.storageKey ?? DEFAULT_STORAGE_KEY;
|
|
364
|
+
const loadStore = () => {
|
|
365
|
+
try {
|
|
366
|
+
const raw = sessionStorage.getItem(storageKey);
|
|
367
|
+
return raw ? JSON.parse(raw) : {};
|
|
368
|
+
} catch {
|
|
369
|
+
return {};
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
const putPos = (key, pos) => {
|
|
373
|
+
try {
|
|
374
|
+
const store = loadStore();
|
|
375
|
+
store[key] = pos;
|
|
376
|
+
sessionStorage.setItem(storageKey, JSON.stringify(store));
|
|
377
|
+
} catch {
|
|
378
|
+
// Ignore quota / security errors.
|
|
379
|
+
}
|
|
380
|
+
};
|
|
359
381
|
const prevScrollRestoration = history.scrollRestoration;
|
|
360
382
|
try {
|
|
361
383
|
history.scrollRestoration = "manual";
|
|
@@ -373,17 +395,46 @@ function createScrollRestoration(router, options) {
|
|
|
373
395
|
const writePos = top => {
|
|
374
396
|
const element = getContainer?.();
|
|
375
397
|
if (element) {
|
|
376
|
-
element.
|
|
398
|
+
element.scrollTo({
|
|
399
|
+
top,
|
|
400
|
+
left: 0,
|
|
401
|
+
behavior
|
|
402
|
+
});
|
|
377
403
|
} else {
|
|
378
|
-
globalThis.scrollTo(
|
|
404
|
+
globalThis.scrollTo({
|
|
405
|
+
top,
|
|
406
|
+
left: 0,
|
|
407
|
+
behavior
|
|
408
|
+
});
|
|
379
409
|
}
|
|
380
410
|
};
|
|
381
|
-
const scrollToHashOrTop =
|
|
411
|
+
const scrollToHashOrTop = route => {
|
|
412
|
+
// URL plugin path (#532): `state.context.url.hash` is the source of truth
|
|
413
|
+
// when one of the URL plugins (browser-plugin / navigation-plugin) is
|
|
414
|
+
// installed. The value is already DECODED — feeding it through
|
|
415
|
+
// `decodeURIComponent` again would throw on a bare `%`.
|
|
416
|
+
const ctxHash = route.context?.url?.hash;
|
|
417
|
+
if (ctxHash !== undefined) {
|
|
418
|
+
if (anchorEnabled && ctxHash.length > 0) {
|
|
419
|
+
// eslint-disable-next-line unicorn/prefer-query-selector -- ids may contain CSS-unsafe chars
|
|
420
|
+
const element = document.getElementById(ctxHash);
|
|
421
|
+
if (element) {
|
|
422
|
+
element.scrollIntoView({
|
|
423
|
+
behavior
|
|
424
|
+
});
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
writePos(0);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Fallback path: no URL plugin, read the DOM. `location.hash` is
|
|
433
|
+
// percent-encoded; ids in the DOM are the raw string, so decode for the
|
|
434
|
+
// match. Fall back to the raw slice if the hash contains a malformed
|
|
435
|
+
// escape sequence (decodeURIComponent throws on those).
|
|
382
436
|
const hash = globalThis.location.hash;
|
|
383
437
|
if (anchorEnabled && hash.length > 1) {
|
|
384
|
-
// location.hash is percent-encoded; ids in the DOM are the raw string.
|
|
385
|
-
// Decode for the match. Fall back to the raw slice if the hash contains
|
|
386
|
-
// a malformed escape sequence (decodeURIComponent throws on those).
|
|
387
438
|
let id;
|
|
388
439
|
try {
|
|
389
440
|
id = decodeURIComponent(hash.slice(1));
|
|
@@ -394,7 +445,9 @@ function createScrollRestoration(router, options) {
|
|
|
394
445
|
// eslint-disable-next-line unicorn/prefer-query-selector -- ids may contain CSS-unsafe chars
|
|
395
446
|
const element = document.getElementById(id);
|
|
396
447
|
if (element) {
|
|
397
|
-
element.scrollIntoView(
|
|
448
|
+
element.scrollIntoView({
|
|
449
|
+
behavior
|
|
450
|
+
});
|
|
398
451
|
return;
|
|
399
452
|
}
|
|
400
453
|
}
|
|
@@ -421,7 +474,7 @@ function createScrollRestoration(router, options) {
|
|
|
421
474
|
return;
|
|
422
475
|
}
|
|
423
476
|
if (mode === "top" || !nav) {
|
|
424
|
-
scrollToHashOrTop();
|
|
477
|
+
scrollToHashOrTop(route);
|
|
425
478
|
return;
|
|
426
479
|
}
|
|
427
480
|
if (nav.navigationType === "replace") {
|
|
@@ -431,7 +484,7 @@ function createScrollRestoration(router, options) {
|
|
|
431
484
|
writePos(loadStore()[keyOf(route)] ?? 0);
|
|
432
485
|
return;
|
|
433
486
|
}
|
|
434
|
-
scrollToHashOrTop();
|
|
487
|
+
scrollToHashOrTop(route);
|
|
435
488
|
});
|
|
436
489
|
});
|
|
437
490
|
const onPageHide = () => {
|
|
@@ -460,23 +513,6 @@ function createScrollRestoration(router, options) {
|
|
|
460
513
|
function keyOf(state) {
|
|
461
514
|
return `${state.name}:${canonicalJson(state.params)}`;
|
|
462
515
|
}
|
|
463
|
-
function loadStore() {
|
|
464
|
-
try {
|
|
465
|
-
const raw = sessionStorage.getItem(STORAGE_KEY);
|
|
466
|
-
return raw ? JSON.parse(raw) : {};
|
|
467
|
-
} catch {
|
|
468
|
-
return {};
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
function putPos(key, pos) {
|
|
472
|
-
try {
|
|
473
|
-
const store = loadStore();
|
|
474
|
-
store[key] = pos;
|
|
475
|
-
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(store));
|
|
476
|
-
} catch {
|
|
477
|
-
// Ignore quota / security errors.
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
516
|
function canonicalJson(value) {
|
|
481
517
|
return JSON.stringify(value, canonicalReplacer);
|
|
482
518
|
}
|
|
@@ -618,21 +654,94 @@ function createViewTransitions(router) {
|
|
|
618
654
|
function shouldNavigate(evt) {
|
|
619
655
|
return evt.button === 0 && !evt.metaKey && !evt.altKey && !evt.ctrlKey && !evt.shiftKey;
|
|
620
656
|
}
|
|
621
|
-
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* RFC 3986 fragment encoding: preserve sub-delims (`&`, `=`, `?`, `:`),
|
|
660
|
+
* encode space, `%`, control chars, non-ASCII via encodeURI; defensively
|
|
661
|
+
* escape `#` (encodeURI does not). Mirrors `encodeHashFragment` in
|
|
662
|
+
* `shared/browser-env/url-context.ts` — duplicated here because the
|
|
663
|
+
* shared/dom-utils symlink graph does not reach shared/browser-env.
|
|
664
|
+
*/
|
|
665
|
+
function encodeFragmentInline(decoded) {
|
|
666
|
+
return encodeURI(decoded).replaceAll("#", "%23");
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Builds an href for a `<Link>` element.
|
|
670
|
+
*
|
|
671
|
+
* - Prefers the URL plugin's `buildUrl` (browser-plugin, navigation-plugin,
|
|
672
|
+
* hash-plugin) when present.
|
|
673
|
+
* - Falls back to `router.buildPath` for runtimes without a URL plugin
|
|
674
|
+
* (memory-plugin, console UIs, NativeScript). In that fallback the hash
|
|
675
|
+
* is appended manually so the rendered href is still correct.
|
|
676
|
+
* - The optional 4th argument is an options object so the contract stays
|
|
677
|
+
* extensible. The `hash` option is a decoded fragment without leading "#";
|
|
678
|
+
* `<Link hash="#section">` is accepted defensively (leading "#" stripped).
|
|
679
|
+
* Frozen API: previous 3-arg call sites continue to work unchanged.
|
|
680
|
+
*/
|
|
681
|
+
function buildHref(router, routeName, routeParams, options) {
|
|
622
682
|
try {
|
|
683
|
+
const rawHash = options?.hash;
|
|
684
|
+
let normHash;
|
|
685
|
+
if (rawHash !== undefined) {
|
|
686
|
+
normHash = rawHash.startsWith("#") ? rawHash.slice(1) : rawHash;
|
|
687
|
+
}
|
|
623
688
|
const buildUrl = router.buildUrl;
|
|
624
689
|
if (buildUrl) {
|
|
625
|
-
const url = buildUrl(routeName, routeParams
|
|
690
|
+
const url = buildUrl(routeName, routeParams, normHash === undefined ? undefined : {
|
|
691
|
+
hash: normHash
|
|
692
|
+
});
|
|
626
693
|
if (url !== undefined) {
|
|
627
694
|
return url;
|
|
628
695
|
}
|
|
629
696
|
}
|
|
630
|
-
|
|
697
|
+
const path = router.buildPath(routeName, routeParams);
|
|
698
|
+
return normHash ? `${path}#${encodeFragmentInline(normHash)}` : path;
|
|
631
699
|
} catch {
|
|
632
700
|
console.error(`[real-router] Route "${routeName}" is not defined. The element will render without an href attribute.`);
|
|
633
701
|
return undefined;
|
|
634
702
|
}
|
|
635
703
|
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* `<Link>` click-handler navigation helper (#532).
|
|
707
|
+
*
|
|
708
|
+
* Wraps `router.navigate(name, params, opts)` with same-route different-hash
|
|
709
|
+
* detection: when the consumer clicks a hash-bearing Link that targets the
|
|
710
|
+
* current route with the same params but a different fragment, core's
|
|
711
|
+
* SAME_STATES check would otherwise reject the navigation. The helper adds
|
|
712
|
+
* `force: true` and `hashChange: true` automatically — subscribers can then
|
|
713
|
+
* disambiguate via `state.context.url.hashChanged`.
|
|
714
|
+
*
|
|
715
|
+
* For pure programmatic same-route hash-only navigation, callers are
|
|
716
|
+
* documented to pass `{ force: true }` themselves; the auto-bypass here is
|
|
717
|
+
* a UX convenience for `<Link hash>` that all 6 framework adapters share.
|
|
718
|
+
*/
|
|
719
|
+
/**
|
|
720
|
+
* Local extended-options type. Adapters that depend only on `@real-router/core`
|
|
721
|
+
* (without a URL plugin) do not see the `NavigationOptions` augmentation that
|
|
722
|
+
* declares `hash` / `hashChange`. Casting to this widened type inside the
|
|
723
|
+
* helper keeps shared/dom-utils self-contained — adapters do not need to
|
|
724
|
+
* augment NavigationOptions themselves to consume `<Link hash>`.
|
|
725
|
+
*/
|
|
726
|
+
|
|
727
|
+
function navigateWithHash(router, routeName, routeParams, hash, extraOptions) {
|
|
728
|
+
const opts = {
|
|
729
|
+
...extraOptions
|
|
730
|
+
};
|
|
731
|
+
if (hash !== undefined) {
|
|
732
|
+
opts.hash = hash;
|
|
733
|
+
}
|
|
734
|
+
const current = router.getState();
|
|
735
|
+
if (current?.name === routeName && shallowEqual(current.params, routeParams)) {
|
|
736
|
+
const currentHash = current.context?.url?.hash ?? "";
|
|
737
|
+
const newHash = hash ?? currentHash;
|
|
738
|
+
if (currentHash !== newHash) {
|
|
739
|
+
opts.force = true;
|
|
740
|
+
opts.hashChange = true;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
return router.navigate(routeName, routeParams, opts);
|
|
744
|
+
}
|
|
636
745
|
function parseTokens(value) {
|
|
637
746
|
return value ? value.match(/\S+/g) ?? [] : [];
|
|
638
747
|
}
|
|
@@ -657,6 +766,26 @@ function buildActiveClassName(isActive, activeClassName, baseClassName) {
|
|
|
657
766
|
}
|
|
658
767
|
return baseClassName ?? undefined;
|
|
659
768
|
}
|
|
769
|
+
function shallowEqual(prev, next) {
|
|
770
|
+
if (Object.is(prev, next)) {
|
|
771
|
+
return true;
|
|
772
|
+
}
|
|
773
|
+
if (!prev || !next) {
|
|
774
|
+
return false;
|
|
775
|
+
}
|
|
776
|
+
const prevKeys = Object.keys(prev);
|
|
777
|
+
if (prevKeys.length !== Object.keys(next).length) {
|
|
778
|
+
return false;
|
|
779
|
+
}
|
|
780
|
+
const prevRecord = prev;
|
|
781
|
+
const nextRecord = next;
|
|
782
|
+
for (const key of prevKeys) {
|
|
783
|
+
if (!Object.is(prevRecord[key], nextRecord[key])) {
|
|
784
|
+
return false;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
return true;
|
|
788
|
+
}
|
|
660
789
|
function applyLinkA11y(element) {
|
|
661
790
|
if (!element) {
|
|
662
791
|
return;
|
|
@@ -681,18 +810,35 @@ function Link(props) {
|
|
|
681
810
|
activeStrict: false,
|
|
682
811
|
ignoreQueryParams: true
|
|
683
812
|
}, props);
|
|
684
|
-
const [local, rest] = solidJs.splitProps(merged, ["routeName", "routeParams", "routeOptions", "activeClassName", "activeStrict", "ignoreQueryParams", "onClick", "target", "class", "children"]);
|
|
813
|
+
const [local, rest] = solidJs.splitProps(merged, ["routeName", "routeParams", "routeOptions", "activeClassName", "activeStrict", "ignoreQueryParams", "hash", "onClick", "target", "class", "children"]);
|
|
685
814
|
const ctx = solidJs.useContext(RouterContext);
|
|
686
815
|
if (!ctx) {
|
|
687
816
|
throw new Error("Link must be used within a RouterProvider");
|
|
688
817
|
}
|
|
689
818
|
const router = ctx.router;
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
819
|
+
|
|
820
|
+
// Hash-aware active state (#532). `routeSelector` (the O(1) shared selector)
|
|
821
|
+
// doesn't know about hash — when `hash` prop is set, fall back to the slow
|
|
822
|
+
// path so the source's hash comparison kicks in. Tab-style UI is opt-in via
|
|
823
|
+
// the prop, so the fast path stays open for the typical Link case.
|
|
824
|
+
const useFastPath = local.hash === undefined && !local.activeStrict && local.ignoreQueryParams && local.routeParams === EMPTY_PARAMS;
|
|
825
|
+
const buildActiveOptions = () => {
|
|
826
|
+
const base = {
|
|
827
|
+
strict: local.activeStrict,
|
|
828
|
+
ignoreQueryParams: local.ignoreQueryParams
|
|
829
|
+
};
|
|
830
|
+
if (local.hash === undefined) {
|
|
831
|
+
return base;
|
|
832
|
+
}
|
|
833
|
+
return {
|
|
834
|
+
...base,
|
|
835
|
+
hash: local.hash
|
|
836
|
+
};
|
|
837
|
+
};
|
|
838
|
+
const isActive = useFastPath ? () => ctx.routeSelector(local.routeName) : createSignalFromSource(sources.createActiveRouteSource(router, local.routeName, local.routeParams, buildActiveOptions()));
|
|
839
|
+
const href = solidJs.createMemo(() => buildHref(router, local.routeName, local.routeParams, local.hash === undefined ? undefined : {
|
|
840
|
+
hash: local.hash
|
|
694
841
|
}));
|
|
695
|
-
const href = solidJs.createMemo(() => buildHref(router, local.routeName, local.routeParams));
|
|
696
842
|
const handleClick = evt => {
|
|
697
843
|
if (local.onClick) {
|
|
698
844
|
local.onClick(evt);
|
|
@@ -704,7 +850,7 @@ function Link(props) {
|
|
|
704
850
|
return;
|
|
705
851
|
}
|
|
706
852
|
evt.preventDefault();
|
|
707
|
-
router
|
|
853
|
+
navigateWithHash(router, local.routeName, local.routeParams, local.hash, local.routeOptions).catch(() => {});
|
|
708
854
|
};
|
|
709
855
|
const finalClassName = solidJs.createMemo(() => buildActiveClassName(isActive(), local.activeClassName, local.class));
|
|
710
856
|
return (() => {
|
package/dist/esm/index.d.mts
CHANGED
|
@@ -61,6 +61,13 @@ interface LinkProps<P extends Params = Params> extends Omit<JSX.HTMLAttributes<H
|
|
|
61
61
|
activeClassName?: string;
|
|
62
62
|
activeStrict?: boolean;
|
|
63
63
|
ignoreQueryParams?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* URL fragment (decoded form, no leading "#") (#532).
|
|
66
|
+
* - omitted/`undefined` → preserve current fragment on same-route navigation
|
|
67
|
+
* - `""` → clear fragment
|
|
68
|
+
* - non-empty → set fragment
|
|
69
|
+
*/
|
|
70
|
+
hash?: string;
|
|
64
71
|
target?: string;
|
|
65
72
|
onClick?: (evt: MouseEvent) => void;
|
|
66
73
|
}
|
|
@@ -266,11 +273,32 @@ interface UseRouteEnterOptions {
|
|
|
266
273
|
*/
|
|
267
274
|
declare function useRouteEnter(handler: RouteEnterHandler, options?: UseRouteEnterOptions): void;
|
|
268
275
|
|
|
269
|
-
type ScrollRestorationMode = "restore" | "top" | "
|
|
276
|
+
type ScrollRestorationMode = "restore" | "top" | "native";
|
|
270
277
|
interface ScrollRestorationOptions {
|
|
271
278
|
mode?: ScrollRestorationMode | undefined;
|
|
272
279
|
anchorScrolling?: boolean | undefined;
|
|
273
280
|
scrollContainer?: (() => HTMLElement | null) | undefined;
|
|
281
|
+
/**
|
|
282
|
+
* Scroll behavior passed to `scrollTo({ behavior })` and
|
|
283
|
+
* `scrollIntoView({ behavior })`.
|
|
284
|
+
*
|
|
285
|
+
* - `"auto"` (default) — browser-defined, usually instant.
|
|
286
|
+
* - `"instant"` — explicit instant jump (no animation).
|
|
287
|
+
* - `"smooth"` — animated transition. Note: smooth restore on back/traverse
|
|
288
|
+
* can feel disorienting if the user expects to land at the saved position
|
|
289
|
+
* immediately. Recommended for `mode: "top"` or anchor scroll only.
|
|
290
|
+
*
|
|
291
|
+
* See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/ScrollToOptions/behavior).
|
|
292
|
+
*/
|
|
293
|
+
behavior?: ScrollBehavior | undefined;
|
|
294
|
+
/**
|
|
295
|
+
* sessionStorage key used to persist saved scroll positions. Default:
|
|
296
|
+
* `"real-router:scroll"`. Override only when multiple independent
|
|
297
|
+
* `RouterProvider` instances share the same document and you need to
|
|
298
|
+
* isolate their scroll stores (e.g. micro-frontends, embedded widgets,
|
|
299
|
+
* or testing). For a single app with one provider the default is fine.
|
|
300
|
+
*/
|
|
301
|
+
storageKey?: string | undefined;
|
|
274
302
|
}
|
|
275
303
|
|
|
276
304
|
interface RouteProviderProps {
|
package/dist/esm/index.mjs
CHANGED
|
@@ -334,7 +334,7 @@ function manageFocus(h1) {
|
|
|
334
334
|
});
|
|
335
335
|
}
|
|
336
336
|
|
|
337
|
-
const
|
|
337
|
+
const DEFAULT_STORAGE_KEY = "real-router:scroll";
|
|
338
338
|
const NOOP_INSTANCE$1 = Object.freeze({
|
|
339
339
|
destroy: () => {
|
|
340
340
|
/* no-op */
|
|
@@ -346,14 +346,36 @@ function createScrollRestoration(router, options) {
|
|
|
346
346
|
}
|
|
347
347
|
const mode = options?.mode ?? "restore";
|
|
348
348
|
|
|
349
|
-
// mode "
|
|
350
|
-
// don't subscribe, don't register pagehide —
|
|
351
|
-
//
|
|
352
|
-
|
|
349
|
+
// mode "native" = utility does nothing. Don't flip history.scrollRestoration,
|
|
350
|
+
// don't subscribe, don't register pagehide — `history.scrollRestoration`
|
|
351
|
+
// stays at the browser default ("auto") so the browser handles scroll
|
|
352
|
+
// restore natively. (Note: this is the OPPOSITE of `history.scrollRestoration
|
|
353
|
+
// === "manual"` — utility's "native" leaves the DOM property at "auto" so
|
|
354
|
+
// the browser is in charge.)
|
|
355
|
+
if (mode === "native") {
|
|
353
356
|
return NOOP_INSTANCE$1;
|
|
354
357
|
}
|
|
355
358
|
const anchorEnabled = options?.anchorScrolling ?? true;
|
|
356
359
|
const getContainer = options?.scrollContainer;
|
|
360
|
+
const behavior = options?.behavior ?? "auto";
|
|
361
|
+
const storageKey = options?.storageKey ?? DEFAULT_STORAGE_KEY;
|
|
362
|
+
const loadStore = () => {
|
|
363
|
+
try {
|
|
364
|
+
const raw = sessionStorage.getItem(storageKey);
|
|
365
|
+
return raw ? JSON.parse(raw) : {};
|
|
366
|
+
} catch {
|
|
367
|
+
return {};
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
const putPos = (key, pos) => {
|
|
371
|
+
try {
|
|
372
|
+
const store = loadStore();
|
|
373
|
+
store[key] = pos;
|
|
374
|
+
sessionStorage.setItem(storageKey, JSON.stringify(store));
|
|
375
|
+
} catch {
|
|
376
|
+
// Ignore quota / security errors.
|
|
377
|
+
}
|
|
378
|
+
};
|
|
357
379
|
const prevScrollRestoration = history.scrollRestoration;
|
|
358
380
|
try {
|
|
359
381
|
history.scrollRestoration = "manual";
|
|
@@ -371,17 +393,46 @@ function createScrollRestoration(router, options) {
|
|
|
371
393
|
const writePos = top => {
|
|
372
394
|
const element = getContainer?.();
|
|
373
395
|
if (element) {
|
|
374
|
-
element.
|
|
396
|
+
element.scrollTo({
|
|
397
|
+
top,
|
|
398
|
+
left: 0,
|
|
399
|
+
behavior
|
|
400
|
+
});
|
|
375
401
|
} else {
|
|
376
|
-
globalThis.scrollTo(
|
|
402
|
+
globalThis.scrollTo({
|
|
403
|
+
top,
|
|
404
|
+
left: 0,
|
|
405
|
+
behavior
|
|
406
|
+
});
|
|
377
407
|
}
|
|
378
408
|
};
|
|
379
|
-
const scrollToHashOrTop =
|
|
409
|
+
const scrollToHashOrTop = route => {
|
|
410
|
+
// URL plugin path (#532): `state.context.url.hash` is the source of truth
|
|
411
|
+
// when one of the URL plugins (browser-plugin / navigation-plugin) is
|
|
412
|
+
// installed. The value is already DECODED — feeding it through
|
|
413
|
+
// `decodeURIComponent` again would throw on a bare `%`.
|
|
414
|
+
const ctxHash = route.context?.url?.hash;
|
|
415
|
+
if (ctxHash !== undefined) {
|
|
416
|
+
if (anchorEnabled && ctxHash.length > 0) {
|
|
417
|
+
// eslint-disable-next-line unicorn/prefer-query-selector -- ids may contain CSS-unsafe chars
|
|
418
|
+
const element = document.getElementById(ctxHash);
|
|
419
|
+
if (element) {
|
|
420
|
+
element.scrollIntoView({
|
|
421
|
+
behavior
|
|
422
|
+
});
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
writePos(0);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Fallback path: no URL plugin, read the DOM. `location.hash` is
|
|
431
|
+
// percent-encoded; ids in the DOM are the raw string, so decode for the
|
|
432
|
+
// match. Fall back to the raw slice if the hash contains a malformed
|
|
433
|
+
// escape sequence (decodeURIComponent throws on those).
|
|
380
434
|
const hash = globalThis.location.hash;
|
|
381
435
|
if (anchorEnabled && hash.length > 1) {
|
|
382
|
-
// location.hash is percent-encoded; ids in the DOM are the raw string.
|
|
383
|
-
// Decode for the match. Fall back to the raw slice if the hash contains
|
|
384
|
-
// a malformed escape sequence (decodeURIComponent throws on those).
|
|
385
436
|
let id;
|
|
386
437
|
try {
|
|
387
438
|
id = decodeURIComponent(hash.slice(1));
|
|
@@ -392,7 +443,9 @@ function createScrollRestoration(router, options) {
|
|
|
392
443
|
// eslint-disable-next-line unicorn/prefer-query-selector -- ids may contain CSS-unsafe chars
|
|
393
444
|
const element = document.getElementById(id);
|
|
394
445
|
if (element) {
|
|
395
|
-
element.scrollIntoView(
|
|
446
|
+
element.scrollIntoView({
|
|
447
|
+
behavior
|
|
448
|
+
});
|
|
396
449
|
return;
|
|
397
450
|
}
|
|
398
451
|
}
|
|
@@ -419,7 +472,7 @@ function createScrollRestoration(router, options) {
|
|
|
419
472
|
return;
|
|
420
473
|
}
|
|
421
474
|
if (mode === "top" || !nav) {
|
|
422
|
-
scrollToHashOrTop();
|
|
475
|
+
scrollToHashOrTop(route);
|
|
423
476
|
return;
|
|
424
477
|
}
|
|
425
478
|
if (nav.navigationType === "replace") {
|
|
@@ -429,7 +482,7 @@ function createScrollRestoration(router, options) {
|
|
|
429
482
|
writePos(loadStore()[keyOf(route)] ?? 0);
|
|
430
483
|
return;
|
|
431
484
|
}
|
|
432
|
-
scrollToHashOrTop();
|
|
485
|
+
scrollToHashOrTop(route);
|
|
433
486
|
});
|
|
434
487
|
});
|
|
435
488
|
const onPageHide = () => {
|
|
@@ -458,23 +511,6 @@ function createScrollRestoration(router, options) {
|
|
|
458
511
|
function keyOf(state) {
|
|
459
512
|
return `${state.name}:${canonicalJson(state.params)}`;
|
|
460
513
|
}
|
|
461
|
-
function loadStore() {
|
|
462
|
-
try {
|
|
463
|
-
const raw = sessionStorage.getItem(STORAGE_KEY);
|
|
464
|
-
return raw ? JSON.parse(raw) : {};
|
|
465
|
-
} catch {
|
|
466
|
-
return {};
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
function putPos(key, pos) {
|
|
470
|
-
try {
|
|
471
|
-
const store = loadStore();
|
|
472
|
-
store[key] = pos;
|
|
473
|
-
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(store));
|
|
474
|
-
} catch {
|
|
475
|
-
// Ignore quota / security errors.
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
514
|
function canonicalJson(value) {
|
|
479
515
|
return JSON.stringify(value, canonicalReplacer);
|
|
480
516
|
}
|
|
@@ -616,21 +652,94 @@ function createViewTransitions(router) {
|
|
|
616
652
|
function shouldNavigate(evt) {
|
|
617
653
|
return evt.button === 0 && !evt.metaKey && !evt.altKey && !evt.ctrlKey && !evt.shiftKey;
|
|
618
654
|
}
|
|
619
|
-
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* RFC 3986 fragment encoding: preserve sub-delims (`&`, `=`, `?`, `:`),
|
|
658
|
+
* encode space, `%`, control chars, non-ASCII via encodeURI; defensively
|
|
659
|
+
* escape `#` (encodeURI does not). Mirrors `encodeHashFragment` in
|
|
660
|
+
* `shared/browser-env/url-context.ts` — duplicated here because the
|
|
661
|
+
* shared/dom-utils symlink graph does not reach shared/browser-env.
|
|
662
|
+
*/
|
|
663
|
+
function encodeFragmentInline(decoded) {
|
|
664
|
+
return encodeURI(decoded).replaceAll("#", "%23");
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Builds an href for a `<Link>` element.
|
|
668
|
+
*
|
|
669
|
+
* - Prefers the URL plugin's `buildUrl` (browser-plugin, navigation-plugin,
|
|
670
|
+
* hash-plugin) when present.
|
|
671
|
+
* - Falls back to `router.buildPath` for runtimes without a URL plugin
|
|
672
|
+
* (memory-plugin, console UIs, NativeScript). In that fallback the hash
|
|
673
|
+
* is appended manually so the rendered href is still correct.
|
|
674
|
+
* - The optional 4th argument is an options object so the contract stays
|
|
675
|
+
* extensible. The `hash` option is a decoded fragment without leading "#";
|
|
676
|
+
* `<Link hash="#section">` is accepted defensively (leading "#" stripped).
|
|
677
|
+
* Frozen API: previous 3-arg call sites continue to work unchanged.
|
|
678
|
+
*/
|
|
679
|
+
function buildHref(router, routeName, routeParams, options) {
|
|
620
680
|
try {
|
|
681
|
+
const rawHash = options?.hash;
|
|
682
|
+
let normHash;
|
|
683
|
+
if (rawHash !== undefined) {
|
|
684
|
+
normHash = rawHash.startsWith("#") ? rawHash.slice(1) : rawHash;
|
|
685
|
+
}
|
|
621
686
|
const buildUrl = router.buildUrl;
|
|
622
687
|
if (buildUrl) {
|
|
623
|
-
const url = buildUrl(routeName, routeParams
|
|
688
|
+
const url = buildUrl(routeName, routeParams, normHash === undefined ? undefined : {
|
|
689
|
+
hash: normHash
|
|
690
|
+
});
|
|
624
691
|
if (url !== undefined) {
|
|
625
692
|
return url;
|
|
626
693
|
}
|
|
627
694
|
}
|
|
628
|
-
|
|
695
|
+
const path = router.buildPath(routeName, routeParams);
|
|
696
|
+
return normHash ? `${path}#${encodeFragmentInline(normHash)}` : path;
|
|
629
697
|
} catch {
|
|
630
698
|
console.error(`[real-router] Route "${routeName}" is not defined. The element will render without an href attribute.`);
|
|
631
699
|
return undefined;
|
|
632
700
|
}
|
|
633
701
|
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* `<Link>` click-handler navigation helper (#532).
|
|
705
|
+
*
|
|
706
|
+
* Wraps `router.navigate(name, params, opts)` with same-route different-hash
|
|
707
|
+
* detection: when the consumer clicks a hash-bearing Link that targets the
|
|
708
|
+
* current route with the same params but a different fragment, core's
|
|
709
|
+
* SAME_STATES check would otherwise reject the navigation. The helper adds
|
|
710
|
+
* `force: true` and `hashChange: true` automatically — subscribers can then
|
|
711
|
+
* disambiguate via `state.context.url.hashChanged`.
|
|
712
|
+
*
|
|
713
|
+
* For pure programmatic same-route hash-only navigation, callers are
|
|
714
|
+
* documented to pass `{ force: true }` themselves; the auto-bypass here is
|
|
715
|
+
* a UX convenience for `<Link hash>` that all 6 framework adapters share.
|
|
716
|
+
*/
|
|
717
|
+
/**
|
|
718
|
+
* Local extended-options type. Adapters that depend only on `@real-router/core`
|
|
719
|
+
* (without a URL plugin) do not see the `NavigationOptions` augmentation that
|
|
720
|
+
* declares `hash` / `hashChange`. Casting to this widened type inside the
|
|
721
|
+
* helper keeps shared/dom-utils self-contained — adapters do not need to
|
|
722
|
+
* augment NavigationOptions themselves to consume `<Link hash>`.
|
|
723
|
+
*/
|
|
724
|
+
|
|
725
|
+
function navigateWithHash(router, routeName, routeParams, hash, extraOptions) {
|
|
726
|
+
const opts = {
|
|
727
|
+
...extraOptions
|
|
728
|
+
};
|
|
729
|
+
if (hash !== undefined) {
|
|
730
|
+
opts.hash = hash;
|
|
731
|
+
}
|
|
732
|
+
const current = router.getState();
|
|
733
|
+
if (current?.name === routeName && shallowEqual(current.params, routeParams)) {
|
|
734
|
+
const currentHash = current.context?.url?.hash ?? "";
|
|
735
|
+
const newHash = hash ?? currentHash;
|
|
736
|
+
if (currentHash !== newHash) {
|
|
737
|
+
opts.force = true;
|
|
738
|
+
opts.hashChange = true;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
return router.navigate(routeName, routeParams, opts);
|
|
742
|
+
}
|
|
634
743
|
function parseTokens(value) {
|
|
635
744
|
return value ? value.match(/\S+/g) ?? [] : [];
|
|
636
745
|
}
|
|
@@ -655,6 +764,26 @@ function buildActiveClassName(isActive, activeClassName, baseClassName) {
|
|
|
655
764
|
}
|
|
656
765
|
return baseClassName ?? undefined;
|
|
657
766
|
}
|
|
767
|
+
function shallowEqual(prev, next) {
|
|
768
|
+
if (Object.is(prev, next)) {
|
|
769
|
+
return true;
|
|
770
|
+
}
|
|
771
|
+
if (!prev || !next) {
|
|
772
|
+
return false;
|
|
773
|
+
}
|
|
774
|
+
const prevKeys = Object.keys(prev);
|
|
775
|
+
if (prevKeys.length !== Object.keys(next).length) {
|
|
776
|
+
return false;
|
|
777
|
+
}
|
|
778
|
+
const prevRecord = prev;
|
|
779
|
+
const nextRecord = next;
|
|
780
|
+
for (const key of prevKeys) {
|
|
781
|
+
if (!Object.is(prevRecord[key], nextRecord[key])) {
|
|
782
|
+
return false;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
return true;
|
|
786
|
+
}
|
|
658
787
|
function applyLinkA11y(element) {
|
|
659
788
|
if (!element) {
|
|
660
789
|
return;
|
|
@@ -679,18 +808,35 @@ function Link(props) {
|
|
|
679
808
|
activeStrict: false,
|
|
680
809
|
ignoreQueryParams: true
|
|
681
810
|
}, props);
|
|
682
|
-
const [local, rest] = splitProps(merged, ["routeName", "routeParams", "routeOptions", "activeClassName", "activeStrict", "ignoreQueryParams", "onClick", "target", "class", "children"]);
|
|
811
|
+
const [local, rest] = splitProps(merged, ["routeName", "routeParams", "routeOptions", "activeClassName", "activeStrict", "ignoreQueryParams", "hash", "onClick", "target", "class", "children"]);
|
|
683
812
|
const ctx = useContext(RouterContext);
|
|
684
813
|
if (!ctx) {
|
|
685
814
|
throw new Error("Link must be used within a RouterProvider");
|
|
686
815
|
}
|
|
687
816
|
const router = ctx.router;
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
817
|
+
|
|
818
|
+
// Hash-aware active state (#532). `routeSelector` (the O(1) shared selector)
|
|
819
|
+
// doesn't know about hash — when `hash` prop is set, fall back to the slow
|
|
820
|
+
// path so the source's hash comparison kicks in. Tab-style UI is opt-in via
|
|
821
|
+
// the prop, so the fast path stays open for the typical Link case.
|
|
822
|
+
const useFastPath = local.hash === undefined && !local.activeStrict && local.ignoreQueryParams && local.routeParams === EMPTY_PARAMS;
|
|
823
|
+
const buildActiveOptions = () => {
|
|
824
|
+
const base = {
|
|
825
|
+
strict: local.activeStrict,
|
|
826
|
+
ignoreQueryParams: local.ignoreQueryParams
|
|
827
|
+
};
|
|
828
|
+
if (local.hash === undefined) {
|
|
829
|
+
return base;
|
|
830
|
+
}
|
|
831
|
+
return {
|
|
832
|
+
...base,
|
|
833
|
+
hash: local.hash
|
|
834
|
+
};
|
|
835
|
+
};
|
|
836
|
+
const isActive = useFastPath ? () => ctx.routeSelector(local.routeName) : createSignalFromSource(createActiveRouteSource(router, local.routeName, local.routeParams, buildActiveOptions()));
|
|
837
|
+
const href = createMemo(() => buildHref(router, local.routeName, local.routeParams, local.hash === undefined ? undefined : {
|
|
838
|
+
hash: local.hash
|
|
692
839
|
}));
|
|
693
|
-
const href = createMemo(() => buildHref(router, local.routeName, local.routeParams));
|
|
694
840
|
const handleClick = evt => {
|
|
695
841
|
if (local.onClick) {
|
|
696
842
|
local.onClick(evt);
|
|
@@ -702,7 +848,7 @@ function Link(props) {
|
|
|
702
848
|
return;
|
|
703
849
|
}
|
|
704
850
|
evt.preventDefault();
|
|
705
|
-
router
|
|
851
|
+
navigateWithHash(router, local.routeName, local.routeParams, local.hash, local.routeOptions).catch(() => {});
|
|
706
852
|
};
|
|
707
853
|
const finalClassName = createMemo(() => buildActiveClassName(isActive(), local.activeClassName, local.class));
|
|
708
854
|
return (() => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Link.d.ts","sourceRoot":"","sources":["../../../src/components/Link.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"Link.d.ts","sourceRoot":"","sources":["../../../src/components/Link.tsx"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAC1C,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,UAAU,CAAC;AAEpC,wBAAgB,IAAI,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,EAC5C,KAAK,EAAE,QAAQ,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,GAC5B,GAAG,CAAC,OAAO,CAkHb"}
|
|
@@ -2,7 +2,7 @@ export { createDirectionTracker } from "./direction-tracker.js";
|
|
|
2
2
|
export { createRouteAnnouncer } from "./route-announcer.js";
|
|
3
3
|
export { createScrollRestoration } from "./scroll-restore.js";
|
|
4
4
|
export { createViewTransitions } from "./view-transitions.js";
|
|
5
|
-
export { shouldNavigate, buildHref, buildActiveClassName, shallowEqual, applyLinkA11y, } from "./link-utils.js";
|
|
5
|
+
export { shouldNavigate, buildHref, buildActiveClassName, navigateWithHash, shallowEqual, applyLinkA11y, } from "./link-utils.js";
|
|
6
6
|
export type { RouteAnnouncerOptions } from "./route-announcer.js";
|
|
7
7
|
export type { ScrollRestorationOptions, ScrollRestorationMode, } from "./scroll-restore.js";
|
|
8
8
|
export type { DirectionTracker } from "./direction-tracker.js";
|
|
@@ -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,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,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,6 +1,22 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { NavigationOptions, Params, Router, State } from "@real-router/core";
|
|
2
2
|
export declare function shouldNavigate(evt: MouseEvent): boolean;
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Builds an href for a `<Link>` element.
|
|
5
|
+
*
|
|
6
|
+
* - Prefers the URL plugin's `buildUrl` (browser-plugin, navigation-plugin,
|
|
7
|
+
* hash-plugin) when present.
|
|
8
|
+
* - Falls back to `router.buildPath` for runtimes without a URL plugin
|
|
9
|
+
* (memory-plugin, console UIs, NativeScript). In that fallback the hash
|
|
10
|
+
* is appended manually so the rendered href is still correct.
|
|
11
|
+
* - The optional 4th argument is an options object so the contract stays
|
|
12
|
+
* extensible. The `hash` option is a decoded fragment without leading "#";
|
|
13
|
+
* `<Link hash="#section">` is accepted defensively (leading "#" stripped).
|
|
14
|
+
* Frozen API: previous 3-arg call sites continue to work unchanged.
|
|
15
|
+
*/
|
|
16
|
+
export declare function buildHref(router: Router, routeName: string, routeParams: Params, options?: {
|
|
17
|
+
hash?: string;
|
|
18
|
+
}): string | undefined;
|
|
19
|
+
export declare function navigateWithHash(router: Router, routeName: string, routeParams: Params, hash: string | undefined, extraOptions?: NavigationOptions): Promise<State>;
|
|
4
20
|
export declare function buildActiveClassName(isActive: boolean, activeClassName: string | undefined, baseClassName: string | undefined): string | undefined;
|
|
5
21
|
export declare function shallowEqual(prev: object | undefined, next: object | undefined): boolean;
|
|
6
22
|
export declare function applyLinkA11y(element: HTMLElement | null | undefined): void;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"link-utils.d.ts","sourceRoot":"","sources":["../../../src/dom-utils/link-utils.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"link-utils.d.ts","sourceRoot":"","sources":["../../../src/dom-utils/link-utils.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,iBAAiB,EACjB,MAAM,EACN,MAAM,EACN,KAAK,EACN,MAAM,mBAAmB,CAAC;AAE3B,wBAAgB,cAAc,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAQvD;AAmBD;;;;;;;;;;;;GAYG;AACH,wBAAgB,SAAS,CACvB,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,MAAM,EACjB,WAAW,EAAE,MAAM,EACnB,OAAO,CAAC,EAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GAC1B,MAAM,GAAG,SAAS,CAiCpB;AA4BD,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,MAAM,EACjB,WAAW,EAAE,MAAM,EACnB,IAAI,EAAE,MAAM,GAAG,SAAS,EACxB,YAAY,CAAC,EAAE,iBAAiB,GAC/B,OAAO,CAAC,KAAK,CAAC,CAyBhB;AAMD,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,OAAO,EACjB,eAAe,EAAE,MAAM,GAAG,SAAS,EACnC,aAAa,EAAE,MAAM,GAAG,SAAS,GAChC,MAAM,GAAG,SAAS,CAyBpB;AAED,wBAAgB,YAAY,CAC1B,IAAI,EAAE,MAAM,GAAG,SAAS,EACxB,IAAI,EAAE,MAAM,GAAG,SAAS,GACvB,OAAO,CAwBT;AAED,wBAAgB,aAAa,CAAC,OAAO,EAAE,WAAW,GAAG,IAAI,GAAG,SAAS,GAAG,IAAI,CAgB3E"}
|
|
@@ -1,9 +1,30 @@
|
|
|
1
1
|
import type { Router } from "@real-router/core";
|
|
2
|
-
export type ScrollRestorationMode = "restore" | "top" | "
|
|
2
|
+
export type ScrollRestorationMode = "restore" | "top" | "native";
|
|
3
3
|
export interface ScrollRestorationOptions {
|
|
4
4
|
mode?: ScrollRestorationMode | undefined;
|
|
5
5
|
anchorScrolling?: boolean | undefined;
|
|
6
6
|
scrollContainer?: (() => HTMLElement | null) | undefined;
|
|
7
|
+
/**
|
|
8
|
+
* Scroll behavior passed to `scrollTo({ behavior })` and
|
|
9
|
+
* `scrollIntoView({ behavior })`.
|
|
10
|
+
*
|
|
11
|
+
* - `"auto"` (default) — browser-defined, usually instant.
|
|
12
|
+
* - `"instant"` — explicit instant jump (no animation).
|
|
13
|
+
* - `"smooth"` — animated transition. Note: smooth restore on back/traverse
|
|
14
|
+
* can feel disorienting if the user expects to land at the saved position
|
|
15
|
+
* immediately. Recommended for `mode: "top"` or anchor scroll only.
|
|
16
|
+
*
|
|
17
|
+
* See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/ScrollToOptions/behavior).
|
|
18
|
+
*/
|
|
19
|
+
behavior?: ScrollBehavior | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* sessionStorage key used to persist saved scroll positions. Default:
|
|
22
|
+
* `"real-router:scroll"`. Override only when multiple independent
|
|
23
|
+
* `RouterProvider` instances share the same document and you need to
|
|
24
|
+
* isolate their scroll stores (e.g. micro-frontends, embedded widgets,
|
|
25
|
+
* or testing). For a single app with one provider the default is fine.
|
|
26
|
+
*/
|
|
27
|
+
storageKey?: string | undefined;
|
|
7
28
|
}
|
|
8
29
|
export declare function createScrollRestoration(router: Router, options?: ScrollRestorationOptions): {
|
|
9
30
|
destroy: () => void;
|
|
@@ -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,EAAS,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;
|
|
1
|
+
{"version":3,"file":"scroll-restore.d.ts","sourceRoot":"","sources":["../../../src/dom-utils/scroll-restore.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAS,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,CAkMzB"}
|
package/dist/types/types.d.ts
CHANGED
|
@@ -11,6 +11,13 @@ export interface LinkProps<P extends Params = Params> extends Omit<JSX.HTMLAttri
|
|
|
11
11
|
activeClassName?: string;
|
|
12
12
|
activeStrict?: boolean;
|
|
13
13
|
ignoreQueryParams?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* URL fragment (decoded form, no leading "#") (#532).
|
|
16
|
+
* - omitted/`undefined` → preserve current fragment on same-route navigation
|
|
17
|
+
* - `""` → clear fragment
|
|
18
|
+
* - non-empty → set fragment
|
|
19
|
+
*/
|
|
20
|
+
hash?: string;
|
|
14
21
|
target?: string;
|
|
15
22
|
onClick?: (evt: MouseEvent) => void;
|
|
16
23
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AAC1E,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,UAAU,CAAC;AAEpC,MAAM,WAAW,UAAU,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM;IACnD,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC,GAAG,SAAS,CAAC;IAC5B,aAAa,CAAC,EAAE,KAAK,GAAG,SAAS,CAAC;CACnC;AAED,MAAM,WAAW,SAAS,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,CAAE,SAAQ,IAAI,CAChE,GAAG,CAAC,cAAc,CAAC,iBAAiB,CAAC,EACrC,SAAS,CACV;IACC,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,CAAC,CAAC;IAChB,YAAY,CAAC,EAAE,iBAAiB,CAAC;IACjC,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,UAAU,KAAK,IAAI,CAAC;CACrC"}
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AAC1E,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,UAAU,CAAC;AAEpC,MAAM,WAAW,UAAU,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM;IACnD,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC,GAAG,SAAS,CAAC;IAC5B,aAAa,CAAC,EAAE,KAAK,GAAG,SAAS,CAAC;CACnC;AAED,MAAM,WAAW,SAAS,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,CAAE,SAAQ,IAAI,CAChE,GAAG,CAAC,cAAc,CAAC,iBAAiB,CAAC,EACrC,SAAS,CACV;IACC,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,CAAC,CAAC;IAChB,YAAY,CAAC,EAAE,iBAAiB,CAAC;IACjC,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B;;;;;OAKG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,UAAU,KAAK,IAAI,CAAC;CACrC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@real-router/solid",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"type": "commonjs",
|
|
5
5
|
"description": "Solid.js integration for Real-Router",
|
|
6
6
|
"main": "./dist/cjs/index.js",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@real-router/core": "^0.51.0",
|
|
55
55
|
"@real-router/route-utils": "^0.2.2",
|
|
56
|
-
"@real-router/sources": "^0.
|
|
56
|
+
"@real-router/sources": "^0.8.0"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@babel/core": "7.29.0",
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
"solid-js": "1.9.12",
|
|
72
72
|
"vite-plugin-solid": "2.11.11",
|
|
73
73
|
"vitest": "4.1.0",
|
|
74
|
-
"@real-router/browser-plugin": "^0.
|
|
74
|
+
"@real-router/browser-plugin": "^0.17.0"
|
|
75
75
|
},
|
|
76
76
|
"peerDependencies": {
|
|
77
77
|
"solid-js": ">=1.7.0"
|
package/src/components/Link.tsx
CHANGED
|
@@ -4,7 +4,12 @@ import { createMemo, mergeProps, splitProps, useContext } from "solid-js";
|
|
|
4
4
|
import { EMPTY_PARAMS, EMPTY_OPTIONS } from "../constants";
|
|
5
5
|
import { RouterContext } from "../context";
|
|
6
6
|
import { createSignalFromSource } from "../createSignalFromSource";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
shouldNavigate,
|
|
9
|
+
buildHref,
|
|
10
|
+
buildActiveClassName,
|
|
11
|
+
navigateWithHash,
|
|
12
|
+
} from "../dom-utils";
|
|
8
13
|
|
|
9
14
|
import type { LinkProps } from "../types";
|
|
10
15
|
import type { Params } from "@real-router/core";
|
|
@@ -31,6 +36,7 @@ export function Link<P extends Params = Params>(
|
|
|
31
36
|
"activeClassName",
|
|
32
37
|
"activeStrict",
|
|
33
38
|
"ignoreQueryParams",
|
|
39
|
+
"hash",
|
|
34
40
|
"onClick",
|
|
35
41
|
"target",
|
|
36
42
|
"class",
|
|
@@ -45,22 +51,47 @@ export function Link<P extends Params = Params>(
|
|
|
45
51
|
|
|
46
52
|
const router = ctx.router;
|
|
47
53
|
|
|
54
|
+
// Hash-aware active state (#532). `routeSelector` (the O(1) shared selector)
|
|
55
|
+
// doesn't know about hash — when `hash` prop is set, fall back to the slow
|
|
56
|
+
// path so the source's hash comparison kicks in. Tab-style UI is opt-in via
|
|
57
|
+
// the prop, so the fast path stays open for the typical Link case.
|
|
48
58
|
const useFastPath =
|
|
59
|
+
local.hash === undefined &&
|
|
49
60
|
!local.activeStrict &&
|
|
50
61
|
local.ignoreQueryParams &&
|
|
51
62
|
local.routeParams === EMPTY_PARAMS;
|
|
52
63
|
|
|
64
|
+
const buildActiveOptions = () => {
|
|
65
|
+
const base = {
|
|
66
|
+
strict: local.activeStrict,
|
|
67
|
+
ignoreQueryParams: local.ignoreQueryParams,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
if (local.hash === undefined) {
|
|
71
|
+
return base;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { ...base, hash: local.hash };
|
|
75
|
+
};
|
|
76
|
+
|
|
53
77
|
const isActive = useFastPath
|
|
54
78
|
? () => ctx.routeSelector(local.routeName)
|
|
55
79
|
: createSignalFromSource(
|
|
56
|
-
createActiveRouteSource(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
80
|
+
createActiveRouteSource(
|
|
81
|
+
router,
|
|
82
|
+
local.routeName,
|
|
83
|
+
local.routeParams,
|
|
84
|
+
buildActiveOptions(),
|
|
85
|
+
),
|
|
60
86
|
);
|
|
61
87
|
|
|
62
88
|
const href = createMemo(() =>
|
|
63
|
-
buildHref(
|
|
89
|
+
buildHref(
|
|
90
|
+
router,
|
|
91
|
+
local.routeName,
|
|
92
|
+
local.routeParams,
|
|
93
|
+
local.hash === undefined ? undefined : { hash: local.hash },
|
|
94
|
+
),
|
|
64
95
|
);
|
|
65
96
|
|
|
66
97
|
const handleClick = (evt: MouseEvent) => {
|
|
@@ -77,9 +108,13 @@ export function Link<P extends Params = Params>(
|
|
|
77
108
|
}
|
|
78
109
|
|
|
79
110
|
evt.preventDefault();
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
.
|
|
111
|
+
navigateWithHash(
|
|
112
|
+
router,
|
|
113
|
+
local.routeName,
|
|
114
|
+
local.routeParams,
|
|
115
|
+
local.hash,
|
|
116
|
+
local.routeOptions,
|
|
117
|
+
).catch(() => {});
|
|
83
118
|
};
|
|
84
119
|
|
|
85
120
|
const finalClassName = createMemo(() =>
|
package/src/types.ts
CHANGED
|
@@ -16,6 +16,13 @@ export interface LinkProps<P extends Params = Params> extends Omit<
|
|
|
16
16
|
activeClassName?: string;
|
|
17
17
|
activeStrict?: boolean;
|
|
18
18
|
ignoreQueryParams?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* URL fragment (decoded form, no leading "#") (#532).
|
|
21
|
+
* - omitted/`undefined` → preserve current fragment on same-route navigation
|
|
22
|
+
* - `""` → clear fragment
|
|
23
|
+
* - non-empty → set fragment
|
|
24
|
+
*/
|
|
25
|
+
hash?: string;
|
|
19
26
|
target?: string;
|
|
20
27
|
onClick?: (evt: MouseEvent) => void;
|
|
21
28
|
}
|