@djangocfg/ui-core 2.1.615 → 2.1.617
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/package.json +12 -8
- package/src/hooks/index.ts +4 -0
- package/src/hooks/router/README.md +31 -1
- package/src/hooks/router/adapter.tsx +40 -2
- package/src/hooks/router/adapters/nextjs.tsx +60 -23
- package/src/hooks/router/index.ts +5 -0
- package/src/hooks/router/useNavigate.ts +34 -9
- package/src/hooks/router/useQueryState.ts +45 -12
- package/src/hooks/router/useRouter.ts +1 -0
- package/src/hooks/router/useScrollToTop.ts +7 -0
- package/src/route-progress/NOTICE +5 -0
- package/src/route-progress/README.md +26 -0
- package/src/route-progress/behavior/store.ts +58 -0
- package/src/route-progress/behavior/useNavigationBehavior.ts +23 -0
- package/src/route-progress/engine/createProgressEngine.ts +87 -0
- package/src/route-progress/engine/types.ts +34 -0
- package/src/route-progress/index.ts +16 -0
- package/src/route-progress/provider/RouteProgressProvider.tsx +74 -0
- package/src/route-progress/provider/context.ts +22 -0
- package/src/route-progress/provider/shouldStart.ts +19 -0
- package/src/route-progress/triggers/attach.ts +30 -0
- package/src/route-progress/triggers/linkClick.ts +42 -0
- package/src/route-progress/view/RouteProgressBar.tsx +62 -0
- package/src/route-progress/view/useReducedMotion.ts +16 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-core",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.617",
|
|
4
4
|
"description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-components",
|
|
@@ -46,6 +46,11 @@
|
|
|
46
46
|
"import": "./src/hooks/index.ts",
|
|
47
47
|
"require": "./src/hooks/index.ts"
|
|
48
48
|
},
|
|
49
|
+
"./route-progress": {
|
|
50
|
+
"types": "./src/route-progress/index.ts",
|
|
51
|
+
"import": "./src/route-progress/index.ts",
|
|
52
|
+
"require": "./src/route-progress/index.ts"
|
|
53
|
+
},
|
|
49
54
|
"./theme": {
|
|
50
55
|
"types": "./src/theme/index.ts",
|
|
51
56
|
"import": "./src/theme/index.ts",
|
|
@@ -133,8 +138,8 @@
|
|
|
133
138
|
"check:docs": "node scripts/check-docs.mjs"
|
|
134
139
|
},
|
|
135
140
|
"peerDependencies": {
|
|
136
|
-
"@djangocfg/api": "^2.1.
|
|
137
|
-
"@djangocfg/i18n": "^2.1.
|
|
141
|
+
"@djangocfg/api": "^2.1.617",
|
|
142
|
+
"@djangocfg/i18n": "^2.1.617",
|
|
138
143
|
"consola": "^3.4.2",
|
|
139
144
|
"lucide-react": "^1.68.0",
|
|
140
145
|
"moment": "^2.30.1",
|
|
@@ -200,7 +205,6 @@
|
|
|
200
205
|
"i18n-iso-countries": "^7.14.0",
|
|
201
206
|
"input-otp": "1.5.0",
|
|
202
207
|
"libphonenumber-js": "^1.13.15",
|
|
203
|
-
"nextjs-toploader": "^3.9.17",
|
|
204
208
|
"react-day-picker": "10.0.2",
|
|
205
209
|
"react-hotkeys-hook": "^5.3.3",
|
|
206
210
|
"react-resizable-panels": "3.0.6",
|
|
@@ -210,10 +214,10 @@
|
|
|
210
214
|
"vaul": "1.1.2"
|
|
211
215
|
},
|
|
212
216
|
"devDependencies": {
|
|
213
|
-
"@djangocfg/api": "^2.1.
|
|
214
|
-
"@djangocfg/eslint-config": "^2.1.
|
|
215
|
-
"@djangocfg/i18n": "^2.1.
|
|
216
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
217
|
+
"@djangocfg/api": "^2.1.617",
|
|
218
|
+
"@djangocfg/eslint-config": "^2.1.617",
|
|
219
|
+
"@djangocfg/i18n": "^2.1.617",
|
|
220
|
+
"@djangocfg/typescript-config": "^2.1.617",
|
|
217
221
|
"@storybook/react-vite": "^10.6.5",
|
|
218
222
|
"@types/node": "^24.13.3",
|
|
219
223
|
"@types/react": "19.3.0",
|
package/src/hooks/index.ts
CHANGED
|
@@ -68,9 +68,13 @@ export {
|
|
|
68
68
|
useRouter,
|
|
69
69
|
// useScrollToTop (covers the routes Next's own scroll handler skips)
|
|
70
70
|
useScrollToTop,
|
|
71
|
+
// Per-page switches for the scroll jump and the loading bar
|
|
72
|
+
useNavigationBehavior,
|
|
71
73
|
} from './router';
|
|
72
74
|
export type {
|
|
73
75
|
RouterAdapter,
|
|
76
|
+
RouterNavigateOptions,
|
|
77
|
+
NavigationBehavior,
|
|
74
78
|
RouterAdapterProviderProps,
|
|
75
79
|
RouterLocation,
|
|
76
80
|
LocationSnapshot,
|
|
@@ -18,6 +18,7 @@ Framework-agnostic navigation primitives for `@djangocfg/ui-core`. Built on plai
|
|
|
18
18
|
| `useLocationProperty` | Subscribe to ONE derived field of `window.location` (avoids re-renders on unrelated fields). |
|
|
19
19
|
| `useRouter` | Convenience facade composing the above. |
|
|
20
20
|
| `RouterAdapterProvider` | Swap the navigation backend (e.g. Next.js's router). |
|
|
21
|
+
| `useNavigationBehavior` | A page or section switches off the scroll jump and the top loading bar while it is mounted. See [Scroll and loading bar](#scroll-and-loading-bar). |
|
|
21
22
|
| `parseAsString` / `parseAsInteger` / `parseAsFloat` / `parseAsBoolean` / `parseAsIsoDate` / `parseAsStringEnum` / `parseAsArrayOf` / `parseAsJson` | Parser builders for `useQueryState`. Each has `.withDefault(value)`. |
|
|
22
23
|
|
|
23
24
|
## Decomposition rationale
|
|
@@ -101,6 +102,35 @@ export function MyRouterAdapter({ children }: { children: React.ReactNode }) {
|
|
|
101
102
|
|
|
102
103
|
**Adapter contract gotcha:** custom `push` / `replace` implementations should ultimately route through `window.history.pushState` (Next does, so does react-router). If yours doesn't, dispatch `'djc:navigate'` after each navigation manually so `useLocation` re-reads.
|
|
103
104
|
|
|
105
|
+
## Scroll and loading bar
|
|
106
|
+
|
|
107
|
+
`push`/`replace` (and `navigate`) take `{ scroll?, progress? }`, and the Next adapter honours both:
|
|
108
|
+
|
|
109
|
+
| Option | Absent | `true` | `false` |
|
|
110
|
+
| --- | --- | --- | --- |
|
|
111
|
+
| `scroll` | the page's setting, then `NextRouterAdapter defaultScroll`, then Next's own default | scroll to the top | keep the position |
|
|
112
|
+
| `progress` | the bar shows only when the **pathname** changes; `?query` and `#hash` changes never show it | show it | do not show it |
|
|
113
|
+
|
|
114
|
+
`useQueryState` passes `scroll: false` and `progress: false` by default (it only ever changes the query); both can be set on the hook or on a single `setValue` call.
|
|
115
|
+
|
|
116
|
+
A page that navigates inside itself (filters, a board, threads) declares it once instead of on every call, and it also covers `<Link>` clicks and `useScrollToTop`:
|
|
117
|
+
|
|
118
|
+
```tsx
|
|
119
|
+
useNavigationBehavior({ scroll: false, progress: false }); // while this component is mounted
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Later or nested entries win field by field; an explicit option on a call beats the page. `BaseApp progressBar={false}` removes the bar for the whole app; `progressBar={{ shouldShow: (from, to) => ... }}` replaces the default route rule. `<Link scroll={false}>` is still how a single link keeps Next's scroll.
|
|
123
|
+
|
|
124
|
+
The bar is ours: [`route-progress`](../../route-progress/README.md). The adapter starts it for programmatic navigation, link clicks are caught by the provider, and every committed navigation (a new pathname, or Next's `pushState`) ends it, with a 15 s ceiling, so a forced bar on a query change cannot hang. Without a `RouteProgressProvider` above (BaseApp mounts one) there is no bar and `useNavigationBehavior` does nothing.
|
|
125
|
+
|
|
126
|
+
## Shallow writes (no navigation)
|
|
127
|
+
|
|
128
|
+
`useQueryState(key, parser, { shallow: true })`, or `setValue(v, { shallow: true })` for one call, writes the URL through the History API and nothing else: no request to the server, no server components re-rendered, no loading bar, no scroll (`scroll` and `progress` are ignored). For state only the client needs (a view mode, a zoom, a slider), above all on `force-dynamic` pages where every router call is a full server render. Precedence: the call, then the hook, then `false`. `replace` still picks `replaceState` or `pushState`; with `push`, Back and Forward restore the earlier value (`useLocation` listens to `popstate`). `useNavigate().shallow(href, { replace })` is the same write without the hook.
|
|
129
|
+
|
|
130
|
+
The adapter method is `RouterAdapter.shallow?(url, { replace })`; the default and Next adapters implement it, passing `null` as the history state (Next syncs `useSearchParams` only for entries without its own `__NA`/`_N` keys, so `history.state` must not be forwarded). An adapter without it falls back to `push`/`replace` with the bar and scroll off.
|
|
131
|
+
|
|
132
|
+
Setters read the URL at the moment of the call, not the one the last render saw, so two calls in one tick both land and a functional updater sees the earlier write (this holds for normal mode too, though there the URL changes only when the router commits).
|
|
133
|
+
|
|
104
134
|
## `useQueryState` (typed URL state)
|
|
105
135
|
|
|
106
136
|
Bound to one query key with a typed parser. Inspired by `nuqs` but framework-agnostic via the same adapter context.
|
|
@@ -119,7 +149,7 @@ setPage((prev) => prev + 1); // functional updater, just like useState
|
|
|
119
149
|
setPage(null); // clears the key from the URL
|
|
120
150
|
```
|
|
121
151
|
|
|
122
|
-
`clearOnDefault` (default `true`) drops the key from the URL when the value equals the parser default — keeps URLs short. Pass `{ clearOnDefault: false }` to keep explicit `?page=1` for shareable links.
|
|
152
|
+
`scroll` and `progress` (both default `false`) are described in [Scroll and loading bar](#scroll-and-loading-bar). `clearOnDefault` (default `true`) drops the key from the URL when the value equals the parser default — keeps URLs short. Pass `{ clearOnDefault: false }` to keep explicit `?page=1` for shareable links.
|
|
123
153
|
|
|
124
154
|
Parsers: `parseAsString`, `parseAsInteger`, `parseAsFloat`, `parseAsBoolean`, `parseAsIsoDate`, `parseAsStringEnum([...])`, `parseAsArrayOf(item)`, `parseAsJson<T>()`. Each has `.withDefault(value)`. Build your own by satisfying the `QueryParser<T>` interface — it's three functions (`parse`, `serialize`, `eq`).
|
|
125
155
|
|
|
@@ -52,17 +52,40 @@ export interface RouterLocation {
|
|
|
52
52
|
* Pluggable navigation backend. Implementations must be SSR-safe
|
|
53
53
|
* (mutations should no-op when `typeof window === 'undefined'`).
|
|
54
54
|
*/
|
|
55
|
+
export interface RouterNavigateOptions {
|
|
56
|
+
/**
|
|
57
|
+
* Scroll to the top after the navigation. Absent: the adapter's own default
|
|
58
|
+
* (Next: its `scroll: true`). `false` keeps the position, e.g. for a filter.
|
|
59
|
+
*/
|
|
60
|
+
scroll?: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Show the top loading bar. Absent: only when the pathname changes (a query or
|
|
63
|
+
* hash change is not a page transition). `true` forces it, `false` suppresses it.
|
|
64
|
+
* Adapters without a bar ignore it.
|
|
65
|
+
*/
|
|
66
|
+
progress?: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
55
69
|
export interface RouterAdapter {
|
|
56
70
|
/** Push a new entry onto the history stack. */
|
|
57
|
-
push: (url: string) => void;
|
|
71
|
+
push: (url: string, opts?: RouterNavigateOptions) => void;
|
|
58
72
|
/** Replace the current history entry. */
|
|
59
|
-
replace: (url: string) => void;
|
|
73
|
+
replace: (url: string, opts?: RouterNavigateOptions) => void;
|
|
60
74
|
/** Go back one entry. */
|
|
61
75
|
back: () => void;
|
|
62
76
|
/** Go forward one entry. */
|
|
63
77
|
forward: () => void;
|
|
64
78
|
/** Read the current location (synchronous). */
|
|
65
79
|
getLocation: () => RouterLocation;
|
|
80
|
+
/**
|
|
81
|
+
* Write the URL without a navigation: no request to the server, no server
|
|
82
|
+
* components re-rendered, no loading bar, no scroll. For state that only the
|
|
83
|
+
* client needs (a view mode, a zoom, a slider). Optional: when an adapter has
|
|
84
|
+
* none, callers fall back to `push`/`replace` with the bar and the scroll off.
|
|
85
|
+
* Implementations must pass `null` as the history state: Next syncs
|
|
86
|
+
* `useSearchParams` only for entries without its own router keys.
|
|
87
|
+
*/
|
|
88
|
+
shallow?: (url: string, opts?: { replace?: boolean }) => void;
|
|
66
89
|
}
|
|
67
90
|
|
|
68
91
|
const SSR_LOCATION: RouterLocation = Object.freeze({
|
|
@@ -101,8 +124,23 @@ export const defaultAdapter: RouterAdapter = Object.freeze({
|
|
|
101
124
|
hash: window.location.hash,
|
|
102
125
|
};
|
|
103
126
|
},
|
|
127
|
+
shallow(url: string, opts?: { replace?: boolean }) {
|
|
128
|
+
writeUrlShallow(url, opts?.replace ?? false);
|
|
129
|
+
},
|
|
104
130
|
});
|
|
105
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Write the URL through the History API and nothing else. The state is `null`,
|
|
134
|
+
* never `history.state`: Next marks its own entries (`__NA`, `_N`) and ignores
|
|
135
|
+
* the ones it did not make when it syncs `useSearchParams`/`usePathname`.
|
|
136
|
+
* Our patched `pushState`/`replaceState` tell `useLocation`, so hooks update.
|
|
137
|
+
*/
|
|
138
|
+
export function writeUrlShallow(url: string, replace: boolean): void {
|
|
139
|
+
if (typeof window === 'undefined') return;
|
|
140
|
+
if (replace) window.history.replaceState(null, '', url);
|
|
141
|
+
else window.history.pushState(null, '', url);
|
|
142
|
+
}
|
|
143
|
+
|
|
106
144
|
/**
|
|
107
145
|
* React context carrying the active adapter. `null` means "use default".
|
|
108
146
|
* Kept intentionally nullable so we don't burn a Provider for the default case.
|
|
@@ -34,32 +34,27 @@
|
|
|
34
34
|
|
|
35
35
|
import {
|
|
36
36
|
forwardRef,
|
|
37
|
+
useEffect,
|
|
37
38
|
useMemo,
|
|
39
|
+
useRef,
|
|
38
40
|
type AnchorHTMLAttributes,
|
|
39
41
|
type ComponentType,
|
|
40
42
|
type ReactNode,
|
|
41
43
|
type Ref,
|
|
42
44
|
} from 'react';
|
|
43
|
-
|
|
44
|
-
// navigation through ui-core hooks (`useNavigate`, `useQueryState`,
|
|
45
|
-
// `useRouter` from `@djangocfg/ui-core/hooks`) starts the
|
|
46
|
-
// `nextjs-toploader` progress bar.
|
|
47
|
-
//
|
|
48
|
-
// Why not `next/navigation` directly: <NextTopLoader> only attaches a
|
|
49
|
-
// document-level click listener for <a> tags and instruments
|
|
50
|
-
// `history.pushState` to call `nprogress.done()` (NOT `.start()`). So
|
|
51
|
-
// `router.push()` from `next/navigation` mutates history but never
|
|
52
|
-
// starts the bar — only `<Link>` clicks do. The `nextjs-toploader/app`
|
|
53
|
-
// entry exposes a thin wrapper around the same `useRouter` whose
|
|
54
|
-
// `push`/`replace` call `nprogress.start()` first. Drop-in shape.
|
|
55
|
-
import { useRouter as useNextRouter } from 'nextjs-toploader/app';
|
|
45
|
+
import { usePathname, useRouter as useNextRouter } from 'next/navigation';
|
|
56
46
|
import NextLink from 'next/link';
|
|
57
47
|
|
|
58
48
|
import {
|
|
59
49
|
RouterAdapterProvider,
|
|
60
50
|
type RouterAdapter,
|
|
61
51
|
type RouterLocation,
|
|
52
|
+
type RouterNavigateOptions,
|
|
53
|
+
writeUrlShallow,
|
|
62
54
|
} from '../adapter';
|
|
55
|
+
import { useRouteProgress } from '../../../route-progress';
|
|
56
|
+
import { shouldStartProgress } from '../../../route-progress/provider/shouldStart';
|
|
57
|
+
import { NAVIGATE_EVENT, patchHistoryOnce } from '../useLocation';
|
|
63
58
|
import {
|
|
64
59
|
LinkProvider,
|
|
65
60
|
type LinkComponent,
|
|
@@ -72,6 +67,16 @@ const SSR_LOCATION: RouterLocation = Object.freeze({
|
|
|
72
67
|
hash: '',
|
|
73
68
|
});
|
|
74
69
|
|
|
70
|
+
/** `url` resolved against the current page; `null` outside the browser or for an unparsable url. */
|
|
71
|
+
function resolve(url: string): URL | null {
|
|
72
|
+
if (typeof window === 'undefined') return null;
|
|
73
|
+
try {
|
|
74
|
+
return new URL(url, window.location.href);
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
75
80
|
export interface NextRouterAdapterProps {
|
|
76
81
|
children: ReactNode;
|
|
77
82
|
/**
|
|
@@ -87,19 +92,50 @@ export interface NextRouterAdapterProps {
|
|
|
87
92
|
* through Next's App Router. Server components, loaders, and prefetch
|
|
88
93
|
* keep working as if you used `next/navigation` directly.
|
|
89
94
|
*/
|
|
90
|
-
export function NextRouterAdapter({
|
|
91
|
-
children,
|
|
92
|
-
defaultScroll,
|
|
93
|
-
}: NextRouterAdapterProps) {
|
|
95
|
+
export function NextRouterAdapter({ children, defaultScroll }: NextRouterAdapterProps) {
|
|
94
96
|
const next = useNextRouter();
|
|
97
|
+
const pathname = usePathname();
|
|
98
|
+
// The top bar and the page switches live in `RouteProgressProvider` (BaseApp mounts it); without one this adapter shows no bar.
|
|
99
|
+
const progress = useRouteProgress();
|
|
100
|
+
const progressRef = useRef(progress);
|
|
101
|
+
progressRef.current = progress;
|
|
102
|
+
|
|
103
|
+
// Every committed navigation ends the bar: Next's `pushState`/`replaceState` on commit, or a new pathname.
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
patchHistoryOnce();
|
|
106
|
+
const stop = () => progressRef.current?.engine.done();
|
|
107
|
+
window.addEventListener(NAVIGATE_EVENT, stop);
|
|
108
|
+
return () => window.removeEventListener(NAVIGATE_EVENT, stop);
|
|
109
|
+
}, []);
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
progressRef.current?.engine.done();
|
|
112
|
+
}, [pathname]);
|
|
95
113
|
|
|
96
114
|
const adapter = useMemo<RouterAdapter>(
|
|
97
|
-
() =>
|
|
98
|
-
|
|
99
|
-
|
|
115
|
+
() => {
|
|
116
|
+
// Priority for scroll: the call, then the page (`useNavigationBehavior`), then `defaultScroll`, then Next's own default.
|
|
117
|
+
const nextOptions = (opts?: RouterNavigateOptions) => {
|
|
118
|
+
const scroll = opts?.scroll ?? progressRef.current?.behavior.get().scroll ?? defaultScroll;
|
|
119
|
+
return scroll === undefined ? undefined : { scroll };
|
|
120
|
+
};
|
|
121
|
+
const startBar = (url: string, opts?: RouterNavigateOptions) => {
|
|
122
|
+
const to = resolve(url);
|
|
123
|
+
if (to && shouldStartProgress(progressRef.current, new URL(window.location.href), to, opts?.progress)) {
|
|
124
|
+
progressRef.current?.engine.start();
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
return {
|
|
128
|
+
push(url, opts) {
|
|
129
|
+
startBar(url, opts);
|
|
130
|
+
next.push(url, nextOptions(opts));
|
|
131
|
+
},
|
|
132
|
+
replace(url, opts) {
|
|
133
|
+
startBar(url, opts);
|
|
134
|
+
next.replace(url, nextOptions(opts));
|
|
100
135
|
},
|
|
101
|
-
|
|
102
|
-
|
|
136
|
+
// No router call: no RSC request, no bar, no scroll. `null` state so Next syncs `useSearchParams`.
|
|
137
|
+
shallow(url, opts) {
|
|
138
|
+
writeUrlShallow(url, opts?.replace ?? false);
|
|
103
139
|
},
|
|
104
140
|
back() {
|
|
105
141
|
next.back();
|
|
@@ -115,7 +151,8 @@ export function NextRouterAdapter({
|
|
|
115
151
|
hash: window.location.hash,
|
|
116
152
|
};
|
|
117
153
|
},
|
|
118
|
-
|
|
154
|
+
};
|
|
155
|
+
},
|
|
119
156
|
[next, defaultScroll]
|
|
120
157
|
);
|
|
121
158
|
|
|
@@ -14,6 +14,7 @@ export {
|
|
|
14
14
|
} from './adapter';
|
|
15
15
|
export type {
|
|
16
16
|
RouterAdapter,
|
|
17
|
+
RouterNavigateOptions,
|
|
17
18
|
RouterAdapterProviderProps,
|
|
18
19
|
RouterLocation,
|
|
19
20
|
} from './adapter';
|
|
@@ -29,6 +30,10 @@ export {
|
|
|
29
30
|
} from './useLocation';
|
|
30
31
|
export type { LocationSnapshot } from './useLocation';
|
|
31
32
|
|
|
33
|
+
// Per-page switches for the scroll jump and the loading bar live in ../../route-progress
|
|
34
|
+
export { useNavigationBehavior } from '../../route-progress';
|
|
35
|
+
export type { NavigationBehavior } from '../../route-progress';
|
|
36
|
+
|
|
32
37
|
// useScrollToTop (fills the gaps in Next's own scroll restoration)
|
|
33
38
|
export { useScrollToTop } from './useScrollToTop';
|
|
34
39
|
export type { UseScrollToTopOptions } from './useScrollToTop';
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { useCallback, useMemo } from 'react';
|
|
23
|
-
import { useRouterAdapter } from './adapter';
|
|
23
|
+
import { useRouterAdapter, type RouterNavigateOptions } from './adapter';
|
|
24
24
|
|
|
25
25
|
export interface NavigateOptions {
|
|
26
26
|
/** Use `replaceState` instead of `pushState`. Default: false. */
|
|
@@ -33,6 +33,11 @@ export interface NavigateOptions {
|
|
|
33
33
|
* transitions where you want the user back at the masthead.
|
|
34
34
|
*/
|
|
35
35
|
scroll?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Show the top loading bar. Default: only when the pathname changes.
|
|
38
|
+
* `true` forces it, `false` suppresses it.
|
|
39
|
+
*/
|
|
40
|
+
progress?: boolean;
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
export interface UseNavigateReturn {
|
|
@@ -47,10 +52,16 @@ export interface UseNavigateReturn {
|
|
|
47
52
|
* Use for logout, OAuth, or any cross-origin handoff.
|
|
48
53
|
*/
|
|
49
54
|
navigateExternal: (href: string) => void;
|
|
55
|
+
/**
|
|
56
|
+
* Write the URL without a navigation (no server request, no bar, no scroll), see
|
|
57
|
+
* `RouterAdapter.shallow`. Falls back to `push`/`replace` with bar and scroll off
|
|
58
|
+
* when the adapter has no shallow write.
|
|
59
|
+
*/
|
|
60
|
+
shallow: (href: string, opts?: { replace?: boolean }) => void;
|
|
50
61
|
/** Pass-through: adapter.push */
|
|
51
|
-
push: (url: string) => void;
|
|
62
|
+
push: (url: string, opts?: RouterNavigateOptions) => void;
|
|
52
63
|
/** Pass-through: adapter.replace */
|
|
53
|
-
replace: (url: string) => void;
|
|
64
|
+
replace: (url: string, opts?: RouterNavigateOptions) => void;
|
|
54
65
|
/** Pass-through: adapter.back */
|
|
55
66
|
back: () => void;
|
|
56
67
|
/** Pass-through: adapter.forward */
|
|
@@ -68,10 +79,11 @@ export function useNavigate(): UseNavigateReturn {
|
|
|
68
79
|
(href: string, opts?: NavigateOptions) => {
|
|
69
80
|
const replace = opts?.replace ?? false;
|
|
70
81
|
const scroll = opts?.scroll ?? false;
|
|
82
|
+
const adapterOpts = { scroll: opts?.scroll, progress: opts?.progress };
|
|
71
83
|
if (replace) {
|
|
72
|
-
adapter.replace(href);
|
|
84
|
+
adapter.replace(href, adapterOpts);
|
|
73
85
|
} else {
|
|
74
|
-
adapter.push(href);
|
|
86
|
+
adapter.push(href, adapterOpts);
|
|
75
87
|
}
|
|
76
88
|
if (scroll && typeof window !== 'undefined') {
|
|
77
89
|
window.scrollTo(0, 0);
|
|
@@ -80,18 +92,31 @@ export function useNavigate(): UseNavigateReturn {
|
|
|
80
92
|
[adapter]
|
|
81
93
|
);
|
|
82
94
|
|
|
95
|
+
const shallow = useCallback(
|
|
96
|
+
(href: string, opts?: { replace?: boolean }) => {
|
|
97
|
+
if (adapter.shallow) {
|
|
98
|
+
adapter.shallow(href, opts);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const fallback = { scroll: false, progress: false };
|
|
102
|
+
if (opts?.replace) adapter.replace(href, fallback);
|
|
103
|
+
else adapter.push(href, fallback);
|
|
104
|
+
},
|
|
105
|
+
[adapter]
|
|
106
|
+
);
|
|
107
|
+
|
|
83
108
|
const navigateExternal = useCallback((href: string) => {
|
|
84
109
|
if (typeof window === 'undefined') return;
|
|
85
110
|
window.location.assign(href);
|
|
86
111
|
}, []);
|
|
87
112
|
|
|
88
|
-
const push = useCallback((url: string) => adapter.push(url), [adapter]);
|
|
89
|
-
const replace = useCallback((url: string) => adapter.replace(url), [adapter]);
|
|
113
|
+
const push = useCallback((url: string, opts?: RouterNavigateOptions) => adapter.push(url, opts), [adapter]);
|
|
114
|
+
const replace = useCallback((url: string, opts?: RouterNavigateOptions) => adapter.replace(url, opts), [adapter]);
|
|
90
115
|
const back = useCallback(() => adapter.back(), [adapter]);
|
|
91
116
|
const forward = useCallback(() => adapter.forward(), [adapter]);
|
|
92
117
|
|
|
93
118
|
return useMemo<UseNavigateReturn>(
|
|
94
|
-
() => ({ navigate, navigateExternal, push, replace, back, forward }),
|
|
95
|
-
[navigate, navigateExternal, push, replace, back, forward]
|
|
119
|
+
() => ({ navigate, navigateExternal, shallow, push, replace, back, forward }),
|
|
120
|
+
[navigate, navigateExternal, shallow, push, replace, back, forward]
|
|
96
121
|
);
|
|
97
122
|
}
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { useCallback, useMemo } from 'react';
|
|
23
|
+
import { useRouterAdapter } from './adapter';
|
|
23
24
|
import { useLocation } from './useLocation';
|
|
24
25
|
import { useNavigate } from './useNavigate';
|
|
25
26
|
import type { QueryParser } from './parsers';
|
|
@@ -29,6 +30,18 @@ export interface UseQueryStateOptions {
|
|
|
29
30
|
replace?: boolean;
|
|
30
31
|
/** Scroll to top after navigation. Default: false. */
|
|
31
32
|
scroll?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Show the top loading bar. Default: false: a query change is not a page
|
|
35
|
+
* transition. Pass `true` when the change loads a heavy server view.
|
|
36
|
+
*/
|
|
37
|
+
progress?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Write the URL without a navigation: no request to the server, no server
|
|
40
|
+
* components re-rendered, no bar, no scroll (`scroll` and `progress` are ignored).
|
|
41
|
+
* For state only the client needs (a view mode, a zoom, a slider). Default:
|
|
42
|
+
* false. Precedence: the setter call, then the hook, then false.
|
|
43
|
+
*/
|
|
44
|
+
shallow?: boolean;
|
|
32
45
|
/**
|
|
33
46
|
* When the new value equals the parser's default, drop the key from
|
|
34
47
|
* the URL instead of writing `?page=1`. Default: true (recommended —
|
|
@@ -59,25 +72,38 @@ export function useQueryState<T>(
|
|
|
59
72
|
options?: UseQueryStateOptions
|
|
60
73
|
): [T | null, (next: QueryStateUpdater<T>, opts?: UseQueryStateOptions) => void] {
|
|
61
74
|
const { pathname, search } = useLocation();
|
|
62
|
-
const { navigate } = useNavigate();
|
|
75
|
+
const { navigate, shallow } = useNavigate();
|
|
76
|
+
const adapter = useRouterAdapter();
|
|
63
77
|
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
78
|
+
const parse = useCallback(
|
|
79
|
+
(qs: string): T | null => {
|
|
80
|
+
const raw = new URLSearchParams(qs).get(key);
|
|
81
|
+
if (raw === null) return parser.defaultValue ?? null;
|
|
82
|
+
const parsed = parser.parse(raw);
|
|
83
|
+
return parsed === null ? (parser.defaultValue ?? null) : parsed;
|
|
84
|
+
},
|
|
85
|
+
[key, parser]
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const value = useMemo<T | null>(() => parse(search), [search, parse]);
|
|
70
89
|
|
|
71
90
|
const setValue = useCallback(
|
|
72
91
|
(next: QueryStateUpdater<T>, callOpts?: UseQueryStateOptions) => {
|
|
92
|
+
// Read the URL as it is now, not as this render saw it: two calls in one tick
|
|
93
|
+
// (or one before the re-render) must both land. The rendered `search` is the fallback on the server.
|
|
94
|
+
const live = typeof window === 'undefined' ? null : adapter.getLocation();
|
|
95
|
+
const currentSearch = live ? live.search : search;
|
|
96
|
+
const currentPathname = live ? live.pathname : pathname;
|
|
97
|
+
const current = live ? parse(currentSearch) : value;
|
|
98
|
+
|
|
73
99
|
const resolved =
|
|
74
100
|
typeof next === 'function'
|
|
75
101
|
? (next as (current: T) => T | null)(
|
|
76
|
-
(
|
|
102
|
+
(current ?? parser.defaultValue) as T
|
|
77
103
|
)
|
|
78
104
|
: next;
|
|
79
105
|
|
|
80
|
-
const params = new URLSearchParams(
|
|
106
|
+
const params = new URLSearchParams(currentSearch);
|
|
81
107
|
const clearOnDefault =
|
|
82
108
|
callOpts?.clearOnDefault ?? options?.clearOnDefault ?? true;
|
|
83
109
|
|
|
@@ -93,13 +119,20 @@ export function useQueryState<T>(
|
|
|
93
119
|
}
|
|
94
120
|
|
|
95
121
|
const qs = params.toString();
|
|
96
|
-
const href = qs ? `${
|
|
122
|
+
const href = qs ? `${currentPathname}?${qs}` : currentPathname;
|
|
123
|
+
const replace = callOpts?.replace ?? options?.replace ?? false;
|
|
124
|
+
if (callOpts?.shallow ?? options?.shallow ?? false) {
|
|
125
|
+
// The hash goes with the entry, as `pushState` of a bare path would drop it.
|
|
126
|
+
shallow(live?.hash ? `${href}${live.hash}` : href, { replace });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
97
129
|
navigate(href, {
|
|
98
|
-
replace
|
|
130
|
+
replace,
|
|
99
131
|
scroll: callOpts?.scroll ?? options?.scroll ?? false,
|
|
132
|
+
progress: callOpts?.progress ?? options?.progress ?? false,
|
|
100
133
|
});
|
|
101
134
|
},
|
|
102
|
-
[pathname, search, key, parser, navigate, options, value]
|
|
135
|
+
[adapter, pathname, search, key, parser, parse, navigate, shallow, options, value]
|
|
103
136
|
);
|
|
104
137
|
|
|
105
138
|
return [value, setValue];
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
|
|
48
48
|
import { useEffect, useRef } from 'react';
|
|
49
49
|
|
|
50
|
+
import { useRouteProgress } from '../../route-progress';
|
|
50
51
|
import { NAVIGATE_EVENT, patchHistoryOnce } from './useLocation';
|
|
51
52
|
|
|
52
53
|
export interface UseScrollToTopOptions {
|
|
@@ -74,6 +75,10 @@ export function useScrollToTop({
|
|
|
74
75
|
* restoration on a reload.
|
|
75
76
|
*/
|
|
76
77
|
const lastPathRef = useRef<string | null>(null);
|
|
78
|
+
// The page's switches (`useNavigationBehavior`) live in the app's RouteProgressProvider.
|
|
79
|
+
const progress = useRouteProgress();
|
|
80
|
+
const progressRef = useRef(progress);
|
|
81
|
+
progressRef.current = progress;
|
|
77
82
|
|
|
78
83
|
useEffect(() => {
|
|
79
84
|
if (!enabled || typeof window === 'undefined') return;
|
|
@@ -97,6 +102,8 @@ export function useScrollToTop({
|
|
|
97
102
|
|
|
98
103
|
// Query-only or state-only writes: the visitor stays where they are.
|
|
99
104
|
if (!changedPath) return;
|
|
105
|
+
// The page asked to keep its position (`useNavigationBehavior`).
|
|
106
|
+
if (progressRef.current?.behavior.get().scroll === false) return;
|
|
100
107
|
// The URL names an anchor; it has already said where to land.
|
|
101
108
|
if (hash) return;
|
|
102
109
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# route-progress
|
|
2
|
+
|
|
3
|
+
The thin bar at the top of the window between pages, and the per-page switches for it and for the scroll jump. Our own code; the progress model comes from `nprogress` (see [NOTICE](NOTICE)). Plan and decisions: `frontend/@dev/planned/route-progress-module/PLAN.md`.
|
|
4
|
+
|
|
5
|
+
Subpath: `@djangocfg/ui-core/route-progress`. `BaseApp` mounts the provider; apps normally only call `useNavigationBehavior`.
|
|
6
|
+
|
|
7
|
+
| Folder | Job |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `engine/` | the state machine: `start`, `set`, `inc`, `done`, `cancel`; no DOM, no React |
|
|
10
|
+
| `view/` | `<RouteProgressBar>`: `scaleX` bar, tokens, reduced motion |
|
|
11
|
+
| `triggers/` | which link clicks start the bar; page-hide and bfcache reset |
|
|
12
|
+
| `behavior/` | the lease store behind `useNavigationBehavior` |
|
|
13
|
+
| `provider/` | `RouteProgressProvider`, the context, and the one rule `shouldStartProgress` |
|
|
14
|
+
|
|
15
|
+
## Who decides, in order
|
|
16
|
+
1. the call's `progress` option (`push(url, { progress })`);
|
|
17
|
+
2. the app switch (`BaseApp progressBar={false}` wins over everything);
|
|
18
|
+
3. a mounted page's `useNavigationBehavior({ progress: false })`;
|
|
19
|
+
4. the app's `shouldShow(from, to)`, if set;
|
|
20
|
+
5. the default: the pathname changes (`?query` and `#hash` do not).
|
|
21
|
+
|
|
22
|
+
## Styling
|
|
23
|
+
CSS variables on any ancestor: `--route-progress-color` (default `--primary`), `--route-progress-height` (3px), `--route-progress-z` (1600). One anchor can opt out: `<a data-no-route-progress>`.
|
|
24
|
+
|
|
25
|
+
## Adding a trigger
|
|
26
|
+
A trigger is a function that calls `engine.start()` and returns its unsubscribe; add it to `triggers/attach.ts`. Ending a run is the router wiring's job (`NextRouterAdapter` calls `engine.done()` on commit); the engine's ceiling ends a run nobody finished.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the app currently allows navigation to do: the scroll jump to the top and the top
|
|
3
|
+
* loading bar. Pages and sections hold a lease for as long as they are mounted; the latest
|
|
4
|
+
* lease that sets a field decides it. One store per app, created by the provider: no global.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface NavigationBehavior {
|
|
8
|
+
/** `false`: do not scroll to the top on navigation. */
|
|
9
|
+
scroll?: boolean;
|
|
10
|
+
/** `false`: do not show the top loading bar. */
|
|
11
|
+
progress?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface NavigationBehaviorStore {
|
|
15
|
+
/** The effective behaviour. Same object until a lease changes. */
|
|
16
|
+
get(): NavigationBehavior;
|
|
17
|
+
subscribe(listener: () => void): () => void;
|
|
18
|
+
/** Take a lease; returns the function that gives it back. */
|
|
19
|
+
acquire(lease: NavigationBehavior): () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createNavigationBehaviorStore(): NavigationBehaviorStore {
|
|
23
|
+
const leases: NavigationBehavior[] = [];
|
|
24
|
+
const listeners = new Set<() => void>();
|
|
25
|
+
let current: NavigationBehavior = {};
|
|
26
|
+
|
|
27
|
+
const recompute = () => {
|
|
28
|
+
const next: NavigationBehavior = {};
|
|
29
|
+
for (const l of leases) {
|
|
30
|
+
if (l.scroll !== undefined) next.scroll = l.scroll;
|
|
31
|
+
if (l.progress !== undefined) next.progress = l.progress;
|
|
32
|
+
}
|
|
33
|
+
if (next.scroll === current.scroll && next.progress === current.progress) return;
|
|
34
|
+
current = next;
|
|
35
|
+
listeners.forEach((fn) => fn());
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
get: () => current,
|
|
40
|
+
subscribe(listener) {
|
|
41
|
+
listeners.add(listener);
|
|
42
|
+
return () => {
|
|
43
|
+
listeners.delete(listener);
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
acquire(lease) {
|
|
47
|
+
const own = { ...lease };
|
|
48
|
+
leases.push(own);
|
|
49
|
+
recompute();
|
|
50
|
+
return () => {
|
|
51
|
+
const i = leases.indexOf(own);
|
|
52
|
+
if (i === -1) return;
|
|
53
|
+
leases.splice(i, 1);
|
|
54
|
+
recompute();
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
import { useRouteProgress } from '../provider/context';
|
|
6
|
+
import type { NavigationBehavior } from './store';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Declare navigation behaviour for as long as the calling component is mounted:
|
|
10
|
+
*
|
|
11
|
+
* ```tsx
|
|
12
|
+
* function Board() {
|
|
13
|
+
* useNavigationBehavior({ scroll: false, progress: false });
|
|
14
|
+
* }
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* Needs a `RouteProgressProvider` above (BaseApp mounts one); without it this does nothing.
|
|
18
|
+
*/
|
|
19
|
+
export function useNavigationBehavior({ scroll, progress }: NavigationBehavior): void {
|
|
20
|
+
const ctx = useRouteProgress();
|
|
21
|
+
const behavior = ctx?.behavior;
|
|
22
|
+
useEffect(() => behavior?.acquire({ scroll, progress }), [behavior, scroll, progress]);
|
|
23
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The progress state machine, free of DOM and React. The numbers (start jump, step size by
|
|
3
|
+
* band, the 0.994 cap) follow the model of `nprogress` 0.2.0 (MIT, see ../NOTICE); the code
|
|
4
|
+
* is ours: one timer for trickling, one for the ceiling, one for the fade-out.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ProgressEngine, ProgressEngineOptions, ProgressState } from './types';
|
|
8
|
+
|
|
9
|
+
const IDLE: ProgressState = Object.freeze({ phase: 'idle', value: 0 });
|
|
10
|
+
const CAP = 0.994;
|
|
11
|
+
|
|
12
|
+
/** Step size by how far the bar already is. */
|
|
13
|
+
function stepFor(value: number): number {
|
|
14
|
+
if (value < 0.2) return 0.1;
|
|
15
|
+
if (value < 0.5) return 0.04;
|
|
16
|
+
if (value < 0.8) return 0.02;
|
|
17
|
+
if (value < 0.99) return 0.005;
|
|
18
|
+
return 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createProgressEngine(options: ProgressEngineOptions = {}): ProgressEngine {
|
|
22
|
+
const { minimum = 0.08, trickleMs = 200, maxMs = 15000, finishMs = 400 } = options;
|
|
23
|
+
|
|
24
|
+
let state: ProgressState = IDLE;
|
|
25
|
+
const listeners = new Set<() => void>();
|
|
26
|
+
let trickleTimer: ReturnType<typeof setInterval> | undefined;
|
|
27
|
+
let ceilingTimer: ReturnType<typeof setTimeout> | undefined;
|
|
28
|
+
let finishTimer: ReturnType<typeof setTimeout> | undefined;
|
|
29
|
+
|
|
30
|
+
const emit = (next: ProgressState) => {
|
|
31
|
+
state = next;
|
|
32
|
+
listeners.forEach((l) => l());
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const clearTimers = () => {
|
|
36
|
+
clearInterval(trickleTimer);
|
|
37
|
+
clearTimeout(ceilingTimer);
|
|
38
|
+
clearTimeout(finishTimer);
|
|
39
|
+
trickleTimer = ceilingTimer = finishTimer = undefined;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const set = (value: number) => {
|
|
43
|
+
if (state.phase !== 'running') return;
|
|
44
|
+
const next = Math.min(CAP, Math.max(0, value));
|
|
45
|
+
if (next !== state.value) emit({ phase: 'running', value: next });
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const inc = (amount?: number) => {
|
|
49
|
+
if (state.phase !== 'running') return;
|
|
50
|
+
set(state.value + (amount ?? stepFor(state.value)));
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const done = () => {
|
|
54
|
+
if (state.phase !== 'running') return;
|
|
55
|
+
clearTimers();
|
|
56
|
+
emit({ phase: 'finishing', value: 1 });
|
|
57
|
+
finishTimer = setTimeout(() => {
|
|
58
|
+
finishTimer = undefined;
|
|
59
|
+
emit(IDLE);
|
|
60
|
+
}, finishMs);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
getState: () => state,
|
|
65
|
+
subscribe(listener) {
|
|
66
|
+
listeners.add(listener);
|
|
67
|
+
return () => {
|
|
68
|
+
listeners.delete(listener);
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
start() {
|
|
72
|
+
if (state.phase === 'running') return;
|
|
73
|
+
clearTimers();
|
|
74
|
+
emit({ phase: 'running', value: minimum });
|
|
75
|
+
trickleTimer = setInterval(() => inc(), trickleMs);
|
|
76
|
+
ceilingTimer = setTimeout(done, maxMs);
|
|
77
|
+
},
|
|
78
|
+
set,
|
|
79
|
+
inc,
|
|
80
|
+
done,
|
|
81
|
+
cancel() {
|
|
82
|
+
if (state === IDLE) return;
|
|
83
|
+
clearTimers();
|
|
84
|
+
emit(IDLE);
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type ProgressPhase = 'idle' | 'running' | 'finishing';
|
|
2
|
+
|
|
3
|
+
/** Immutable snapshot: a new object on every change, so it can feed `useSyncExternalStore`. */
|
|
4
|
+
export interface ProgressState {
|
|
5
|
+
phase: ProgressPhase;
|
|
6
|
+
/** 0..1. The bar's width while `running`; 1 while `finishing`; 0 when `idle`. */
|
|
7
|
+
value: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ProgressEngineOptions {
|
|
11
|
+
/** Where the bar jumps to on `start()`. Default 0.08. */
|
|
12
|
+
minimum?: number;
|
|
13
|
+
/** Delay between automatic steps, ms. Default 200. */
|
|
14
|
+
trickleMs?: number;
|
|
15
|
+
/** Ceiling: a run that was never finished ends by itself after this many ms. Default 15000. */
|
|
16
|
+
maxMs?: number;
|
|
17
|
+
/** How long the full bar stays before it goes idle (the fade-out), ms. Default 400. */
|
|
18
|
+
finishMs?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ProgressEngine {
|
|
22
|
+
getState(): ProgressState;
|
|
23
|
+
subscribe(listener: () => void): () => void;
|
|
24
|
+
/** Begin a run. Starting while running keeps the current value. */
|
|
25
|
+
start(): void;
|
|
26
|
+
/** Jump to `value` (clamped to 0..0.994) while running. */
|
|
27
|
+
set(value: number): void;
|
|
28
|
+
/** Move forward by `amount`, or by the step for the current band. */
|
|
29
|
+
inc(amount?: number): void;
|
|
30
|
+
/** Finish: fill the bar, then go idle after `finishMs`. No-op when idle. */
|
|
31
|
+
done(): void;
|
|
32
|
+
/** Drop the run at once, without the fill. */
|
|
33
|
+
cancel(): void;
|
|
34
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Route progress: the thin top bar between pages and the per-page navigation switches.
|
|
2
|
+
// Engine (state machine) -> view (bar) -> triggers (link clicks) -> provider (one per app).
|
|
3
|
+
|
|
4
|
+
export { RouteProgressProvider } from './provider/RouteProgressProvider';
|
|
5
|
+
export type { RouteProgressProviderProps } from './provider/RouteProgressProvider';
|
|
6
|
+
export { useRouteProgress } from './provider/context';
|
|
7
|
+
export type { RouteProgressContextValue } from './provider/context';
|
|
8
|
+
export { shouldStartProgress } from './provider/shouldStart';
|
|
9
|
+
|
|
10
|
+
export { useNavigationBehavior } from './behavior/useNavigationBehavior';
|
|
11
|
+
export type { NavigationBehavior } from './behavior/store';
|
|
12
|
+
|
|
13
|
+
export { RouteProgressBar } from './view/RouteProgressBar';
|
|
14
|
+
export { createProgressEngine } from './engine/createProgressEngine';
|
|
15
|
+
export type { ProgressEngine, ProgressEngineOptions, ProgressState } from './engine/types';
|
|
16
|
+
export { NO_PROGRESS_ATTR } from './triggers/linkClick';
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useRef, type ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
import { createNavigationBehaviorStore } from '../behavior/store';
|
|
6
|
+
import { createProgressEngine } from '../engine/createProgressEngine';
|
|
7
|
+
import type { ProgressEngineOptions } from '../engine/types';
|
|
8
|
+
import { attachRouteProgressTriggers } from '../triggers/attach';
|
|
9
|
+
import { RouteProgressBar } from '../view/RouteProgressBar';
|
|
10
|
+
import { RouteProgressContext, type RouteProgressContextValue } from './context';
|
|
11
|
+
import { shouldStartProgress } from './shouldStart';
|
|
12
|
+
|
|
13
|
+
export interface RouteProgressProviderProps extends ProgressEngineOptions {
|
|
14
|
+
children: ReactNode;
|
|
15
|
+
/** `false` turns the bar off for the whole app. Default true. */
|
|
16
|
+
enabled?: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Route rule for the whole app, replacing the default "the pathname changes".
|
|
19
|
+
* A page's `useNavigationBehavior({ progress: false })` and a call's `progress` option still win.
|
|
20
|
+
*/
|
|
21
|
+
shouldShow?: (from: URL, to: URL) => boolean;
|
|
22
|
+
/** CSS colour of the bar. */
|
|
23
|
+
color?: string;
|
|
24
|
+
/** Height of the bar in px. */
|
|
25
|
+
height?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* One progress engine and one behaviour store for the app. Mounts the bar and the link-click
|
|
30
|
+
* triggers. The router wiring (`NextRouterAdapter`) starts it for programmatic navigation and
|
|
31
|
+
* ends every run when the navigation commits.
|
|
32
|
+
*/
|
|
33
|
+
export function RouteProgressProvider({
|
|
34
|
+
children,
|
|
35
|
+
enabled = true,
|
|
36
|
+
shouldShow,
|
|
37
|
+
color,
|
|
38
|
+
height,
|
|
39
|
+
...engineOptions
|
|
40
|
+
}: RouteProgressProviderProps) {
|
|
41
|
+
const { minimum, trickleMs, maxMs, finishMs } = engineOptions;
|
|
42
|
+
// One instance per mount; the options only matter the first time.
|
|
43
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
44
|
+
const engine = useMemo(() => createProgressEngine({ minimum, trickleMs, maxMs, finishMs }), []);
|
|
45
|
+
const behavior = useMemo(createNavigationBehaviorStore, []);
|
|
46
|
+
|
|
47
|
+
const value = useMemo<RouteProgressContextValue>(
|
|
48
|
+
() => ({ engine, behavior, enabled, shouldShow }),
|
|
49
|
+
[engine, behavior, enabled, shouldShow],
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// The triggers read the latest value, so an inline `shouldShow` never re-attaches them (which would cancel a running bar).
|
|
53
|
+
const latest = useRef(value);
|
|
54
|
+
latest.current = value;
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!enabled) return;
|
|
58
|
+
const detach = attachRouteProgressTriggers({
|
|
59
|
+
engine,
|
|
60
|
+
shouldStart: (from, to) => shouldStartProgress(latest.current, from, to),
|
|
61
|
+
});
|
|
62
|
+
return () => {
|
|
63
|
+
detach();
|
|
64
|
+
engine.cancel();
|
|
65
|
+
};
|
|
66
|
+
}, [engine, enabled]);
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<RouteProgressContext.Provider value={value}>
|
|
70
|
+
{children}
|
|
71
|
+
{enabled && <RouteProgressBar engine={engine} color={color} height={height} />}
|
|
72
|
+
</RouteProgressContext.Provider>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext } from 'react';
|
|
4
|
+
|
|
5
|
+
import type { NavigationBehaviorStore } from '../behavior/store';
|
|
6
|
+
import type { ProgressEngine } from '../engine/types';
|
|
7
|
+
|
|
8
|
+
export interface RouteProgressContextValue {
|
|
9
|
+
engine: ProgressEngine;
|
|
10
|
+
behavior: NavigationBehaviorStore;
|
|
11
|
+
/** `false`: the app turned the bar off; nothing starts it. */
|
|
12
|
+
enabled: boolean;
|
|
13
|
+
/** App-level route rule, see `RouteProgressProvider`. */
|
|
14
|
+
shouldShow?: (from: URL, to: URL) => boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const RouteProgressContext = createContext<RouteProgressContextValue | null>(null);
|
|
18
|
+
|
|
19
|
+
/** The app's engine and behaviour store, or `null` outside a `RouteProgressProvider`. */
|
|
20
|
+
export function useRouteProgress(): RouteProgressContextValue | null {
|
|
21
|
+
return useContext(RouteProgressContext);
|
|
22
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { RouteProgressContextValue } from './context';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether a navigation from `from` to `to` shows the bar. In order:
|
|
5
|
+
* the call's explicit option; the app switch; a mounted page that said `progress: false`;
|
|
6
|
+
* the app's `shouldShow`; the default, "the pathname changes" (query and hash do not).
|
|
7
|
+
*/
|
|
8
|
+
export function shouldStartProgress(
|
|
9
|
+
ctx: RouteProgressContextValue | null,
|
|
10
|
+
from: URL,
|
|
11
|
+
to: URL,
|
|
12
|
+
explicit?: boolean,
|
|
13
|
+
): boolean {
|
|
14
|
+
if (!ctx || !ctx.enabled) return false;
|
|
15
|
+
if (explicit !== undefined) return explicit;
|
|
16
|
+
if (ctx.behavior.get().progress === false) return false;
|
|
17
|
+
if (ctx.shouldShow) return ctx.shouldShow(from, to);
|
|
18
|
+
return from.pathname !== to.pathname;
|
|
19
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ProgressEngine } from '../engine/types';
|
|
2
|
+
import { clickDestination } from './linkClick';
|
|
3
|
+
|
|
4
|
+
export interface TriggerOptions {
|
|
5
|
+
engine: ProgressEngine;
|
|
6
|
+
/** Whether a navigation from `from` to `to` shows the bar. */
|
|
7
|
+
shouldStart: (from: URL, to: URL) => boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Start the bar on link clicks; drop it on `pagehide` and when a page comes back from the
|
|
12
|
+
* back-forward cache, so a frozen bar is never restored. Completion is not decided here:
|
|
13
|
+
* the router wiring calls `engine.done()` when the navigation commits.
|
|
14
|
+
*/
|
|
15
|
+
export function attachRouteProgressTriggers({ engine, shouldStart }: TriggerOptions): () => void {
|
|
16
|
+
const onClick = (event: MouseEvent) => {
|
|
17
|
+
const to = clickDestination(event, window.location.href);
|
|
18
|
+
if (to && shouldStart(new URL(window.location.href), to)) engine.start();
|
|
19
|
+
};
|
|
20
|
+
const reset = () => engine.cancel();
|
|
21
|
+
|
|
22
|
+
document.addEventListener('click', onClick);
|
|
23
|
+
window.addEventListener('pagehide', reset);
|
|
24
|
+
window.addEventListener('pageshow', reset);
|
|
25
|
+
return () => {
|
|
26
|
+
document.removeEventListener('click', onClick);
|
|
27
|
+
window.removeEventListener('pagehide', reset);
|
|
28
|
+
window.removeEventListener('pageshow', reset);
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which link clicks are page navigations the bar should react to. Pure: it reads the event,
|
|
3
|
+
* the anchor and the base URL, and answers with the destination or `null`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** The parts of an anchor we read; a real `HTMLAnchorElement` fits. */
|
|
7
|
+
export interface AnchorLike {
|
|
8
|
+
href: string;
|
|
9
|
+
target: string;
|
|
10
|
+
hasAttribute(name: string): boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ClickLike {
|
|
14
|
+
button: number;
|
|
15
|
+
metaKey: boolean;
|
|
16
|
+
ctrlKey: boolean;
|
|
17
|
+
shiftKey: boolean;
|
|
18
|
+
altKey: boolean;
|
|
19
|
+
target: { closest?(selector: string): AnchorLike | null } | EventTarget | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Opt-out on one anchor: `<a data-no-route-progress>`. */
|
|
23
|
+
export const NO_PROGRESS_ATTR = 'data-no-route-progress';
|
|
24
|
+
|
|
25
|
+
/** The URL a plain left click on a same-origin link goes to, else `null`. */
|
|
26
|
+
export function clickDestination(event: ClickLike, baseHref: string): URL | null {
|
|
27
|
+
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return null;
|
|
28
|
+
const el = event.target as { closest?(selector: string): AnchorLike | null } | null;
|
|
29
|
+
const anchor = el && typeof el.closest === 'function' ? el.closest('a[href]') : null;
|
|
30
|
+
if (!anchor || typeof anchor.href !== 'string') return null;
|
|
31
|
+
if (anchor.target && anchor.target !== '_self') return null;
|
|
32
|
+
if (anchor.hasAttribute('download') || anchor.hasAttribute(NO_PROGRESS_ATTR)) return null;
|
|
33
|
+
let url: URL;
|
|
34
|
+
try {
|
|
35
|
+
url = new URL(anchor.href, baseHref);
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
|
40
|
+
if (url.origin !== new URL(baseHref).origin) return null;
|
|
41
|
+
return url;
|
|
42
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useSyncExternalStore } from 'react';
|
|
4
|
+
|
|
5
|
+
import type { ProgressEngine } from '../engine/types';
|
|
6
|
+
import { useReducedMotion } from './useReducedMotion';
|
|
7
|
+
|
|
8
|
+
export interface RouteProgressBarProps {
|
|
9
|
+
engine: ProgressEngine;
|
|
10
|
+
/** CSS colour. Default: the `--route-progress-color` variable, else `--primary`. */
|
|
11
|
+
color?: string;
|
|
12
|
+
/** Height in px. Default 3. */
|
|
13
|
+
height?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const serverState = { phase: 'idle', value: 0 } as const;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The thin bar at the top of the window. Decoration only (`aria-hidden`): the page owns its
|
|
20
|
+
* own busy semantics. Styling hooks: `--route-progress-color`, `--route-progress-height`,
|
|
21
|
+
* `--route-progress-z`. Renders nothing while idle and on the server.
|
|
22
|
+
*/
|
|
23
|
+
export function RouteProgressBar({ engine, color, height = 3 }: RouteProgressBarProps) {
|
|
24
|
+
const state = useSyncExternalStore(engine.subscribe, engine.getState, () => serverState);
|
|
25
|
+
const reduced = useReducedMotion();
|
|
26
|
+
if (state.phase === 'idle') return null;
|
|
27
|
+
|
|
28
|
+
const finishing = state.phase === 'finishing';
|
|
29
|
+
const fill = reduced ? 'none' : 'transform 200ms ease';
|
|
30
|
+
const fade = reduced ? 'opacity 150ms linear' : 'opacity 300ms ease 100ms';
|
|
31
|
+
const bar = color ?? 'var(--route-progress-color, var(--primary))';
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div
|
|
35
|
+
aria-hidden
|
|
36
|
+
data-route-progress={state.phase}
|
|
37
|
+
style={{
|
|
38
|
+
position: 'fixed',
|
|
39
|
+
top: 0,
|
|
40
|
+
left: 0,
|
|
41
|
+
right: 0,
|
|
42
|
+
height: `var(--route-progress-height, ${height}px)`,
|
|
43
|
+
zIndex: 'var(--route-progress-z, 1600)' as unknown as number,
|
|
44
|
+
pointerEvents: 'none',
|
|
45
|
+
opacity: finishing ? 0 : 1,
|
|
46
|
+
transition: fade,
|
|
47
|
+
}}
|
|
48
|
+
>
|
|
49
|
+
<div
|
|
50
|
+
style={{
|
|
51
|
+
position: 'absolute',
|
|
52
|
+
inset: 0,
|
|
53
|
+
background: bar,
|
|
54
|
+
transformOrigin: 'left center',
|
|
55
|
+
transform: `scaleX(${state.value})`,
|
|
56
|
+
transition: fill,
|
|
57
|
+
boxShadow: reduced ? 'none' : `0 0 10px ${bar}, 0 0 5px ${bar}`,
|
|
58
|
+
}}
|
|
59
|
+
/>
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
/** `prefers-reduced-motion: reduce`, false on the server and until mounted. */
|
|
6
|
+
export function useReducedMotion(): boolean {
|
|
7
|
+
const [reduced, setReduced] = useState(false);
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
10
|
+
setReduced(mq.matches);
|
|
11
|
+
const on = (e: MediaQueryListEvent) => setReduced(e.matches);
|
|
12
|
+
mq.addEventListener('change', on);
|
|
13
|
+
return () => mq.removeEventListener('change', on);
|
|
14
|
+
}, []);
|
|
15
|
+
return reduced;
|
|
16
|
+
}
|