@real-router/angular 0.6.1 → 0.8.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 +11 -1
- package/dist/README.md +11 -1
- package/dist/fesm2022/real-router-angular.mjs +142 -50
- package/dist/fesm2022/real-router-angular.mjs.map +1 -1
- package/dist/types/real-router-angular.d.ts +31 -2
- package/dist/types/real-router-angular.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/directives/RealLink.ts +38 -11
- package/src/dom-utils/index.ts +1 -0
- package/src/dom-utils/link-utils.ts +110 -4
- package/src/dom-utils/scroll-restore.ts +87 -36
- package/src/functions/injectIsActiveRoute.ts +14 -5
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
NavigationOptions,
|
|
3
|
+
Params,
|
|
4
|
+
Router,
|
|
5
|
+
State,
|
|
6
|
+
} from "@real-router/core";
|
|
2
7
|
|
|
3
8
|
export function shouldNavigate(evt: MouseEvent): boolean {
|
|
4
9
|
return (
|
|
@@ -10,25 +15,67 @@ export function shouldNavigate(evt: MouseEvent): boolean {
|
|
|
10
15
|
);
|
|
11
16
|
}
|
|
12
17
|
|
|
13
|
-
|
|
18
|
+
/**
|
|
19
|
+
* RFC 3986 fragment encoding: preserve sub-delims (`&`, `=`, `?`, `:`),
|
|
20
|
+
* encode space, `%`, control chars, non-ASCII via encodeURI; defensively
|
|
21
|
+
* escape `#` (encodeURI does not). Mirrors `encodeHashFragment` in
|
|
22
|
+
* `shared/browser-env/url-context.ts` — duplicated here because the
|
|
23
|
+
* shared/dom-utils symlink graph does not reach shared/browser-env.
|
|
24
|
+
*/
|
|
25
|
+
function encodeFragmentInline(decoded: string): string {
|
|
26
|
+
return encodeURI(decoded).replaceAll("#", "%23");
|
|
27
|
+
}
|
|
14
28
|
|
|
29
|
+
type BuildUrlFn = (
|
|
30
|
+
name: string,
|
|
31
|
+
params: Params,
|
|
32
|
+
options?: { hash?: string },
|
|
33
|
+
) => string | undefined;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Builds an href for a `<Link>` element.
|
|
37
|
+
*
|
|
38
|
+
* - Prefers the URL plugin's `buildUrl` (browser-plugin, navigation-plugin,
|
|
39
|
+
* hash-plugin) when present.
|
|
40
|
+
* - Falls back to `router.buildPath` for runtimes without a URL plugin
|
|
41
|
+
* (memory-plugin, console UIs, NativeScript). In that fallback the hash
|
|
42
|
+
* is appended manually so the rendered href is still correct.
|
|
43
|
+
* - The optional 4th argument is an options object so the contract stays
|
|
44
|
+
* extensible. The `hash` option is a decoded fragment without leading "#";
|
|
45
|
+
* `<Link hash="#section">` is accepted defensively (leading "#" stripped).
|
|
46
|
+
* Frozen API: previous 3-arg call sites continue to work unchanged.
|
|
47
|
+
*/
|
|
15
48
|
export function buildHref(
|
|
16
49
|
router: Router,
|
|
17
50
|
routeName: string,
|
|
18
51
|
routeParams: Params,
|
|
52
|
+
options?: { hash?: string },
|
|
19
53
|
): string | undefined {
|
|
20
54
|
try {
|
|
55
|
+
const rawHash = options?.hash;
|
|
56
|
+
let normHash: string | undefined;
|
|
57
|
+
|
|
58
|
+
if (rawHash !== undefined) {
|
|
59
|
+
normHash = rawHash.startsWith("#") ? rawHash.slice(1) : rawHash;
|
|
60
|
+
}
|
|
61
|
+
|
|
21
62
|
const buildUrl = router.buildUrl as BuildUrlFn | undefined;
|
|
22
63
|
|
|
23
64
|
if (buildUrl) {
|
|
24
|
-
const url = buildUrl(
|
|
65
|
+
const url = buildUrl(
|
|
66
|
+
routeName,
|
|
67
|
+
routeParams,
|
|
68
|
+
normHash === undefined ? undefined : { hash: normHash },
|
|
69
|
+
);
|
|
25
70
|
|
|
26
71
|
if (url !== undefined) {
|
|
27
72
|
return url;
|
|
28
73
|
}
|
|
29
74
|
}
|
|
30
75
|
|
|
31
|
-
|
|
76
|
+
const path = router.buildPath(routeName, routeParams);
|
|
77
|
+
|
|
78
|
+
return normHash ? `${path}#${encodeFragmentInline(normHash)}` : path;
|
|
32
79
|
} catch {
|
|
33
80
|
console.error(
|
|
34
81
|
`[real-router] Route "${routeName}" is not defined. The element will render without an href attribute.`,
|
|
@@ -38,6 +85,65 @@ export function buildHref(
|
|
|
38
85
|
}
|
|
39
86
|
}
|
|
40
87
|
|
|
88
|
+
/**
|
|
89
|
+
* `<Link>` click-handler navigation helper (#532).
|
|
90
|
+
*
|
|
91
|
+
* Wraps `router.navigate(name, params, opts)` with same-route different-hash
|
|
92
|
+
* detection: when the consumer clicks a hash-bearing Link that targets the
|
|
93
|
+
* current route with the same params but a different fragment, core's
|
|
94
|
+
* SAME_STATES check would otherwise reject the navigation. The helper adds
|
|
95
|
+
* `force: true` and `hashChange: true` automatically — subscribers can then
|
|
96
|
+
* disambiguate via `state.context.url.hashChanged`.
|
|
97
|
+
*
|
|
98
|
+
* For pure programmatic same-route hash-only navigation, callers are
|
|
99
|
+
* documented to pass `{ force: true }` themselves; the auto-bypass here is
|
|
100
|
+
* a UX convenience for `<Link hash>` that all 6 framework adapters share.
|
|
101
|
+
*/
|
|
102
|
+
/**
|
|
103
|
+
* Local extended-options type. Adapters that depend only on `@real-router/core`
|
|
104
|
+
* (without a URL plugin) do not see the `NavigationOptions` augmentation that
|
|
105
|
+
* declares `hash` / `hashChange`. Casting to this widened type inside the
|
|
106
|
+
* helper keeps shared/dom-utils self-contained — adapters do not need to
|
|
107
|
+
* augment NavigationOptions themselves to consume `<Link hash>`.
|
|
108
|
+
*/
|
|
109
|
+
type HashAwareNavigationOptions = NavigationOptions & {
|
|
110
|
+
hash?: string;
|
|
111
|
+
hashChange?: boolean;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export function navigateWithHash(
|
|
115
|
+
router: Router,
|
|
116
|
+
routeName: string,
|
|
117
|
+
routeParams: Params,
|
|
118
|
+
hash: string | undefined,
|
|
119
|
+
extraOptions?: NavigationOptions,
|
|
120
|
+
): Promise<State> {
|
|
121
|
+
const opts: HashAwareNavigationOptions = { ...extraOptions };
|
|
122
|
+
|
|
123
|
+
if (hash !== undefined) {
|
|
124
|
+
opts.hash = hash;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const current = router.getState();
|
|
128
|
+
|
|
129
|
+
if (
|
|
130
|
+
current?.name === routeName &&
|
|
131
|
+
shallowEqual(current.params, routeParams)
|
|
132
|
+
) {
|
|
133
|
+
const currentHash =
|
|
134
|
+
(current.context as { url?: { hash?: string } } | undefined)?.url?.hash ??
|
|
135
|
+
"";
|
|
136
|
+
const newHash = hash ?? currentHash;
|
|
137
|
+
|
|
138
|
+
if (currentHash !== newHash) {
|
|
139
|
+
opts.force = true;
|
|
140
|
+
opts.hashChange = true;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return router.navigate(routeName, routeParams, opts);
|
|
145
|
+
}
|
|
146
|
+
|
|
41
147
|
function parseTokens(value: string | undefined): string[] {
|
|
42
148
|
return value ? (value.match(/\S+/g) ?? []) : [];
|
|
43
149
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Router, State } from "@real-router/core";
|
|
2
2
|
|
|
3
|
-
const
|
|
3
|
+
const DEFAULT_STORAGE_KEY = "real-router:scroll";
|
|
4
4
|
|
|
5
5
|
const NOOP_INSTANCE: { destroy: () => void } = Object.freeze({
|
|
6
6
|
destroy: () => {
|
|
@@ -8,12 +8,33 @@ const NOOP_INSTANCE: { destroy: () => void } = Object.freeze({
|
|
|
8
8
|
},
|
|
9
9
|
});
|
|
10
10
|
|
|
11
|
-
export type ScrollRestorationMode = "restore" | "top" | "
|
|
11
|
+
export type ScrollRestorationMode = "restore" | "top" | "native";
|
|
12
12
|
|
|
13
13
|
export interface ScrollRestorationOptions {
|
|
14
14
|
mode?: ScrollRestorationMode | undefined;
|
|
15
15
|
anchorScrolling?: boolean | undefined;
|
|
16
16
|
scrollContainer?: (() => HTMLElement | null) | undefined;
|
|
17
|
+
/**
|
|
18
|
+
* Scroll behavior passed to `scrollTo({ behavior })` and
|
|
19
|
+
* `scrollIntoView({ behavior })`.
|
|
20
|
+
*
|
|
21
|
+
* - `"auto"` (default) — browser-defined, usually instant.
|
|
22
|
+
* - `"instant"` — explicit instant jump (no animation).
|
|
23
|
+
* - `"smooth"` — animated transition. Note: smooth restore on back/traverse
|
|
24
|
+
* can feel disorienting if the user expects to land at the saved position
|
|
25
|
+
* immediately. Recommended for `mode: "top"` or anchor scroll only.
|
|
26
|
+
*
|
|
27
|
+
* See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/ScrollToOptions/behavior).
|
|
28
|
+
*/
|
|
29
|
+
behavior?: ScrollBehavior | undefined;
|
|
30
|
+
/**
|
|
31
|
+
* sessionStorage key used to persist saved scroll positions. Default:
|
|
32
|
+
* `"real-router:scroll"`. Override only when multiple independent
|
|
33
|
+
* `RouterProvider` instances share the same document and you need to
|
|
34
|
+
* isolate their scroll stores (e.g. micro-frontends, embedded widgets,
|
|
35
|
+
* or testing). For a single app with one provider the default is fine.
|
|
36
|
+
*/
|
|
37
|
+
storageKey?: string | undefined;
|
|
17
38
|
}
|
|
18
39
|
|
|
19
40
|
interface NavigationContext {
|
|
@@ -31,15 +52,41 @@ export function createScrollRestoration(
|
|
|
31
52
|
|
|
32
53
|
const mode = options?.mode ?? "restore";
|
|
33
54
|
|
|
34
|
-
// mode "
|
|
35
|
-
// don't subscribe, don't register pagehide —
|
|
36
|
-
//
|
|
37
|
-
|
|
55
|
+
// mode "native" = utility does nothing. Don't flip history.scrollRestoration,
|
|
56
|
+
// don't subscribe, don't register pagehide — `history.scrollRestoration`
|
|
57
|
+
// stays at the browser default ("auto") so the browser handles scroll
|
|
58
|
+
// restore natively. (Note: this is the OPPOSITE of `history.scrollRestoration
|
|
59
|
+
// === "manual"` — utility's "native" leaves the DOM property at "auto" so
|
|
60
|
+
// the browser is in charge.)
|
|
61
|
+
if (mode === "native") {
|
|
38
62
|
return NOOP_INSTANCE;
|
|
39
63
|
}
|
|
40
64
|
|
|
41
65
|
const anchorEnabled = options?.anchorScrolling ?? true;
|
|
42
66
|
const getContainer = options?.scrollContainer;
|
|
67
|
+
const behavior: ScrollBehavior = options?.behavior ?? "auto";
|
|
68
|
+
const storageKey = options?.storageKey ?? DEFAULT_STORAGE_KEY;
|
|
69
|
+
|
|
70
|
+
const loadStore = (): Record<string, number> => {
|
|
71
|
+
try {
|
|
72
|
+
const raw = sessionStorage.getItem(storageKey);
|
|
73
|
+
|
|
74
|
+
return raw ? (JSON.parse(raw) as Record<string, number>) : {};
|
|
75
|
+
} catch {
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const putPos = (key: string, pos: number): void => {
|
|
81
|
+
try {
|
|
82
|
+
const store = loadStore();
|
|
83
|
+
|
|
84
|
+
store[key] = pos;
|
|
85
|
+
sessionStorage.setItem(storageKey, JSON.stringify(store));
|
|
86
|
+
} catch {
|
|
87
|
+
// Ignore quota / security errors.
|
|
88
|
+
}
|
|
89
|
+
};
|
|
43
90
|
|
|
44
91
|
const prevScrollRestoration = history.scrollRestoration;
|
|
45
92
|
|
|
@@ -62,19 +109,44 @@ export function createScrollRestoration(
|
|
|
62
109
|
const element = getContainer?.();
|
|
63
110
|
|
|
64
111
|
if (element) {
|
|
65
|
-
element.
|
|
112
|
+
element.scrollTo({ top, left: 0, behavior });
|
|
66
113
|
} else {
|
|
67
|
-
globalThis.scrollTo(0,
|
|
114
|
+
globalThis.scrollTo({ top, left: 0, behavior });
|
|
68
115
|
}
|
|
69
116
|
};
|
|
70
117
|
|
|
71
|
-
const scrollToHashOrTop = (): void => {
|
|
118
|
+
const scrollToHashOrTop = (route: State): void => {
|
|
119
|
+
// URL plugin path (#532): `state.context.url.hash` is the source of truth
|
|
120
|
+
// when one of the URL plugins (browser-plugin / navigation-plugin) is
|
|
121
|
+
// installed. The value is already DECODED — feeding it through
|
|
122
|
+
// `decodeURIComponent` again would throw on a bare `%`.
|
|
123
|
+
const ctxHash = (route.context as { url?: { hash?: string } } | undefined)
|
|
124
|
+
?.url?.hash;
|
|
125
|
+
|
|
126
|
+
if (ctxHash !== undefined) {
|
|
127
|
+
if (anchorEnabled && ctxHash.length > 0) {
|
|
128
|
+
// eslint-disable-next-line unicorn/prefer-query-selector -- ids may contain CSS-unsafe chars
|
|
129
|
+
const element = document.getElementById(ctxHash);
|
|
130
|
+
|
|
131
|
+
if (element) {
|
|
132
|
+
element.scrollIntoView({ behavior });
|
|
133
|
+
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
writePos(0);
|
|
139
|
+
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Fallback path: no URL plugin, read the DOM. `location.hash` is
|
|
144
|
+
// percent-encoded; ids in the DOM are the raw string, so decode for the
|
|
145
|
+
// match. Fall back to the raw slice if the hash contains a malformed
|
|
146
|
+
// escape sequence (decodeURIComponent throws on those).
|
|
72
147
|
const hash = globalThis.location.hash;
|
|
73
148
|
|
|
74
149
|
if (anchorEnabled && hash.length > 1) {
|
|
75
|
-
// location.hash is percent-encoded; ids in the DOM are the raw string.
|
|
76
|
-
// Decode for the match. Fall back to the raw slice if the hash contains
|
|
77
|
-
// a malformed escape sequence (decodeURIComponent throws on those).
|
|
78
150
|
let id: string;
|
|
79
151
|
|
|
80
152
|
try {
|
|
@@ -87,7 +159,7 @@ export function createScrollRestoration(
|
|
|
87
159
|
const element = document.getElementById(id);
|
|
88
160
|
|
|
89
161
|
if (element) {
|
|
90
|
-
element.scrollIntoView();
|
|
162
|
+
element.scrollIntoView({ behavior });
|
|
91
163
|
|
|
92
164
|
return;
|
|
93
165
|
}
|
|
@@ -117,7 +189,7 @@ export function createScrollRestoration(
|
|
|
117
189
|
}
|
|
118
190
|
|
|
119
191
|
if (mode === "top" || !nav) {
|
|
120
|
-
scrollToHashOrTop();
|
|
192
|
+
scrollToHashOrTop(route);
|
|
121
193
|
|
|
122
194
|
return;
|
|
123
195
|
}
|
|
@@ -136,7 +208,7 @@ export function createScrollRestoration(
|
|
|
136
208
|
return;
|
|
137
209
|
}
|
|
138
210
|
|
|
139
|
-
scrollToHashOrTop();
|
|
211
|
+
scrollToHashOrTop(route);
|
|
140
212
|
});
|
|
141
213
|
});
|
|
142
214
|
|
|
@@ -173,27 +245,6 @@ function keyOf(state: State): string {
|
|
|
173
245
|
return `${state.name}:${canonicalJson(state.params)}`;
|
|
174
246
|
}
|
|
175
247
|
|
|
176
|
-
function loadStore(): Record<string, number> {
|
|
177
|
-
try {
|
|
178
|
-
const raw = sessionStorage.getItem(STORAGE_KEY);
|
|
179
|
-
|
|
180
|
-
return raw ? (JSON.parse(raw) as Record<string, number>) : {};
|
|
181
|
-
} catch {
|
|
182
|
-
return {};
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function putPos(key: string, pos: number): void {
|
|
187
|
-
try {
|
|
188
|
-
const store = loadStore();
|
|
189
|
-
|
|
190
|
-
store[key] = pos;
|
|
191
|
-
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(store));
|
|
192
|
-
} catch {
|
|
193
|
-
// Ignore quota / security errors.
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
248
|
function canonicalJson(value: unknown): string {
|
|
198
249
|
return JSON.stringify(value, canonicalReplacer);
|
|
199
250
|
}
|
|
@@ -9,13 +9,22 @@ import type { Params } from "@real-router/core";
|
|
|
9
9
|
export function injectIsActiveRoute(
|
|
10
10
|
routeName: string,
|
|
11
11
|
params?: Params,
|
|
12
|
-
options?: { strict?: boolean; ignoreQueryParams?: boolean },
|
|
12
|
+
options?: { strict?: boolean; ignoreQueryParams?: boolean; hash?: string },
|
|
13
13
|
): Signal<boolean> {
|
|
14
14
|
const router = injectRouter();
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
15
|
+
const strict = options?.strict ?? false;
|
|
16
|
+
const ignoreQueryParams = options?.ignoreQueryParams ?? true;
|
|
17
|
+
const hash = options?.hash;
|
|
18
|
+
// exactOptionalPropertyTypes forbids `{ hash: undefined }` literally — pass
|
|
19
|
+
// the field only when a value was provided. (#532)
|
|
20
|
+
const source = createActiveRouteSource(
|
|
21
|
+
router,
|
|
22
|
+
routeName,
|
|
23
|
+
params,
|
|
24
|
+
hash === undefined
|
|
25
|
+
? { strict, ignoreQueryParams }
|
|
26
|
+
: { strict, ignoreQueryParams, hash },
|
|
27
|
+
);
|
|
19
28
|
|
|
20
29
|
return sourceToSignal(source);
|
|
21
30
|
}
|