@real-router/solid 0.9.0 → 0.10.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 +9 -0
- package/dist/cjs/index.d.ts +7 -0
- package/dist/cjs/index.js +145 -16
- package/dist/esm/index.d.mts +7 -0
- package/dist/esm/index.mjs +145 -16
- 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.map +1 -1
- package/dist/types/types.d.ts +7 -0
- package/dist/types/types.d.ts.map +1 -1
- package/package.json +5 -5
- 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.
|
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
|
}
|
package/dist/cjs/index.js
CHANGED
|
@@ -378,12 +378,31 @@ function createScrollRestoration(router, options) {
|
|
|
378
378
|
globalThis.scrollTo(0, top);
|
|
379
379
|
}
|
|
380
380
|
};
|
|
381
|
-
const scrollToHashOrTop =
|
|
381
|
+
const scrollToHashOrTop = route => {
|
|
382
|
+
// URL plugin path (#532): `state.context.url.hash` is the source of truth
|
|
383
|
+
// when one of the URL plugins (browser-plugin / navigation-plugin) is
|
|
384
|
+
// installed. The value is already DECODED — feeding it through
|
|
385
|
+
// `decodeURIComponent` again would throw on a bare `%`.
|
|
386
|
+
const ctxHash = route.context?.url?.hash;
|
|
387
|
+
if (ctxHash !== undefined) {
|
|
388
|
+
if (anchorEnabled && ctxHash.length > 0) {
|
|
389
|
+
// eslint-disable-next-line unicorn/prefer-query-selector -- ids may contain CSS-unsafe chars
|
|
390
|
+
const element = document.getElementById(ctxHash);
|
|
391
|
+
if (element) {
|
|
392
|
+
element.scrollIntoView();
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
writePos(0);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Fallback path: no URL plugin, read the DOM. `location.hash` is
|
|
401
|
+
// percent-encoded; ids in the DOM are the raw string, so decode for the
|
|
402
|
+
// match. Fall back to the raw slice if the hash contains a malformed
|
|
403
|
+
// escape sequence (decodeURIComponent throws on those).
|
|
382
404
|
const hash = globalThis.location.hash;
|
|
383
405
|
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
406
|
let id;
|
|
388
407
|
try {
|
|
389
408
|
id = decodeURIComponent(hash.slice(1));
|
|
@@ -421,7 +440,7 @@ function createScrollRestoration(router, options) {
|
|
|
421
440
|
return;
|
|
422
441
|
}
|
|
423
442
|
if (mode === "top" || !nav) {
|
|
424
|
-
scrollToHashOrTop();
|
|
443
|
+
scrollToHashOrTop(route);
|
|
425
444
|
return;
|
|
426
445
|
}
|
|
427
446
|
if (nav.navigationType === "replace") {
|
|
@@ -431,7 +450,7 @@ function createScrollRestoration(router, options) {
|
|
|
431
450
|
writePos(loadStore()[keyOf(route)] ?? 0);
|
|
432
451
|
return;
|
|
433
452
|
}
|
|
434
|
-
scrollToHashOrTop();
|
|
453
|
+
scrollToHashOrTop(route);
|
|
435
454
|
});
|
|
436
455
|
});
|
|
437
456
|
const onPageHide = () => {
|
|
@@ -618,21 +637,94 @@ function createViewTransitions(router) {
|
|
|
618
637
|
function shouldNavigate(evt) {
|
|
619
638
|
return evt.button === 0 && !evt.metaKey && !evt.altKey && !evt.ctrlKey && !evt.shiftKey;
|
|
620
639
|
}
|
|
621
|
-
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* RFC 3986 fragment encoding: preserve sub-delims (`&`, `=`, `?`, `:`),
|
|
643
|
+
* encode space, `%`, control chars, non-ASCII via encodeURI; defensively
|
|
644
|
+
* escape `#` (encodeURI does not). Mirrors `encodeHashFragment` in
|
|
645
|
+
* `shared/browser-env/url-context.ts` — duplicated here because the
|
|
646
|
+
* shared/dom-utils symlink graph does not reach shared/browser-env.
|
|
647
|
+
*/
|
|
648
|
+
function encodeFragmentInline(decoded) {
|
|
649
|
+
return encodeURI(decoded).replaceAll("#", "%23");
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Builds an href for a `<Link>` element.
|
|
653
|
+
*
|
|
654
|
+
* - Prefers the URL plugin's `buildUrl` (browser-plugin, navigation-plugin,
|
|
655
|
+
* hash-plugin) when present.
|
|
656
|
+
* - Falls back to `router.buildPath` for runtimes without a URL plugin
|
|
657
|
+
* (memory-plugin, console UIs, NativeScript). In that fallback the hash
|
|
658
|
+
* is appended manually so the rendered href is still correct.
|
|
659
|
+
* - The optional 4th argument is an options object so the contract stays
|
|
660
|
+
* extensible. The `hash` option is a decoded fragment without leading "#";
|
|
661
|
+
* `<Link hash="#section">` is accepted defensively (leading "#" stripped).
|
|
662
|
+
* Frozen API: previous 3-arg call sites continue to work unchanged.
|
|
663
|
+
*/
|
|
664
|
+
function buildHref(router, routeName, routeParams, options) {
|
|
622
665
|
try {
|
|
666
|
+
const rawHash = options?.hash;
|
|
667
|
+
let normHash;
|
|
668
|
+
if (rawHash !== undefined) {
|
|
669
|
+
normHash = rawHash.startsWith("#") ? rawHash.slice(1) : rawHash;
|
|
670
|
+
}
|
|
623
671
|
const buildUrl = router.buildUrl;
|
|
624
672
|
if (buildUrl) {
|
|
625
|
-
const url = buildUrl(routeName, routeParams
|
|
673
|
+
const url = buildUrl(routeName, routeParams, normHash === undefined ? undefined : {
|
|
674
|
+
hash: normHash
|
|
675
|
+
});
|
|
626
676
|
if (url !== undefined) {
|
|
627
677
|
return url;
|
|
628
678
|
}
|
|
629
679
|
}
|
|
630
|
-
|
|
680
|
+
const path = router.buildPath(routeName, routeParams);
|
|
681
|
+
return normHash ? `${path}#${encodeFragmentInline(normHash)}` : path;
|
|
631
682
|
} catch {
|
|
632
683
|
console.error(`[real-router] Route "${routeName}" is not defined. The element will render without an href attribute.`);
|
|
633
684
|
return undefined;
|
|
634
685
|
}
|
|
635
686
|
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* `<Link>` click-handler navigation helper (#532).
|
|
690
|
+
*
|
|
691
|
+
* Wraps `router.navigate(name, params, opts)` with same-route different-hash
|
|
692
|
+
* detection: when the consumer clicks a hash-bearing Link that targets the
|
|
693
|
+
* current route with the same params but a different fragment, core's
|
|
694
|
+
* SAME_STATES check would otherwise reject the navigation. The helper adds
|
|
695
|
+
* `force: true` and `hashChange: true` automatically — subscribers can then
|
|
696
|
+
* disambiguate via `state.context.url.hashChanged`.
|
|
697
|
+
*
|
|
698
|
+
* For pure programmatic same-route hash-only navigation, callers are
|
|
699
|
+
* documented to pass `{ force: true }` themselves; the auto-bypass here is
|
|
700
|
+
* a UX convenience for `<Link hash>` that all 6 framework adapters share.
|
|
701
|
+
*/
|
|
702
|
+
/**
|
|
703
|
+
* Local extended-options type. Adapters that depend only on `@real-router/core`
|
|
704
|
+
* (without a URL plugin) do not see the `NavigationOptions` augmentation that
|
|
705
|
+
* declares `hash` / `hashChange`. Casting to this widened type inside the
|
|
706
|
+
* helper keeps shared/dom-utils self-contained — adapters do not need to
|
|
707
|
+
* augment NavigationOptions themselves to consume `<Link hash>`.
|
|
708
|
+
*/
|
|
709
|
+
|
|
710
|
+
function navigateWithHash(router, routeName, routeParams, hash, extraOptions) {
|
|
711
|
+
const opts = {
|
|
712
|
+
...extraOptions
|
|
713
|
+
};
|
|
714
|
+
if (hash !== undefined) {
|
|
715
|
+
opts.hash = hash;
|
|
716
|
+
}
|
|
717
|
+
const current = router.getState();
|
|
718
|
+
if (current?.name === routeName && shallowEqual(current.params, routeParams)) {
|
|
719
|
+
const currentHash = current.context?.url?.hash ?? "";
|
|
720
|
+
const newHash = hash ?? currentHash;
|
|
721
|
+
if (currentHash !== newHash) {
|
|
722
|
+
opts.force = true;
|
|
723
|
+
opts.hashChange = true;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
return router.navigate(routeName, routeParams, opts);
|
|
727
|
+
}
|
|
636
728
|
function parseTokens(value) {
|
|
637
729
|
return value ? value.match(/\S+/g) ?? [] : [];
|
|
638
730
|
}
|
|
@@ -657,6 +749,26 @@ function buildActiveClassName(isActive, activeClassName, baseClassName) {
|
|
|
657
749
|
}
|
|
658
750
|
return baseClassName ?? undefined;
|
|
659
751
|
}
|
|
752
|
+
function shallowEqual(prev, next) {
|
|
753
|
+
if (Object.is(prev, next)) {
|
|
754
|
+
return true;
|
|
755
|
+
}
|
|
756
|
+
if (!prev || !next) {
|
|
757
|
+
return false;
|
|
758
|
+
}
|
|
759
|
+
const prevKeys = Object.keys(prev);
|
|
760
|
+
if (prevKeys.length !== Object.keys(next).length) {
|
|
761
|
+
return false;
|
|
762
|
+
}
|
|
763
|
+
const prevRecord = prev;
|
|
764
|
+
const nextRecord = next;
|
|
765
|
+
for (const key of prevKeys) {
|
|
766
|
+
if (!Object.is(prevRecord[key], nextRecord[key])) {
|
|
767
|
+
return false;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
return true;
|
|
771
|
+
}
|
|
660
772
|
function applyLinkA11y(element) {
|
|
661
773
|
if (!element) {
|
|
662
774
|
return;
|
|
@@ -681,18 +793,35 @@ function Link(props) {
|
|
|
681
793
|
activeStrict: false,
|
|
682
794
|
ignoreQueryParams: true
|
|
683
795
|
}, props);
|
|
684
|
-
const [local, rest] = solidJs.splitProps(merged, ["routeName", "routeParams", "routeOptions", "activeClassName", "activeStrict", "ignoreQueryParams", "onClick", "target", "class", "children"]);
|
|
796
|
+
const [local, rest] = solidJs.splitProps(merged, ["routeName", "routeParams", "routeOptions", "activeClassName", "activeStrict", "ignoreQueryParams", "hash", "onClick", "target", "class", "children"]);
|
|
685
797
|
const ctx = solidJs.useContext(RouterContext);
|
|
686
798
|
if (!ctx) {
|
|
687
799
|
throw new Error("Link must be used within a RouterProvider");
|
|
688
800
|
}
|
|
689
801
|
const router = ctx.router;
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
802
|
+
|
|
803
|
+
// Hash-aware active state (#532). `routeSelector` (the O(1) shared selector)
|
|
804
|
+
// doesn't know about hash — when `hash` prop is set, fall back to the slow
|
|
805
|
+
// path so the source's hash comparison kicks in. Tab-style UI is opt-in via
|
|
806
|
+
// the prop, so the fast path stays open for the typical Link case.
|
|
807
|
+
const useFastPath = local.hash === undefined && !local.activeStrict && local.ignoreQueryParams && local.routeParams === EMPTY_PARAMS;
|
|
808
|
+
const buildActiveOptions = () => {
|
|
809
|
+
const base = {
|
|
810
|
+
strict: local.activeStrict,
|
|
811
|
+
ignoreQueryParams: local.ignoreQueryParams
|
|
812
|
+
};
|
|
813
|
+
if (local.hash === undefined) {
|
|
814
|
+
return base;
|
|
815
|
+
}
|
|
816
|
+
return {
|
|
817
|
+
...base,
|
|
818
|
+
hash: local.hash
|
|
819
|
+
};
|
|
820
|
+
};
|
|
821
|
+
const isActive = useFastPath ? () => ctx.routeSelector(local.routeName) : createSignalFromSource(sources.createActiveRouteSource(router, local.routeName, local.routeParams, buildActiveOptions()));
|
|
822
|
+
const href = solidJs.createMemo(() => buildHref(router, local.routeName, local.routeParams, local.hash === undefined ? undefined : {
|
|
823
|
+
hash: local.hash
|
|
694
824
|
}));
|
|
695
|
-
const href = solidJs.createMemo(() => buildHref(router, local.routeName, local.routeParams));
|
|
696
825
|
const handleClick = evt => {
|
|
697
826
|
if (local.onClick) {
|
|
698
827
|
local.onClick(evt);
|
|
@@ -704,7 +833,7 @@ function Link(props) {
|
|
|
704
833
|
return;
|
|
705
834
|
}
|
|
706
835
|
evt.preventDefault();
|
|
707
|
-
router
|
|
836
|
+
navigateWithHash(router, local.routeName, local.routeParams, local.hash, local.routeOptions).catch(() => {});
|
|
708
837
|
};
|
|
709
838
|
const finalClassName = solidJs.createMemo(() => buildActiveClassName(isActive(), local.activeClassName, local.class));
|
|
710
839
|
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
|
}
|
package/dist/esm/index.mjs
CHANGED
|
@@ -376,12 +376,31 @@ function createScrollRestoration(router, options) {
|
|
|
376
376
|
globalThis.scrollTo(0, top);
|
|
377
377
|
}
|
|
378
378
|
};
|
|
379
|
-
const scrollToHashOrTop =
|
|
379
|
+
const scrollToHashOrTop = route => {
|
|
380
|
+
// URL plugin path (#532): `state.context.url.hash` is the source of truth
|
|
381
|
+
// when one of the URL plugins (browser-plugin / navigation-plugin) is
|
|
382
|
+
// installed. The value is already DECODED — feeding it through
|
|
383
|
+
// `decodeURIComponent` again would throw on a bare `%`.
|
|
384
|
+
const ctxHash = route.context?.url?.hash;
|
|
385
|
+
if (ctxHash !== undefined) {
|
|
386
|
+
if (anchorEnabled && ctxHash.length > 0) {
|
|
387
|
+
// eslint-disable-next-line unicorn/prefer-query-selector -- ids may contain CSS-unsafe chars
|
|
388
|
+
const element = document.getElementById(ctxHash);
|
|
389
|
+
if (element) {
|
|
390
|
+
element.scrollIntoView();
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
writePos(0);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Fallback path: no URL plugin, read the DOM. `location.hash` is
|
|
399
|
+
// percent-encoded; ids in the DOM are the raw string, so decode for the
|
|
400
|
+
// match. Fall back to the raw slice if the hash contains a malformed
|
|
401
|
+
// escape sequence (decodeURIComponent throws on those).
|
|
380
402
|
const hash = globalThis.location.hash;
|
|
381
403
|
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
404
|
let id;
|
|
386
405
|
try {
|
|
387
406
|
id = decodeURIComponent(hash.slice(1));
|
|
@@ -419,7 +438,7 @@ function createScrollRestoration(router, options) {
|
|
|
419
438
|
return;
|
|
420
439
|
}
|
|
421
440
|
if (mode === "top" || !nav) {
|
|
422
|
-
scrollToHashOrTop();
|
|
441
|
+
scrollToHashOrTop(route);
|
|
423
442
|
return;
|
|
424
443
|
}
|
|
425
444
|
if (nav.navigationType === "replace") {
|
|
@@ -429,7 +448,7 @@ function createScrollRestoration(router, options) {
|
|
|
429
448
|
writePos(loadStore()[keyOf(route)] ?? 0);
|
|
430
449
|
return;
|
|
431
450
|
}
|
|
432
|
-
scrollToHashOrTop();
|
|
451
|
+
scrollToHashOrTop(route);
|
|
433
452
|
});
|
|
434
453
|
});
|
|
435
454
|
const onPageHide = () => {
|
|
@@ -616,21 +635,94 @@ function createViewTransitions(router) {
|
|
|
616
635
|
function shouldNavigate(evt) {
|
|
617
636
|
return evt.button === 0 && !evt.metaKey && !evt.altKey && !evt.ctrlKey && !evt.shiftKey;
|
|
618
637
|
}
|
|
619
|
-
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* RFC 3986 fragment encoding: preserve sub-delims (`&`, `=`, `?`, `:`),
|
|
641
|
+
* encode space, `%`, control chars, non-ASCII via encodeURI; defensively
|
|
642
|
+
* escape `#` (encodeURI does not). Mirrors `encodeHashFragment` in
|
|
643
|
+
* `shared/browser-env/url-context.ts` — duplicated here because the
|
|
644
|
+
* shared/dom-utils symlink graph does not reach shared/browser-env.
|
|
645
|
+
*/
|
|
646
|
+
function encodeFragmentInline(decoded) {
|
|
647
|
+
return encodeURI(decoded).replaceAll("#", "%23");
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Builds an href for a `<Link>` element.
|
|
651
|
+
*
|
|
652
|
+
* - Prefers the URL plugin's `buildUrl` (browser-plugin, navigation-plugin,
|
|
653
|
+
* hash-plugin) when present.
|
|
654
|
+
* - Falls back to `router.buildPath` for runtimes without a URL plugin
|
|
655
|
+
* (memory-plugin, console UIs, NativeScript). In that fallback the hash
|
|
656
|
+
* is appended manually so the rendered href is still correct.
|
|
657
|
+
* - The optional 4th argument is an options object so the contract stays
|
|
658
|
+
* extensible. The `hash` option is a decoded fragment without leading "#";
|
|
659
|
+
* `<Link hash="#section">` is accepted defensively (leading "#" stripped).
|
|
660
|
+
* Frozen API: previous 3-arg call sites continue to work unchanged.
|
|
661
|
+
*/
|
|
662
|
+
function buildHref(router, routeName, routeParams, options) {
|
|
620
663
|
try {
|
|
664
|
+
const rawHash = options?.hash;
|
|
665
|
+
let normHash;
|
|
666
|
+
if (rawHash !== undefined) {
|
|
667
|
+
normHash = rawHash.startsWith("#") ? rawHash.slice(1) : rawHash;
|
|
668
|
+
}
|
|
621
669
|
const buildUrl = router.buildUrl;
|
|
622
670
|
if (buildUrl) {
|
|
623
|
-
const url = buildUrl(routeName, routeParams
|
|
671
|
+
const url = buildUrl(routeName, routeParams, normHash === undefined ? undefined : {
|
|
672
|
+
hash: normHash
|
|
673
|
+
});
|
|
624
674
|
if (url !== undefined) {
|
|
625
675
|
return url;
|
|
626
676
|
}
|
|
627
677
|
}
|
|
628
|
-
|
|
678
|
+
const path = router.buildPath(routeName, routeParams);
|
|
679
|
+
return normHash ? `${path}#${encodeFragmentInline(normHash)}` : path;
|
|
629
680
|
} catch {
|
|
630
681
|
console.error(`[real-router] Route "${routeName}" is not defined. The element will render without an href attribute.`);
|
|
631
682
|
return undefined;
|
|
632
683
|
}
|
|
633
684
|
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* `<Link>` click-handler navigation helper (#532).
|
|
688
|
+
*
|
|
689
|
+
* Wraps `router.navigate(name, params, opts)` with same-route different-hash
|
|
690
|
+
* detection: when the consumer clicks a hash-bearing Link that targets the
|
|
691
|
+
* current route with the same params but a different fragment, core's
|
|
692
|
+
* SAME_STATES check would otherwise reject the navigation. The helper adds
|
|
693
|
+
* `force: true` and `hashChange: true` automatically — subscribers can then
|
|
694
|
+
* disambiguate via `state.context.url.hashChanged`.
|
|
695
|
+
*
|
|
696
|
+
* For pure programmatic same-route hash-only navigation, callers are
|
|
697
|
+
* documented to pass `{ force: true }` themselves; the auto-bypass here is
|
|
698
|
+
* a UX convenience for `<Link hash>` that all 6 framework adapters share.
|
|
699
|
+
*/
|
|
700
|
+
/**
|
|
701
|
+
* Local extended-options type. Adapters that depend only on `@real-router/core`
|
|
702
|
+
* (without a URL plugin) do not see the `NavigationOptions` augmentation that
|
|
703
|
+
* declares `hash` / `hashChange`. Casting to this widened type inside the
|
|
704
|
+
* helper keeps shared/dom-utils self-contained — adapters do not need to
|
|
705
|
+
* augment NavigationOptions themselves to consume `<Link hash>`.
|
|
706
|
+
*/
|
|
707
|
+
|
|
708
|
+
function navigateWithHash(router, routeName, routeParams, hash, extraOptions) {
|
|
709
|
+
const opts = {
|
|
710
|
+
...extraOptions
|
|
711
|
+
};
|
|
712
|
+
if (hash !== undefined) {
|
|
713
|
+
opts.hash = hash;
|
|
714
|
+
}
|
|
715
|
+
const current = router.getState();
|
|
716
|
+
if (current?.name === routeName && shallowEqual(current.params, routeParams)) {
|
|
717
|
+
const currentHash = current.context?.url?.hash ?? "";
|
|
718
|
+
const newHash = hash ?? currentHash;
|
|
719
|
+
if (currentHash !== newHash) {
|
|
720
|
+
opts.force = true;
|
|
721
|
+
opts.hashChange = true;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
return router.navigate(routeName, routeParams, opts);
|
|
725
|
+
}
|
|
634
726
|
function parseTokens(value) {
|
|
635
727
|
return value ? value.match(/\S+/g) ?? [] : [];
|
|
636
728
|
}
|
|
@@ -655,6 +747,26 @@ function buildActiveClassName(isActive, activeClassName, baseClassName) {
|
|
|
655
747
|
}
|
|
656
748
|
return baseClassName ?? undefined;
|
|
657
749
|
}
|
|
750
|
+
function shallowEqual(prev, next) {
|
|
751
|
+
if (Object.is(prev, next)) {
|
|
752
|
+
return true;
|
|
753
|
+
}
|
|
754
|
+
if (!prev || !next) {
|
|
755
|
+
return false;
|
|
756
|
+
}
|
|
757
|
+
const prevKeys = Object.keys(prev);
|
|
758
|
+
if (prevKeys.length !== Object.keys(next).length) {
|
|
759
|
+
return false;
|
|
760
|
+
}
|
|
761
|
+
const prevRecord = prev;
|
|
762
|
+
const nextRecord = next;
|
|
763
|
+
for (const key of prevKeys) {
|
|
764
|
+
if (!Object.is(prevRecord[key], nextRecord[key])) {
|
|
765
|
+
return false;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
return true;
|
|
769
|
+
}
|
|
658
770
|
function applyLinkA11y(element) {
|
|
659
771
|
if (!element) {
|
|
660
772
|
return;
|
|
@@ -679,18 +791,35 @@ function Link(props) {
|
|
|
679
791
|
activeStrict: false,
|
|
680
792
|
ignoreQueryParams: true
|
|
681
793
|
}, props);
|
|
682
|
-
const [local, rest] = splitProps(merged, ["routeName", "routeParams", "routeOptions", "activeClassName", "activeStrict", "ignoreQueryParams", "onClick", "target", "class", "children"]);
|
|
794
|
+
const [local, rest] = splitProps(merged, ["routeName", "routeParams", "routeOptions", "activeClassName", "activeStrict", "ignoreQueryParams", "hash", "onClick", "target", "class", "children"]);
|
|
683
795
|
const ctx = useContext(RouterContext);
|
|
684
796
|
if (!ctx) {
|
|
685
797
|
throw new Error("Link must be used within a RouterProvider");
|
|
686
798
|
}
|
|
687
799
|
const router = ctx.router;
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
800
|
+
|
|
801
|
+
// Hash-aware active state (#532). `routeSelector` (the O(1) shared selector)
|
|
802
|
+
// doesn't know about hash — when `hash` prop is set, fall back to the slow
|
|
803
|
+
// path so the source's hash comparison kicks in. Tab-style UI is opt-in via
|
|
804
|
+
// the prop, so the fast path stays open for the typical Link case.
|
|
805
|
+
const useFastPath = local.hash === undefined && !local.activeStrict && local.ignoreQueryParams && local.routeParams === EMPTY_PARAMS;
|
|
806
|
+
const buildActiveOptions = () => {
|
|
807
|
+
const base = {
|
|
808
|
+
strict: local.activeStrict,
|
|
809
|
+
ignoreQueryParams: local.ignoreQueryParams
|
|
810
|
+
};
|
|
811
|
+
if (local.hash === undefined) {
|
|
812
|
+
return base;
|
|
813
|
+
}
|
|
814
|
+
return {
|
|
815
|
+
...base,
|
|
816
|
+
hash: local.hash
|
|
817
|
+
};
|
|
818
|
+
};
|
|
819
|
+
const isActive = useFastPath ? () => ctx.routeSelector(local.routeName) : createSignalFromSource(createActiveRouteSource(router, local.routeName, local.routeParams, buildActiveOptions()));
|
|
820
|
+
const href = createMemo(() => buildHref(router, local.routeName, local.routeParams, local.hash === undefined ? undefined : {
|
|
821
|
+
hash: local.hash
|
|
692
822
|
}));
|
|
693
|
-
const href = createMemo(() => buildHref(router, local.routeName, local.routeParams));
|
|
694
823
|
const handleClick = evt => {
|
|
695
824
|
if (local.onClick) {
|
|
696
825
|
local.onClick(evt);
|
|
@@ -702,7 +831,7 @@ function Link(props) {
|
|
|
702
831
|
return;
|
|
703
832
|
}
|
|
704
833
|
evt.preventDefault();
|
|
705
|
-
router
|
|
834
|
+
navigateWithHash(router, local.routeName, local.routeParams, local.hash, local.routeOptions).catch(() => {});
|
|
706
835
|
};
|
|
707
836
|
const finalClassName = createMemo(() => buildActiveClassName(isActive(), local.activeClassName, local.class));
|
|
708
837
|
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 +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;CAC1D;AAOD,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,MAAM,EACd,OAAO,CAAC,EAAE,wBAAwB,GACjC;IAAE,OAAO,EAAE,MAAM,IAAI,CAAA;CAAE,
|
|
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;CAC1D;AAOD,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,MAAM,EACd,OAAO,CAAC,EAAE,wBAAwB,GACjC;IAAE,OAAO,EAAE,MAAM,IAAI,CAAA;CAAE,CAwKzB"}
|
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.10.0",
|
|
4
4
|
"type": "commonjs",
|
|
5
5
|
"description": "Solid.js integration for Real-Router",
|
|
6
6
|
"main": "./dist/cjs/index.js",
|
|
@@ -51,9 +51,9 @@
|
|
|
51
51
|
"license": "MIT",
|
|
52
52
|
"sideEffects": false,
|
|
53
53
|
"dependencies": {
|
|
54
|
-
"@real-router/core": "^0.
|
|
55
|
-
"@real-router/route-utils": "^0.2.
|
|
56
|
-
"@real-router/sources": "^0.
|
|
54
|
+
"@real-router/core": "^0.51.0",
|
|
55
|
+
"@real-router/route-utils": "^0.2.2",
|
|
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
|
}
|