@djangocfg/ui-core 2.1.616 → 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 +7 -7
- package/src/hooks/router/README.md +8 -0
- package/src/hooks/router/adapter.tsx +24 -0
- package/src/hooks/router/adapters/nextjs.tsx +5 -0
- package/src/hooks/router/useNavigate.ts +21 -2
- package/src/hooks/router/useQueryState.ts +39 -12
- package/src/hooks/router/useRouter.ts +1 -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",
|
|
@@ -138,8 +138,8 @@
|
|
|
138
138
|
"check:docs": "node scripts/check-docs.mjs"
|
|
139
139
|
},
|
|
140
140
|
"peerDependencies": {
|
|
141
|
-
"@djangocfg/api": "^2.1.
|
|
142
|
-
"@djangocfg/i18n": "^2.1.
|
|
141
|
+
"@djangocfg/api": "^2.1.617",
|
|
142
|
+
"@djangocfg/i18n": "^2.1.617",
|
|
143
143
|
"consola": "^3.4.2",
|
|
144
144
|
"lucide-react": "^1.68.0",
|
|
145
145
|
"moment": "^2.30.1",
|
|
@@ -214,10 +214,10 @@
|
|
|
214
214
|
"vaul": "1.1.2"
|
|
215
215
|
},
|
|
216
216
|
"devDependencies": {
|
|
217
|
-
"@djangocfg/api": "^2.1.
|
|
218
|
-
"@djangocfg/eslint-config": "^2.1.
|
|
219
|
-
"@djangocfg/i18n": "^2.1.
|
|
220
|
-
"@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",
|
|
221
221
|
"@storybook/react-vite": "^10.6.5",
|
|
222
222
|
"@types/node": "^24.13.3",
|
|
223
223
|
"@types/react": "19.3.0",
|
|
@@ -123,6 +123,14 @@ Later or nested entries win field by field; an explicit option on a call beats t
|
|
|
123
123
|
|
|
124
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
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
|
+
|
|
126
134
|
## `useQueryState` (typed URL state)
|
|
127
135
|
|
|
128
136
|
Bound to one query key with a typed parser. Inspired by `nuqs` but framework-agnostic via the same adapter context.
|
|
@@ -77,6 +77,15 @@ export interface RouterAdapter {
|
|
|
77
77
|
forward: () => void;
|
|
78
78
|
/** Read the current location (synchronous). */
|
|
79
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;
|
|
80
89
|
}
|
|
81
90
|
|
|
82
91
|
const SSR_LOCATION: RouterLocation = Object.freeze({
|
|
@@ -115,8 +124,23 @@ export const defaultAdapter: RouterAdapter = Object.freeze({
|
|
|
115
124
|
hash: window.location.hash,
|
|
116
125
|
};
|
|
117
126
|
},
|
|
127
|
+
shallow(url: string, opts?: { replace?: boolean }) {
|
|
128
|
+
writeUrlShallow(url, opts?.replace ?? false);
|
|
129
|
+
},
|
|
118
130
|
});
|
|
119
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
|
+
|
|
120
144
|
/**
|
|
121
145
|
* React context carrying the active adapter. `null` means "use default".
|
|
122
146
|
* Kept intentionally nullable so we don't burn a Provider for the default case.
|
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
type RouterAdapter,
|
|
51
51
|
type RouterLocation,
|
|
52
52
|
type RouterNavigateOptions,
|
|
53
|
+
writeUrlShallow,
|
|
53
54
|
} from '../adapter';
|
|
54
55
|
import { useRouteProgress } from '../../../route-progress';
|
|
55
56
|
import { shouldStartProgress } from '../../../route-progress/provider/shouldStart';
|
|
@@ -132,6 +133,10 @@ export function NextRouterAdapter({ children, defaultScroll }: NextRouterAdapter
|
|
|
132
133
|
startBar(url, opts);
|
|
133
134
|
next.replace(url, nextOptions(opts));
|
|
134
135
|
},
|
|
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);
|
|
139
|
+
},
|
|
135
140
|
back() {
|
|
136
141
|
next.back();
|
|
137
142
|
},
|
|
@@ -52,6 +52,12 @@ export interface UseNavigateReturn {
|
|
|
52
52
|
* Use for logout, OAuth, or any cross-origin handoff.
|
|
53
53
|
*/
|
|
54
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;
|
|
55
61
|
/** Pass-through: adapter.push */
|
|
56
62
|
push: (url: string, opts?: RouterNavigateOptions) => void;
|
|
57
63
|
/** Pass-through: adapter.replace */
|
|
@@ -86,6 +92,19 @@ export function useNavigate(): UseNavigateReturn {
|
|
|
86
92
|
[adapter]
|
|
87
93
|
);
|
|
88
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
|
+
|
|
89
108
|
const navigateExternal = useCallback((href: string) => {
|
|
90
109
|
if (typeof window === 'undefined') return;
|
|
91
110
|
window.location.assign(href);
|
|
@@ -97,7 +116,7 @@ export function useNavigate(): UseNavigateReturn {
|
|
|
97
116
|
const forward = useCallback(() => adapter.forward(), [adapter]);
|
|
98
117
|
|
|
99
118
|
return useMemo<UseNavigateReturn>(
|
|
100
|
-
() => ({ navigate, navigateExternal, push, replace, back, forward }),
|
|
101
|
-
[navigate, navigateExternal, push, replace, back, forward]
|
|
119
|
+
() => ({ navigate, navigateExternal, shallow, push, replace, back, forward }),
|
|
120
|
+
[navigate, navigateExternal, shallow, push, replace, back, forward]
|
|
102
121
|
);
|
|
103
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';
|
|
@@ -34,6 +35,13 @@ export interface UseQueryStateOptions {
|
|
|
34
35
|
* transition. Pass `true` when the change loads a heavy server view.
|
|
35
36
|
*/
|
|
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;
|
|
37
45
|
/**
|
|
38
46
|
* When the new value equals the parser's default, drop the key from
|
|
39
47
|
* the URL instead of writing `?page=1`. Default: true (recommended —
|
|
@@ -64,25 +72,38 @@ export function useQueryState<T>(
|
|
|
64
72
|
options?: UseQueryStateOptions
|
|
65
73
|
): [T | null, (next: QueryStateUpdater<T>, opts?: UseQueryStateOptions) => void] {
|
|
66
74
|
const { pathname, search } = useLocation();
|
|
67
|
-
const { navigate } = useNavigate();
|
|
75
|
+
const { navigate, shallow } = useNavigate();
|
|
76
|
+
const adapter = useRouterAdapter();
|
|
68
77
|
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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]);
|
|
75
89
|
|
|
76
90
|
const setValue = useCallback(
|
|
77
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
|
+
|
|
78
99
|
const resolved =
|
|
79
100
|
typeof next === 'function'
|
|
80
101
|
? (next as (current: T) => T | null)(
|
|
81
|
-
(
|
|
102
|
+
(current ?? parser.defaultValue) as T
|
|
82
103
|
)
|
|
83
104
|
: next;
|
|
84
105
|
|
|
85
|
-
const params = new URLSearchParams(
|
|
106
|
+
const params = new URLSearchParams(currentSearch);
|
|
86
107
|
const clearOnDefault =
|
|
87
108
|
callOpts?.clearOnDefault ?? options?.clearOnDefault ?? true;
|
|
88
109
|
|
|
@@ -98,14 +119,20 @@ export function useQueryState<T>(
|
|
|
98
119
|
}
|
|
99
120
|
|
|
100
121
|
const qs = params.toString();
|
|
101
|
-
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
|
+
}
|
|
102
129
|
navigate(href, {
|
|
103
|
-
replace
|
|
130
|
+
replace,
|
|
104
131
|
scroll: callOpts?.scroll ?? options?.scroll ?? false,
|
|
105
132
|
progress: callOpts?.progress ?? options?.progress ?? false,
|
|
106
133
|
});
|
|
107
134
|
},
|
|
108
|
-
[pathname, search, key, parser, navigate, options, value]
|
|
135
|
+
[adapter, pathname, search, key, parser, parse, navigate, shallow, options, value]
|
|
109
136
|
);
|
|
110
137
|
|
|
111
138
|
return [value, setValue];
|