use-navigation-api 0.0.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 +29 -0
- package/eslint.config.js +22 -0
- package/package.json +44 -0
- package/src/index.ts +4 -0
- package/src/location/useLocation.ts +71 -0
- package/src/location/useQueryParam.ts +23 -0
- package/src/navigation-api-types.d.ts +266 -0
- package/src/navigationProvider.ts +98 -0
- package/src/useNavigate.ts +44 -0
- package/src/util/future.ts +21 -0
- package/tsconfig.app.json +30 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- package/vite.config.ts +28 -0
package/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# useNavigationAPI
|
|
2
|
+
|
|
3
|
+
Simple [Navigation API](https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API) integration for React.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
Wrap your application in a `<NavigationProvider logger={console.log}>`, optionally specifying a `store` if you wish to
|
|
8
|
+
store navigation locations
|
|
9
|
+
anywhere other than the browser's location or to modify the location before completing navigation,
|
|
10
|
+
for instance you can convert an app to use hash-based routing by specifying
|
|
11
|
+
`<NavigationProvider logger={console.log} store="hash">`,
|
|
12
|
+
or bypass the browser location entirely using `store="memory"`. Setting `scoped` to `true` will ignore navigation
|
|
13
|
+
events originating from components or elements outside the `<NavigationProvider logger={console.log}>` component.
|
|
14
|
+
|
|
15
|
+
TODO: test this library on older browsers with the https://github.com/virtualstate/navigation polyfill and update this
|
|
16
|
+
readme.
|
|
17
|
+
|
|
18
|
+
### Getting the current location
|
|
19
|
+
|
|
20
|
+
Calling `useLocation` with no arguments returns the current URL. helper functions such as `useQueryParam` can be used
|
|
21
|
+
for more fine-grained access.
|
|
22
|
+
|
|
23
|
+
### Navigation
|
|
24
|
+
|
|
25
|
+
Any element that would cause the browser to navigate can be used. Note that the browser will resolve locations relative
|
|
26
|
+
to the current window location, so to ensure unsurprising behavior of relative links you should resolve them relative to
|
|
27
|
+
the navigation context by passing them to `useLocation`.
|
|
28
|
+
For programmatic navigation, the `useNavigation` hook returns a
|
|
29
|
+
[Navigation](https://developer.mozilla.org/en-US/docs/Web/API/Navigation) object.
|
package/eslint.config.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import js from "@eslint/js";
|
|
2
|
+
import globals from "globals";
|
|
3
|
+
import reactHooks from "eslint-plugin-react-hooks";
|
|
4
|
+
import reactRefresh from "eslint-plugin-react-refresh";
|
|
5
|
+
import tseslint from "typescript-eslint";
|
|
6
|
+
import { defineConfig, globalIgnores } from "eslint/config";
|
|
7
|
+
|
|
8
|
+
export default defineConfig([
|
|
9
|
+
globalIgnores(["dist"]),
|
|
10
|
+
{
|
|
11
|
+
files: ["**/*.{ts,tsx}"],
|
|
12
|
+
extends: [
|
|
13
|
+
js.configs.recommended,
|
|
14
|
+
tseslint.configs.recommended,
|
|
15
|
+
reactHooks.configs.flat.recommended,
|
|
16
|
+
],
|
|
17
|
+
languageOptions: {
|
|
18
|
+
ecmaVersion: 2020,
|
|
19
|
+
globals: globals.browser,
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
]);
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "use-navigation-api",
|
|
3
|
+
"private": false,
|
|
4
|
+
"main": "./dist/use-navigation-api.umd.js",
|
|
5
|
+
"module": "./dist/use-navigation-api.es.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/use-navigation-api.es.js",
|
|
11
|
+
"require": "./dist/use-navigation-api.umd.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"version": "0.0.0",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc -b && vite build",
|
|
18
|
+
"lint": "eslint ."
|
|
19
|
+
},
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
22
|
+
"react-dom": "^18.0.0 || ^19.0.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@eslint/js": "^9.39.1",
|
|
26
|
+
"@types/node": "^24.10.1",
|
|
27
|
+
"@types/react": "^19.2.5",
|
|
28
|
+
"@types/react-dom": "^19.2.3",
|
|
29
|
+
"@vitejs/plugin-react": "^5.1.1",
|
|
30
|
+
"eslint": "^9.39.1",
|
|
31
|
+
"eslint-plugin-react-hooks": "^7.0.1",
|
|
32
|
+
"eslint-plugin-react-refresh": "^0.4.24",
|
|
33
|
+
"globals": "^16.5.0",
|
|
34
|
+
"navigation-api-types": "^0.6.1",
|
|
35
|
+
"prettier": "^3.8.1",
|
|
36
|
+
"typescript": "~5.9.3",
|
|
37
|
+
"typescript-eslint": "^8.46.4",
|
|
38
|
+
"unplugin-dts": "^1.0.0-beta.6",
|
|
39
|
+
"vite": "npm:rolldown-vite@7.2.5"
|
|
40
|
+
},
|
|
41
|
+
"overrides": {
|
|
42
|
+
"vite": "npm:rolldown-vite@7.2.5"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useContext, useMemo } from "react";
|
|
2
|
+
import {
|
|
3
|
+
NavigationContext,
|
|
4
|
+
type NavigationContextState,
|
|
5
|
+
} from "src/navigationProvider.ts";
|
|
6
|
+
|
|
7
|
+
type Location = { pathname: string; search: string; hash: string; url?: URL };
|
|
8
|
+
|
|
9
|
+
export function parseLocation(
|
|
10
|
+
location: string,
|
|
11
|
+
{
|
|
12
|
+
url = window?.location?.href || "https://example.com/",
|
|
13
|
+
}: NavigationContextState,
|
|
14
|
+
): Location {
|
|
15
|
+
try {
|
|
16
|
+
const parsed = new URL(location, url);
|
|
17
|
+
return {
|
|
18
|
+
url: parsed,
|
|
19
|
+
pathname: parsed.pathname,
|
|
20
|
+
search: parsed.search,
|
|
21
|
+
hash: parsed.hash,
|
|
22
|
+
};
|
|
23
|
+
} catch {
|
|
24
|
+
/* fall back to string parsing */
|
|
25
|
+
}
|
|
26
|
+
if (!location) return { pathname: "/", search: "", hash: "" };
|
|
27
|
+
const pathname = location.split("?")[0];
|
|
28
|
+
const search = location.substring(pathname.length).split("#")[0];
|
|
29
|
+
const hash = location.substring(pathname.length + search.length);
|
|
30
|
+
try {
|
|
31
|
+
return { url: undefined, pathname, search, hash };
|
|
32
|
+
} catch {
|
|
33
|
+
return { url: undefined, pathname, search, hash };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Returns the current location for the navigation context.
|
|
39
|
+
*/
|
|
40
|
+
export function useLocation(): Location;
|
|
41
|
+
/**
|
|
42
|
+
* Resolves a URL string relative to the current location and returns its parts.
|
|
43
|
+
*/
|
|
44
|
+
export function useLocation(url: string): Location;
|
|
45
|
+
/**
|
|
46
|
+
* Resolves a URL string relative to the current location after applying the supplied function.
|
|
47
|
+
*/
|
|
48
|
+
export function useLocation<R>(
|
|
49
|
+
url: string,
|
|
50
|
+
parse: (location: string, value: NavigationContextState) => R,
|
|
51
|
+
): R;
|
|
52
|
+
/**
|
|
53
|
+
* Returns a location based on the returned url if present, or the modified input argument.
|
|
54
|
+
* For instance, `useLocation(url => url.searchParams.set("parameter", "value"))` returns the current location with only a single parameter updated.
|
|
55
|
+
*/
|
|
56
|
+
export function useLocation(url: (current: URL) => URL | void): Location;
|
|
57
|
+
export function useLocation(
|
|
58
|
+
url?: string | ((current: URL) => URL | void),
|
|
59
|
+
parse = parseLocation,
|
|
60
|
+
) {
|
|
61
|
+
const location = useContext(NavigationContext);
|
|
62
|
+
return useMemo(() => {
|
|
63
|
+
if (typeof url === "string") return parse(url, location);
|
|
64
|
+
if (typeof url === "function") {
|
|
65
|
+
const modified = new URL(location.url);
|
|
66
|
+
const returned = url(modified);
|
|
67
|
+
return parse((returned || modified).href, location);
|
|
68
|
+
}
|
|
69
|
+
return parse("", location);
|
|
70
|
+
}, [parse, url, location]);
|
|
71
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useLocation } from "src/location/useLocation.ts";
|
|
2
|
+
import { useMemo } from "react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Returns the first value for a query parameter, or null if it is missing.
|
|
6
|
+
*/
|
|
7
|
+
export function useQueryParam(param: string, all?: false): string | null;
|
|
8
|
+
/**
|
|
9
|
+
* Returns all values for a query parameter when it appears multiple times.
|
|
10
|
+
*/
|
|
11
|
+
export function useQueryParam(param: string, all: true): string[];
|
|
12
|
+
export function useQueryParam(param: string, all: boolean = false) {
|
|
13
|
+
const location = useLocation();
|
|
14
|
+
return useMemo(() => {
|
|
15
|
+
try {
|
|
16
|
+
const searchParams =
|
|
17
|
+
location.url?.searchParams || new URLSearchParams(location.search);
|
|
18
|
+
return all ? searchParams.getAll(param) || [] : searchParams.get(param);
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}, [location, all, param]);
|
|
23
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// From https://github.com/lukewarlow/navigation-api-types/blob/master/index.d.ts
|
|
2
|
+
|
|
3
|
+
/** @see https://html.spec.whatwg.org/multipage/nav-history-apis.html#navigation */
|
|
4
|
+
interface Navigation extends EventTarget {
|
|
5
|
+
entries(): NavigationHistoryEntry[];
|
|
6
|
+
readonly currentEntry?: NavigationHistoryEntry;
|
|
7
|
+
updateCurrentEntry(options: NavigationUpdateCurrentEntryOptions): void;
|
|
8
|
+
readonly transition?: NavigationTransition;
|
|
9
|
+
|
|
10
|
+
readonly canGoBack: boolean;
|
|
11
|
+
readonly canGoForward: boolean;
|
|
12
|
+
|
|
13
|
+
navigate(url: string, options?: NavigationNavigateOptions): NavigationResult;
|
|
14
|
+
reload(options?: NavigationReloadOptions): NavigationResult;
|
|
15
|
+
|
|
16
|
+
traverseTo(key: string, options?: NavigationOptions): NavigationResult;
|
|
17
|
+
back(options?: NavigationOptions): NavigationResult;
|
|
18
|
+
forward(options?: NavigationOptions): NavigationResult;
|
|
19
|
+
|
|
20
|
+
onnavigate:
|
|
21
|
+
| ((this: Navigation, ev: NavigationEventMap["navigate"]) => unknown)
|
|
22
|
+
| null;
|
|
23
|
+
onnavigatesuccess:
|
|
24
|
+
| ((this: Navigation, ev: NavigationEventMap["navigatesuccess"]) => unknown)
|
|
25
|
+
| null;
|
|
26
|
+
onnavigateerror:
|
|
27
|
+
| ((this: Navigation, ev: NavigationEventMap["navigateerror"]) => unknown)
|
|
28
|
+
| null;
|
|
29
|
+
oncurrententrychange:
|
|
30
|
+
| ((
|
|
31
|
+
this: Navigation,
|
|
32
|
+
ev: NavigationEventMap["currententrychange"],
|
|
33
|
+
) => unknown)
|
|
34
|
+
| null;
|
|
35
|
+
|
|
36
|
+
addEventListener<K extends keyof NavigationEventMap>(
|
|
37
|
+
type: K,
|
|
38
|
+
listener: (this: Navigation, ev: NavigationEventMap[K]) => unknown,
|
|
39
|
+
options?: boolean | AddEventListenerOptions,
|
|
40
|
+
): void;
|
|
41
|
+
addEventListener(
|
|
42
|
+
type: string,
|
|
43
|
+
listener: EventListenerOrEventListenerObject,
|
|
44
|
+
options?: boolean | AddEventListenerOptions,
|
|
45
|
+
): void;
|
|
46
|
+
removeEventListener<K extends keyof NavigationEventMap>(
|
|
47
|
+
type: K,
|
|
48
|
+
listener: (this: Navigation, ev: NavigationEventMap[K]) => unknown,
|
|
49
|
+
options?: boolean | EventListenerOptions,
|
|
50
|
+
): void;
|
|
51
|
+
removeEventListener(
|
|
52
|
+
type: string,
|
|
53
|
+
listener: EventListenerOrEventListenerObject,
|
|
54
|
+
options?: boolean | EventListenerOptions,
|
|
55
|
+
): void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface NavigationEventMap {
|
|
59
|
+
navigate: NavigateEvent;
|
|
60
|
+
navigatesuccess: Event;
|
|
61
|
+
navigateerror: ErrorEvent;
|
|
62
|
+
currententrychange: NavigationCurrentEntryChangeEvent;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
declare const navigation: Navigation | undefined;
|
|
66
|
+
|
|
67
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
68
|
+
declare interface Window extends WindowNavigation {}
|
|
69
|
+
|
|
70
|
+
declare interface WindowNavigation {
|
|
71
|
+
readonly navigation?: Navigation;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** @see https://html.spec.whatwg.org/multipage/nav-history-apis.html#navigateevent */
|
|
75
|
+
interface NavigateEvent extends Event {
|
|
76
|
+
readonly navigationType: NavigationApiNavigationType;
|
|
77
|
+
readonly destination: NavigationDestination;
|
|
78
|
+
readonly canIntercept: boolean;
|
|
79
|
+
readonly userInitiated: boolean;
|
|
80
|
+
readonly hashChange: boolean;
|
|
81
|
+
readonly signal: AbortSignal;
|
|
82
|
+
readonly formData: FormData | null;
|
|
83
|
+
readonly downloadRequest: string | null;
|
|
84
|
+
readonly info: unknown;
|
|
85
|
+
readonly hasUAVisualTransition: boolean;
|
|
86
|
+
readonly sourceElement: Element | null;
|
|
87
|
+
|
|
88
|
+
intercept(options?: NavigationInterceptOptions): void;
|
|
89
|
+
/**
|
|
90
|
+
* Not in HTML spec but implemented in Chromium
|
|
91
|
+
* @see https://github.com/WICG/navigation-api?tab=readme-ov-file#deferred-commit
|
|
92
|
+
* @experimental
|
|
93
|
+
*/
|
|
94
|
+
commit(): void;
|
|
95
|
+
/**
|
|
96
|
+
* Not in HTML spec but implemented in Chromium
|
|
97
|
+
* @see https://github.com/WICG/navigation-api?tab=readme-ov-file#redirects-during-deferred-commit
|
|
98
|
+
* @experimental
|
|
99
|
+
*/
|
|
100
|
+
redirect(): void;
|
|
101
|
+
scroll(): void;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
declare const NavigateEvent: {
|
|
105
|
+
prototype: NavigateEvent;
|
|
106
|
+
new (type: string, eventInit: NavigateEventInit): Event;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** @see https://html.spec.whatwg.org/multipage/nav-history-apis.html#navigateeventinit */
|
|
110
|
+
interface NavigateEventInit extends EventInit {
|
|
111
|
+
navigationType?: NavigationApiNavigationType;
|
|
112
|
+
destination: NavigationDestination;
|
|
113
|
+
canIntercept?: boolean;
|
|
114
|
+
userInitiated?: boolean;
|
|
115
|
+
hashChange?: boolean;
|
|
116
|
+
signal: AbortSignal;
|
|
117
|
+
formData?: FormData | null;
|
|
118
|
+
downloadRequest?: string | null;
|
|
119
|
+
info?: unknown;
|
|
120
|
+
hasUAVisualTransition?: boolean;
|
|
121
|
+
sourceElement?: Element | null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** @see https://html.spec.whatwg.org/multipage/nav-history-apis.html#the-navigationcurrententrychangeevent-interface */
|
|
125
|
+
interface NavigationCurrentEntryChangeEvent extends Event {
|
|
126
|
+
readonly navigationType?: NavigationApiNavigationType;
|
|
127
|
+
readonly from: NavigationHistoryEntry;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
declare const NavigationCurrentEntryChangeEvent: {
|
|
131
|
+
prototype: NavigationCurrentEntryChangeEvent;
|
|
132
|
+
new (type: string, eventInit: NavigationCurrentEntryChangeEventInit): Event;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** @see https://html.spec.whatwg.org/multipage/nav-history-apis.html#navigationcurrententrychangeeventinit */
|
|
136
|
+
interface NavigationCurrentEntryChangeEventInit extends EventInit {
|
|
137
|
+
navigationType?: NavigationApiNavigationType;
|
|
138
|
+
destination: NavigationHistoryEntry;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** @see https://html.spec.whatwg.org/multipage/nav-history-apis.html#navigationhistoryentry */
|
|
142
|
+
interface NavigationHistoryEntry extends EventTarget {
|
|
143
|
+
readonly url: string | null;
|
|
144
|
+
readonly key: string;
|
|
145
|
+
readonly id: string;
|
|
146
|
+
readonly index: number;
|
|
147
|
+
readonly sameDocument: boolean;
|
|
148
|
+
|
|
149
|
+
getState(): unknown;
|
|
150
|
+
|
|
151
|
+
ondispose: ((this: NavigationHistoryEntry, ev: Event) => unknown) | null;
|
|
152
|
+
|
|
153
|
+
addEventListener(
|
|
154
|
+
type: "dispose",
|
|
155
|
+
callback: (this: NavigationHistoryEntry, ev: Event) => unknown,
|
|
156
|
+
options?: boolean | AddEventListenerOptions,
|
|
157
|
+
): void;
|
|
158
|
+
addEventListener(
|
|
159
|
+
type: string,
|
|
160
|
+
callback: EventListenerOrEventListenerObject,
|
|
161
|
+
options?: boolean | AddEventListenerOptions,
|
|
162
|
+
): void;
|
|
163
|
+
removeEventListener(
|
|
164
|
+
type: "dispose",
|
|
165
|
+
callback: (this: NavigationHistoryEntry, ev: Event) => unknown,
|
|
166
|
+
options?: boolean | EventListenerOptions,
|
|
167
|
+
): void;
|
|
168
|
+
removeEventListener(
|
|
169
|
+
type: string,
|
|
170
|
+
callback: EventListenerOrEventListenerObject,
|
|
171
|
+
options?: boolean | EventListenerOptions,
|
|
172
|
+
): void;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** @see https://html.spec.whatwg.org/multipage/nav-history-apis.html#navigationdestination */
|
|
176
|
+
interface NavigationDestination {
|
|
177
|
+
readonly url: string;
|
|
178
|
+
readonly key: string | null;
|
|
179
|
+
readonly id: string | null;
|
|
180
|
+
readonly index: number;
|
|
181
|
+
readonly sameDocument: boolean;
|
|
182
|
+
|
|
183
|
+
getState(): unknown;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** @see https://html.spec.whatwg.org/multipage/nav-history-apis.html#navigationupdatecurrententryoptions */
|
|
187
|
+
interface NavigationUpdateCurrentEntryOptions {
|
|
188
|
+
state: unknown;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** @see https://html.spec.whatwg.org/multipage/nav-history-apis.html#navigationoptions */
|
|
192
|
+
interface NavigationOptions {
|
|
193
|
+
info?: unknown;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** @see https://html.spec.whatwg.org/multipage/nav-history-apis.html#navigationnavigateoptions */
|
|
197
|
+
interface NavigationNavigateOptions extends NavigationOptions {
|
|
198
|
+
state?: unknown;
|
|
199
|
+
// Defaults to "auto"
|
|
200
|
+
history?: NavigationHistoryBehavior;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** @see https://html.spec.whatwg.org/multipage/nav-history-apis.html#navigationreloadoptions */
|
|
204
|
+
interface NavigationReloadOptions extends NavigationOptions {
|
|
205
|
+
state?: unknown;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** @see https://html.spec.whatwg.org/multipage/nav-history-apis.html#navigationtransition */
|
|
209
|
+
interface NavigationTransition {
|
|
210
|
+
readonly navigationType: NavigationApiNavigationType;
|
|
211
|
+
readonly from: NavigationHistoryEntry;
|
|
212
|
+
readonly finished: Promise<void>;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** @see https://html.spec.whatwg.org/multipage/nav-history-apis.html#navigationresult */
|
|
216
|
+
interface NavigationResult {
|
|
217
|
+
committed: Promise<NavigationHistoryEntry>;
|
|
218
|
+
finished: Promise<NavigationHistoryEntry>;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** @see https://html.spec.whatwg.org/multipage/nav-history-apis.html#navigationinterceptoptions */
|
|
222
|
+
interface NavigationInterceptOptions {
|
|
223
|
+
handler?: NavigationInterceptHandler;
|
|
224
|
+
precommitHandler?: NavigationPrecommitHandler;
|
|
225
|
+
focusReset?: NavigationFocusReset;
|
|
226
|
+
scroll?: NavigationScrollBehavior;
|
|
227
|
+
/**
|
|
228
|
+
* Not in HTML spec but implemented in Chromium
|
|
229
|
+
* @see https://github.com/WICG/navigation-api?tab=readme-ov-file#deferred-commit
|
|
230
|
+
* @experimental
|
|
231
|
+
*/
|
|
232
|
+
commit?: NavigationCommitBehavior;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** @see https://html.spec.whatwg.org/multipage/nav-history-apis.html#navigationtype */
|
|
236
|
+
type NavigationApiNavigationType = "reload" | "push" | "replace" | "traverse";
|
|
237
|
+
|
|
238
|
+
/** @see https://html.spec.whatwg.org/multipage/nav-history-apis.html#navigationhistorybehavior */
|
|
239
|
+
type NavigationHistoryBehavior = "auto" | "push" | "replace";
|
|
240
|
+
|
|
241
|
+
/** @see https://html.spec.whatwg.org/multipage/nav-history-apis.html#navigationintercepthandler */
|
|
242
|
+
type NavigationInterceptHandler = () => Promise<void>;
|
|
243
|
+
|
|
244
|
+
/** @see https://html.spec.whatwg.org/multipage/nav-history-apis.html#navigationprecommithandler */
|
|
245
|
+
type NavigationPrecommitHandler = (
|
|
246
|
+
controller: NavigationPrecommitController,
|
|
247
|
+
) => Promise<void>;
|
|
248
|
+
|
|
249
|
+
/** @see https://html.spec.whatwg.org/multipage/nav-history-apis.html#navigationprecommitcontroller */
|
|
250
|
+
interface NavigationPrecommitController {
|
|
251
|
+
redirect: (url: string, options?: NavigationNavigateOptions = {}) => void;
|
|
252
|
+
addHandler: (handler: NavigationInterceptHandler) => void;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** @see https://html.spec.whatwg.org/multipage/nav-history-apis.html#navigationfocusreset */
|
|
256
|
+
type NavigationFocusReset = "after-transition" | "manual";
|
|
257
|
+
|
|
258
|
+
/** @see https://html.spec.whatwg.org/multipage/nav-history-apis.html#navigationscrollbehavior */
|
|
259
|
+
type NavigationScrollBehavior = "after-transition" | "manual";
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Not in HTML spec but implemented in Chromium
|
|
263
|
+
* @see https://github.com/WICG/navigation-api?tab=readme-ov-file#deferred-commit
|
|
264
|
+
* @experimental
|
|
265
|
+
*/
|
|
266
|
+
type NavigationCommitBehavior = "after-transition" | "immediate";
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
createElement,
|
|
4
|
+
type Dispatch,
|
|
5
|
+
type FC,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
type SetStateAction,
|
|
8
|
+
useEffect,
|
|
9
|
+
useMemo,
|
|
10
|
+
useState,
|
|
11
|
+
} from "react";
|
|
12
|
+
|
|
13
|
+
export type NavigationContextState = {
|
|
14
|
+
navigation: Navigation;
|
|
15
|
+
url: string;
|
|
16
|
+
store: "url" | "hash" | "memory";
|
|
17
|
+
};
|
|
18
|
+
type NavigationContextValue = NavigationContextState & {
|
|
19
|
+
setState?: Dispatch<SetStateAction<NavigationContextState>>;
|
|
20
|
+
};
|
|
21
|
+
const defaultValue: NavigationContextValue = {
|
|
22
|
+
navigation: window.navigation!,
|
|
23
|
+
url: window?.location?.href || "/",
|
|
24
|
+
store: "url" as "url" | "hash" | "memory",
|
|
25
|
+
};
|
|
26
|
+
export const NavigationContext = createContext(defaultValue);
|
|
27
|
+
|
|
28
|
+
function defaultShouldHandle(event: NavigateEvent) {
|
|
29
|
+
return event.canIntercept && !event.downloadRequest;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type ShouldHandle = typeof defaultShouldHandle;
|
|
33
|
+
export const NavigationProvider: FC<{
|
|
34
|
+
children: ReactNode;
|
|
35
|
+
store?: "url" | "hash" | "memory";
|
|
36
|
+
scoped?: boolean;
|
|
37
|
+
shouldHandle?: ShouldHandle;
|
|
38
|
+
}> = ({
|
|
39
|
+
children,
|
|
40
|
+
store = "url",
|
|
41
|
+
scoped,
|
|
42
|
+
shouldHandle = defaultShouldHandle,
|
|
43
|
+
}) => {
|
|
44
|
+
const navigation = defaultValue.navigation;
|
|
45
|
+
const [scope, setScope] = useState<HTMLDivElement | null>(null);
|
|
46
|
+
const [state, setState] = useState(() => ({ ...defaultValue, store }));
|
|
47
|
+
const skip = useMemo(
|
|
48
|
+
() => (event: NavigateEvent) => {
|
|
49
|
+
const target = event.sourceElement;
|
|
50
|
+
if (scope && target && !scope.contains(target)) return true;
|
|
51
|
+
return !shouldHandle(event);
|
|
52
|
+
},
|
|
53
|
+
[shouldHandle, scope],
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
const handler = (event: NavigateEvent) => {
|
|
58
|
+
if (skip(event)) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let url = event.destination.url;
|
|
63
|
+
if (store === "memory") {
|
|
64
|
+
event.preventDefault();
|
|
65
|
+
|
|
66
|
+
setState((prev) => {
|
|
67
|
+
if (
|
|
68
|
+
event.sourceElement instanceof HTMLAnchorElement &&
|
|
69
|
+
event.navigationType === "push"
|
|
70
|
+
) {
|
|
71
|
+
const href = event.sourceElement.getAttribute("href");
|
|
72
|
+
if (href && !href.startsWith("/") && !/^[a-z]+:\/\//i.test(href)) {
|
|
73
|
+
url = new URL(href, prev.url).href;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { ...prev, url, store };
|
|
77
|
+
});
|
|
78
|
+
} else {
|
|
79
|
+
event.intercept({
|
|
80
|
+
async handler() {
|
|
81
|
+
setState({ navigation, url, store });
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
navigation?.addEventListener("navigate", handler);
|
|
88
|
+
return () => navigation?.removeEventListener("navigate", handler);
|
|
89
|
+
}, [skip, navigation, scoped, store]);
|
|
90
|
+
|
|
91
|
+
const value = useMemo(() => ({ ...state, setState }), [state]);
|
|
92
|
+
|
|
93
|
+
return createElement(
|
|
94
|
+
NavigationContext.Provider,
|
|
95
|
+
{ value },
|
|
96
|
+
scoped ? createElement("div", { ref: setScope }, children) : children,
|
|
97
|
+
);
|
|
98
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useContext, useMemo } from "react";
|
|
2
|
+
import { NavigationContext } from "src/navigationProvider";
|
|
3
|
+
import { future } from "src/util/future";
|
|
4
|
+
|
|
5
|
+
export function useNavigate() {
|
|
6
|
+
const { navigation, setState } = useContext(NavigationContext);
|
|
7
|
+
return useMemo(() => {
|
|
8
|
+
if (!setState) return navigation;
|
|
9
|
+
const navigate: Navigation["navigate"] = (destination, options) => {
|
|
10
|
+
const result = {
|
|
11
|
+
committed: future(true),
|
|
12
|
+
finished: future(true),
|
|
13
|
+
};
|
|
14
|
+
const handle = ({ committed, finished }: NavigationResult) => {
|
|
15
|
+
committed.then(result.committed.resolve, result.committed.reject);
|
|
16
|
+
finished.then(result.finished.resolve, result.finished.reject);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
setState((state) => {
|
|
20
|
+
Promise.resolve().then(() => {
|
|
21
|
+
try {
|
|
22
|
+
handle(
|
|
23
|
+
navigation.navigate(
|
|
24
|
+
new URL(destination, state.url).href,
|
|
25
|
+
options,
|
|
26
|
+
),
|
|
27
|
+
);
|
|
28
|
+
} catch {
|
|
29
|
+
handle(navigation.navigate(destination, options));
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
return state;
|
|
33
|
+
});
|
|
34
|
+
return result as NavigationResult;
|
|
35
|
+
};
|
|
36
|
+
return new Proxy(navigation, {
|
|
37
|
+
get(target, prop, receiver) {
|
|
38
|
+
if (prop === "navigate") return navigate;
|
|
39
|
+
const value = Reflect.get(target, prop, receiver);
|
|
40
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
}, [navigation, setState]);
|
|
44
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
type Resolution<T> = {
|
|
2
|
+
resolve: (value: T) => void;
|
|
3
|
+
reject: (reason?: unknown) => void;
|
|
4
|
+
};
|
|
5
|
+
type Future<T> = Promise<T> & Resolution<T>;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Returns a Promise that can be resolved or rejected from outside.
|
|
9
|
+
* @param suppressUncaught If true, adds a dummy catch handler to bypass browsers' uncaught error logging.
|
|
10
|
+
*/
|
|
11
|
+
export function future<T>(suppressUncaught?: boolean) {
|
|
12
|
+
const resolution = {} as Resolution<T>;
|
|
13
|
+
const result = new Promise<T>((resolve, reject) => {
|
|
14
|
+
resolution.resolve = resolve;
|
|
15
|
+
resolution.reject = reject;
|
|
16
|
+
}) as Future<T>;
|
|
17
|
+
result.resolve = resolution.resolve;
|
|
18
|
+
result.reject = resolution.reject;
|
|
19
|
+
if (suppressUncaught) result.catch(() => {});
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
|
4
|
+
"target": "ES2022",
|
|
5
|
+
"useDefineForClassFields": true,
|
|
6
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
7
|
+
"module": "ESNext",
|
|
8
|
+
"types": ["vite/client"],
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
|
|
11
|
+
/* Bundler mode */
|
|
12
|
+
"moduleResolution": "bundler",
|
|
13
|
+
"allowImportingTsExtensions": true,
|
|
14
|
+
"verbatimModuleSyntax": true,
|
|
15
|
+
"moduleDetection": "force",
|
|
16
|
+
"noEmit": true,
|
|
17
|
+
"jsx": "react-jsx",
|
|
18
|
+
|
|
19
|
+
/* Linting */
|
|
20
|
+
"strict": true,
|
|
21
|
+
"noUnusedLocals": true,
|
|
22
|
+
"noUnusedParameters": true,
|
|
23
|
+
"erasableSyntaxOnly": true,
|
|
24
|
+
"noFallthroughCasesInSwitch": true,
|
|
25
|
+
"noUncheckedSideEffectImports": true,
|
|
26
|
+
|
|
27
|
+
"baseUrl": "."
|
|
28
|
+
},
|
|
29
|
+
"include": ["src"]
|
|
30
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
|
4
|
+
"target": "ES2023",
|
|
5
|
+
"lib": ["ES2023"],
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"types": ["node"],
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
|
|
10
|
+
/* Bundler mode */
|
|
11
|
+
"moduleResolution": "bundler",
|
|
12
|
+
"allowImportingTsExtensions": true,
|
|
13
|
+
"verbatimModuleSyntax": true,
|
|
14
|
+
"moduleDetection": "force",
|
|
15
|
+
"noEmit": true,
|
|
16
|
+
|
|
17
|
+
/* Linting */
|
|
18
|
+
"strict": true,
|
|
19
|
+
"noUnusedLocals": true,
|
|
20
|
+
"noUnusedParameters": true,
|
|
21
|
+
"erasableSyntaxOnly": true,
|
|
22
|
+
"noFallthroughCasesInSwitch": true,
|
|
23
|
+
"noUncheckedSideEffectImports": true
|
|
24
|
+
},
|
|
25
|
+
"include": ["vite.config.ts"]
|
|
26
|
+
}
|
package/vite.config.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { defineConfig } from "vite";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
import dts from "unplugin-dts/vite";
|
|
4
|
+
import react from "@vitejs/plugin-react";
|
|
5
|
+
import tsconfigPaths from "vite-tsconfig-paths";
|
|
6
|
+
|
|
7
|
+
export default defineConfig({
|
|
8
|
+
build: {
|
|
9
|
+
lib: {
|
|
10
|
+
entry: resolve(__dirname, "src/index.ts"),
|
|
11
|
+
name: "useNavigationAPI",
|
|
12
|
+
fileName: (format) => `use-navigation-api.${format}.js`,
|
|
13
|
+
},
|
|
14
|
+
rollupOptions: {
|
|
15
|
+
external: ["react", "react-dom", "react/jsx-runtime"],
|
|
16
|
+
output: {
|
|
17
|
+
globals: {
|
|
18
|
+
react: "React",
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
plugins: [
|
|
24
|
+
react(),
|
|
25
|
+
dts({ tsconfigPath: "./tsconfig.app.json" }),
|
|
26
|
+
tsconfigPaths(),
|
|
27
|
+
],
|
|
28
|
+
});
|