@djangocfg/ui-core 2.1.292 → 2.1.294
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 +50 -3
- package/package.json +16 -4
- package/src/hooks/index.ts +67 -0
- package/src/hooks/router/README.md +119 -0
- package/src/hooks/router/adapter.tsx +139 -0
- package/src/hooks/router/adapters/index.ts +5 -0
- package/src/hooks/router/adapters/nextjs.tsx +100 -0
- package/src/hooks/router/index.ts +90 -0
- package/src/hooks/router/parsers.ts +154 -0
- package/src/hooks/router/useBackOrFallback.ts +145 -0
- package/src/hooks/router/useIsActive.ts +60 -0
- package/src/hooks/router/useLocation.ts +163 -0
- package/src/hooks/router/useNavigate.ts +96 -0
- package/src/hooks/router/useQueryParams.ts +262 -0
- package/src/hooks/router/useQueryState.ts +106 -0
- package/src/hooks/router/useRouter.ts +81 -0
- package/src/hooks/router/useSmartLink.ts +157 -0
- package/src/hooks/router/useUrlBuilder.ts +118 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useQueryParams — read & write `?key=value` URL state.
|
|
5
|
+
*
|
|
6
|
+
* WHY:
|
|
7
|
+
* Pagination, filters, sort, search-as-you-type all live in the URL.
|
|
8
|
+
* This hook gives a typed, ergonomic surface (get/getNumber/getBoolean,
|
|
9
|
+
* set with merge semantics, remove, clear) so consumers don't reinvent
|
|
10
|
+
* URLSearchParams plumbing in every component.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* const { params, get, set, remove } = useQueryParams();
|
|
14
|
+
* const page = get('page', '1');
|
|
15
|
+
* set({ page: 2, sort: 'asc' }); // merges
|
|
16
|
+
* set({ q: 'foo' }, { reset: true }); // drops everything else
|
|
17
|
+
* set({ q: 'foo' }, { preserve: ['tab'] }); // keeps only `tab`
|
|
18
|
+
* remove(['page', 'sort']);
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { useCallback, useMemo } from 'react';
|
|
22
|
+
import { useLocation } from './useLocation';
|
|
23
|
+
import { useNavigate } from './useNavigate';
|
|
24
|
+
|
|
25
|
+
/** Snapshot of current params. Single value = string, repeated key = string[]. */
|
|
26
|
+
export type QueryParamsSnapshot = Record<string, string | string[]>;
|
|
27
|
+
|
|
28
|
+
/** Value type accepted by `set`. Empty/null/undefined ⇒ delete the key. */
|
|
29
|
+
export type QueryParamValue =
|
|
30
|
+
| string
|
|
31
|
+
| number
|
|
32
|
+
| boolean
|
|
33
|
+
| null
|
|
34
|
+
| undefined
|
|
35
|
+
| Array<string | number | boolean>;
|
|
36
|
+
|
|
37
|
+
export interface QueryParamUpdates {
|
|
38
|
+
[key: string]: QueryParamValue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SetQueryParamsOptions {
|
|
42
|
+
/** Use `replaceState` instead of `pushState`. Default: false. */
|
|
43
|
+
replace?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Drop ALL existing params before applying updates.
|
|
46
|
+
* Useful when filters change and pagination should reset.
|
|
47
|
+
*/
|
|
48
|
+
reset?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* If `reset` is true, keep these keys from the current URL.
|
|
51
|
+
* Ignored when `reset` is false.
|
|
52
|
+
*/
|
|
53
|
+
preserve?: string[];
|
|
54
|
+
/** Scroll to top after navigation. Default: false (filters/pagination shouldn't jump). */
|
|
55
|
+
scroll?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function snapshotFromSearch(search: string): QueryParamsSnapshot {
|
|
59
|
+
const out: QueryParamsSnapshot = {};
|
|
60
|
+
if (!search) return out;
|
|
61
|
+
const params = new URLSearchParams(search);
|
|
62
|
+
// Build with multi-value awareness.
|
|
63
|
+
for (const key of new Set(params.keys())) {
|
|
64
|
+
const all = params.getAll(key);
|
|
65
|
+
out[key] = all.length > 1 ? all : (all[0] ?? '');
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function applyUpdates(
|
|
71
|
+
target: URLSearchParams,
|
|
72
|
+
updates: QueryParamUpdates
|
|
73
|
+
): void {
|
|
74
|
+
for (const key of Object.keys(updates)) {
|
|
75
|
+
const value = updates[key];
|
|
76
|
+
target.delete(key);
|
|
77
|
+
if (value === null || value === undefined) continue;
|
|
78
|
+
if (Array.isArray(value)) {
|
|
79
|
+
for (const item of value) {
|
|
80
|
+
if (item === '' || item === null || item === undefined) continue;
|
|
81
|
+
target.append(key, String(item));
|
|
82
|
+
}
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (typeof value === 'string' && value === '') continue;
|
|
86
|
+
target.append(key, String(value));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface UseQueryParamsReturn {
|
|
91
|
+
/** Current params snapshot. Identity changes only on querystring change. */
|
|
92
|
+
params: QueryParamsSnapshot;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Read a single value (first one if repeated).
|
|
96
|
+
* Returns `fallback` (or `undefined`) when the key is missing.
|
|
97
|
+
*/
|
|
98
|
+
get: <T extends string = string>(key: string, fallback?: T) => T | undefined;
|
|
99
|
+
|
|
100
|
+
/** Read all values for a repeated key. Empty array if missing. */
|
|
101
|
+
getAll: (key: string) => string[];
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Read & coerce to number. Returns fallback (or `undefined`) when
|
|
105
|
+
* missing or unparseable. NaN is treated as missing.
|
|
106
|
+
*/
|
|
107
|
+
getNumber: (key: string, fallback?: number) => number | undefined;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Read & coerce to boolean. `'true'` / `'1'` / `''` (key present, no value)
|
|
111
|
+
* → true. Anything else → false. Returns fallback when key missing.
|
|
112
|
+
*/
|
|
113
|
+
getBoolean: (key: string, fallback?: boolean) => boolean | undefined;
|
|
114
|
+
|
|
115
|
+
/** Merge updates into current params and navigate. */
|
|
116
|
+
set: (updates: QueryParamUpdates, opts?: SetQueryParamsOptions) => void;
|
|
117
|
+
|
|
118
|
+
/** Drop one or more keys and navigate. */
|
|
119
|
+
remove: (keys: string | string[], opts?: SetQueryParamsOptions) => void;
|
|
120
|
+
|
|
121
|
+
/** Drop all params and navigate. */
|
|
122
|
+
clear: (opts?: SetQueryParamsOptions) => void;
|
|
123
|
+
|
|
124
|
+
/** Current querystring without leading `?`. */
|
|
125
|
+
toString: () => string;
|
|
126
|
+
|
|
127
|
+
/** Build `path?currentSearch` for use in `<a href={...}>`. */
|
|
128
|
+
toUrl: (path: string) => string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Reactive read + ergonomic write for `?key=value` URL state.
|
|
133
|
+
* Re-renders only when the search string changes.
|
|
134
|
+
*/
|
|
135
|
+
export function useQueryParams(): UseQueryParamsReturn {
|
|
136
|
+
const { pathname, search } = useLocation();
|
|
137
|
+
const { navigate } = useNavigate();
|
|
138
|
+
|
|
139
|
+
// Snapshot is rebuilt only when `search` changes — useMemo is enough.
|
|
140
|
+
const params = useMemo(() => snapshotFromSearch(search), [search]);
|
|
141
|
+
|
|
142
|
+
const get = useCallback(
|
|
143
|
+
<T extends string = string>(key: string, fallback?: T): T | undefined => {
|
|
144
|
+
const value = params[key];
|
|
145
|
+
if (value === undefined) return fallback;
|
|
146
|
+
const first = Array.isArray(value) ? value[0] : value;
|
|
147
|
+
if (first === undefined || first === '') return fallback;
|
|
148
|
+
return first as T;
|
|
149
|
+
},
|
|
150
|
+
[params]
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const getAll = useCallback(
|
|
154
|
+
(key: string): string[] => {
|
|
155
|
+
const value = params[key];
|
|
156
|
+
if (value === undefined) return [];
|
|
157
|
+
return Array.isArray(value) ? value : [value];
|
|
158
|
+
},
|
|
159
|
+
[params]
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const getNumber = useCallback(
|
|
163
|
+
(key: string, fallback?: number): number | undefined => {
|
|
164
|
+
const raw = get(key);
|
|
165
|
+
if (raw === undefined) return fallback;
|
|
166
|
+
const num = Number(raw);
|
|
167
|
+
return Number.isFinite(num) ? num : fallback;
|
|
168
|
+
},
|
|
169
|
+
[get]
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const getBoolean = useCallback(
|
|
173
|
+
(key: string, fallback?: boolean): boolean | undefined => {
|
|
174
|
+
const raw = get(key);
|
|
175
|
+
if (raw === undefined) return fallback;
|
|
176
|
+
// '' means key was present without a value (e.g. ?debug)
|
|
177
|
+
// — treat as truthy. Match URLSearchParams behavior.
|
|
178
|
+
if (raw === '' || raw === 'true' || raw === '1') return true;
|
|
179
|
+
return false;
|
|
180
|
+
},
|
|
181
|
+
[get]
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const navigateWithSearch = useCallback(
|
|
185
|
+
(next: URLSearchParams, opts?: SetQueryParamsOptions) => {
|
|
186
|
+
const qs = next.toString();
|
|
187
|
+
const href = qs ? `${pathname}?${qs}` : pathname;
|
|
188
|
+
navigate(href, {
|
|
189
|
+
replace: opts?.replace ?? false,
|
|
190
|
+
scroll: opts?.scroll ?? false,
|
|
191
|
+
});
|
|
192
|
+
},
|
|
193
|
+
[pathname, navigate]
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const set = useCallback(
|
|
197
|
+
(updates: QueryParamUpdates, opts?: SetQueryParamsOptions) => {
|
|
198
|
+
let next: URLSearchParams;
|
|
199
|
+
if (opts?.reset) {
|
|
200
|
+
next = new URLSearchParams();
|
|
201
|
+
if (opts.preserve && opts.preserve.length > 0) {
|
|
202
|
+
const current = new URLSearchParams(search);
|
|
203
|
+
for (const key of opts.preserve) {
|
|
204
|
+
const all = current.getAll(key);
|
|
205
|
+
for (const item of all) next.append(key, item);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
next = new URLSearchParams(search);
|
|
210
|
+
}
|
|
211
|
+
applyUpdates(next, updates);
|
|
212
|
+
navigateWithSearch(next, opts);
|
|
213
|
+
},
|
|
214
|
+
[search, navigateWithSearch]
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const remove = useCallback(
|
|
218
|
+
(keys: string | string[], opts?: SetQueryParamsOptions) => {
|
|
219
|
+
const next = new URLSearchParams(search);
|
|
220
|
+
const list = Array.isArray(keys) ? keys : [keys];
|
|
221
|
+
for (const key of list) next.delete(key);
|
|
222
|
+
navigateWithSearch(next, opts);
|
|
223
|
+
},
|
|
224
|
+
[search, navigateWithSearch]
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const clear = useCallback(
|
|
228
|
+
(opts?: SetQueryParamsOptions) => {
|
|
229
|
+
navigateWithSearch(new URLSearchParams(), opts);
|
|
230
|
+
},
|
|
231
|
+
[navigateWithSearch]
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const toString = useCallback((): string => {
|
|
235
|
+
// search includes the leading '?', strip it.
|
|
236
|
+
return search.startsWith('?') ? search.slice(1) : search;
|
|
237
|
+
}, [search]);
|
|
238
|
+
|
|
239
|
+
const toUrl = useCallback(
|
|
240
|
+
(path: string): string => {
|
|
241
|
+
const qs = toString();
|
|
242
|
+
return qs ? `${path}?${qs}` : path;
|
|
243
|
+
},
|
|
244
|
+
[toString]
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
return useMemo<UseQueryParamsReturn>(
|
|
248
|
+
() => ({
|
|
249
|
+
params,
|
|
250
|
+
get,
|
|
251
|
+
getAll,
|
|
252
|
+
getNumber,
|
|
253
|
+
getBoolean,
|
|
254
|
+
set,
|
|
255
|
+
remove,
|
|
256
|
+
clear,
|
|
257
|
+
toString,
|
|
258
|
+
toUrl,
|
|
259
|
+
}),
|
|
260
|
+
[params, get, getAll, getNumber, getBoolean, set, remove, clear, toString, toUrl]
|
|
261
|
+
);
|
|
262
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useQueryState — typed `useState`-style hook backed by ONE URL query key.
|
|
5
|
+
*
|
|
6
|
+
* WHY:
|
|
7
|
+
* `useQueryParams().get('page')` works, but you re-coerce to number
|
|
8
|
+
* in every component. `useQueryState('page', parseAsInteger.withDefault(1))`
|
|
9
|
+
* gives you `[number, setter]` directly, like `useState`. Setting to
|
|
10
|
+
* the parser's default value clears the key from the URL (no `?page=1`
|
|
11
|
+
* noise) — toggle off via `clearOnDefault: false`.
|
|
12
|
+
*
|
|
13
|
+
* Inspired by nuqs (47ng/nuqs) but framework-agnostic via our adapter.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
|
|
17
|
+
* const [tab, setTab] = useQueryState('tab', parseAsStringEnum(['a','b']).withDefault('a'));
|
|
18
|
+
* setPage((p) => p + 1); // functional updater
|
|
19
|
+
* setPage(null); // clear the key
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { useCallback, useMemo } from 'react';
|
|
23
|
+
import { useLocation } from './useLocation';
|
|
24
|
+
import { useNavigate } from './useNavigate';
|
|
25
|
+
import type { QueryParser } from './parsers';
|
|
26
|
+
|
|
27
|
+
export interface UseQueryStateOptions {
|
|
28
|
+
/** Use `replaceState` instead of `pushState`. Default: false. */
|
|
29
|
+
replace?: boolean;
|
|
30
|
+
/** Scroll to top after navigation. Default: false. */
|
|
31
|
+
scroll?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* When the new value equals the parser's default, drop the key from
|
|
34
|
+
* the URL instead of writing `?page=1`. Default: true (recommended —
|
|
35
|
+
* keeps URLs clean). Set false if your URLs are linked / bookmarked
|
|
36
|
+
* and you need explicit values.
|
|
37
|
+
*/
|
|
38
|
+
clearOnDefault?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type QueryStateUpdater<T> = T | null | ((current: T) => T | null);
|
|
42
|
+
|
|
43
|
+
// Two overloads so the return type narrows on `defaultValue`.
|
|
44
|
+
export function useQueryState<T>(
|
|
45
|
+
key: string,
|
|
46
|
+
parser: QueryParser<T> & { defaultValue: T },
|
|
47
|
+
options?: UseQueryStateOptions
|
|
48
|
+
): [T, (next: QueryStateUpdater<T>, opts?: UseQueryStateOptions) => void];
|
|
49
|
+
|
|
50
|
+
export function useQueryState<T>(
|
|
51
|
+
key: string,
|
|
52
|
+
parser: QueryParser<T>,
|
|
53
|
+
options?: UseQueryStateOptions
|
|
54
|
+
): [T | null, (next: QueryStateUpdater<T>, opts?: UseQueryStateOptions) => void];
|
|
55
|
+
|
|
56
|
+
export function useQueryState<T>(
|
|
57
|
+
key: string,
|
|
58
|
+
parser: QueryParser<T>,
|
|
59
|
+
options?: UseQueryStateOptions
|
|
60
|
+
): [T | null, (next: QueryStateUpdater<T>, opts?: UseQueryStateOptions) => void] {
|
|
61
|
+
const { pathname, search } = useLocation();
|
|
62
|
+
const { navigate } = useNavigate();
|
|
63
|
+
|
|
64
|
+
const value = useMemo<T | null>(() => {
|
|
65
|
+
const raw = new URLSearchParams(search).get(key);
|
|
66
|
+
if (raw === null) return parser.defaultValue ?? null;
|
|
67
|
+
const parsed = parser.parse(raw);
|
|
68
|
+
return parsed === null ? (parser.defaultValue ?? null) : parsed;
|
|
69
|
+
}, [search, key, parser]);
|
|
70
|
+
|
|
71
|
+
const setValue = useCallback(
|
|
72
|
+
(next: QueryStateUpdater<T>, callOpts?: UseQueryStateOptions) => {
|
|
73
|
+
const resolved =
|
|
74
|
+
typeof next === 'function'
|
|
75
|
+
? (next as (current: T) => T | null)(
|
|
76
|
+
(value ?? parser.defaultValue) as T
|
|
77
|
+
)
|
|
78
|
+
: next;
|
|
79
|
+
|
|
80
|
+
const params = new URLSearchParams(search);
|
|
81
|
+
const clearOnDefault =
|
|
82
|
+
callOpts?.clearOnDefault ?? options?.clearOnDefault ?? true;
|
|
83
|
+
|
|
84
|
+
if (
|
|
85
|
+
resolved === null ||
|
|
86
|
+
(clearOnDefault &&
|
|
87
|
+
parser.defaultValue !== undefined &&
|
|
88
|
+
parser.eq(resolved as T, parser.defaultValue))
|
|
89
|
+
) {
|
|
90
|
+
params.delete(key);
|
|
91
|
+
} else {
|
|
92
|
+
params.set(key, parser.serialize(resolved as T));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const qs = params.toString();
|
|
96
|
+
const href = qs ? `${pathname}?${qs}` : pathname;
|
|
97
|
+
navigate(href, {
|
|
98
|
+
replace: callOpts?.replace ?? options?.replace ?? false,
|
|
99
|
+
scroll: callOpts?.scroll ?? options?.scroll ?? false,
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
[pathname, search, key, parser, navigate, options, value]
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
return [value, setValue];
|
|
106
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useRouter — composite facade over the atomic router hooks.
|
|
5
|
+
*
|
|
6
|
+
* WHY:
|
|
7
|
+
* For consumers who want a single import that "feels like" Next's
|
|
8
|
+
* `useRouter`. Composes useLocation + useNavigate + useQueryParams +
|
|
9
|
+
* useBackOrFallback + useUrlBuilder.
|
|
10
|
+
*
|
|
11
|
+
* NOTE on perf and tree-shaking:
|
|
12
|
+
* This hook subscribes to EVERY URL change (location, search, depth)
|
|
13
|
+
* because it composes hooks that subscribe to those things. If your
|
|
14
|
+
* component only needs `navigate` (no location read), prefer the
|
|
15
|
+
* atomic `useNavigate()` to avoid extra re-renders. Same for
|
|
16
|
+
* `useQueryParams`, `useUrlBuilder`, etc. The atomic hooks also
|
|
17
|
+
* tree-shake better — pulling in just `useNavigate` doesn't pay the
|
|
18
|
+
* cost of the snapshot machinery.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* const router = useRouter();
|
|
22
|
+
* router.navigate('/users');
|
|
23
|
+
* router.query.set({ page: 2 });
|
|
24
|
+
* router.backOrFallback('/dashboard');
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { useMemo } from 'react';
|
|
28
|
+
import { useLocation, type LocationSnapshot } from './useLocation';
|
|
29
|
+
import { useNavigate, type UseNavigateReturn } from './useNavigate';
|
|
30
|
+
import { useQueryParams, type UseQueryParamsReturn } from './useQueryParams';
|
|
31
|
+
import { useBackOrFallback } from './useBackOrFallback';
|
|
32
|
+
import { useUrlBuilder, type UseUrlBuilderReturn } from './useUrlBuilder';
|
|
33
|
+
|
|
34
|
+
export interface UseRouterReturn extends LocationSnapshot, UseNavigateReturn {
|
|
35
|
+
/** Same as `useQueryParams()`. */
|
|
36
|
+
query: UseQueryParamsReturn;
|
|
37
|
+
/** Same as `useUrlBuilder().build`. */
|
|
38
|
+
build: UseUrlBuilderReturn['build'];
|
|
39
|
+
/** Same as `useUrlBuilder().withCurrentParams`. */
|
|
40
|
+
withCurrentParams: UseUrlBuilderReturn['withCurrentParams'];
|
|
41
|
+
/** Same as `useBackOrFallback().back`. */
|
|
42
|
+
backOrFallback: (fallback?: string) => void;
|
|
43
|
+
/** Same as `useBackOrFallback().canGoBack`. */
|
|
44
|
+
canGoBack: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Convenience hook that exposes the full router surface.
|
|
49
|
+
* For minimal re-renders / smaller bundles, prefer the atomic hooks.
|
|
50
|
+
*/
|
|
51
|
+
export function useRouter(): UseRouterReturn {
|
|
52
|
+
const location = useLocation();
|
|
53
|
+
const nav = useNavigate();
|
|
54
|
+
const query = useQueryParams();
|
|
55
|
+
const builder = useUrlBuilder();
|
|
56
|
+
const { back: backOrFallback, canGoBack } = useBackOrFallback();
|
|
57
|
+
|
|
58
|
+
return useMemo<UseRouterReturn>(
|
|
59
|
+
() => ({
|
|
60
|
+
// Location snapshot
|
|
61
|
+
pathname: location.pathname,
|
|
62
|
+
search: location.search,
|
|
63
|
+
hash: location.hash,
|
|
64
|
+
href: location.href,
|
|
65
|
+
// Navigation pass-through
|
|
66
|
+
navigate: nav.navigate,
|
|
67
|
+
navigateExternal: nav.navigateExternal,
|
|
68
|
+
push: nav.push,
|
|
69
|
+
replace: nav.replace,
|
|
70
|
+
back: nav.back,
|
|
71
|
+
forward: nav.forward,
|
|
72
|
+
// Sub-APIs
|
|
73
|
+
query,
|
|
74
|
+
build: builder.build,
|
|
75
|
+
withCurrentParams: builder.withCurrentParams,
|
|
76
|
+
backOrFallback,
|
|
77
|
+
canGoBack,
|
|
78
|
+
}),
|
|
79
|
+
[location, nav, query, builder, backOrFallback, canGoBack]
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useSmartLink — turn a non-`<a>` element (card, table row, list item)
|
|
5
|
+
* into a proper link.
|
|
6
|
+
*
|
|
7
|
+
* WHY:
|
|
8
|
+
* "Clickable cards" used to either nest an `<a>` (which then can't
|
|
9
|
+
* contain other interactive children) or attach `onClick={navigate}`
|
|
10
|
+
* (which loses cmd-click, middle-click, keyboard, accessibility).
|
|
11
|
+
* This hook returns a small bag of handlers that gives a non-anchor
|
|
12
|
+
* element the right behavior in all those cases.
|
|
13
|
+
*
|
|
14
|
+
* Key behaviors:
|
|
15
|
+
* - Cmd/Ctrl+click and middle-click open in a new tab.
|
|
16
|
+
* - Plain click does SPA nav.
|
|
17
|
+
* - Enter / Space activate from keyboard.
|
|
18
|
+
* - Clicks inside nested `<a>` / `<button>` are ignored (so the
|
|
19
|
+
* inner element handles its own action).
|
|
20
|
+
* - `role="link"` and `tabIndex={0}` for screen readers / keyboard.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* const link = useSmartLink('/products/42');
|
|
24
|
+
* <div {...link}>Product card</div>
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { useCallback, useMemo, type KeyboardEvent, type MouseEvent } from 'react';
|
|
28
|
+
import { useNavigate, type NavigateOptions } from './useNavigate';
|
|
29
|
+
|
|
30
|
+
export interface UseSmartLinkOptions extends NavigateOptions {
|
|
31
|
+
/** Disable all navigation (e.g. while a row is being edited). */
|
|
32
|
+
disabled?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* If true, modifier-clicks (cmd/ctrl) and middle-clicks DON'T open
|
|
35
|
+
* a new tab — they're treated as plain clicks. Default: false.
|
|
36
|
+
*/
|
|
37
|
+
ignoreModifiers?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SmartLinkHandlers {
|
|
41
|
+
onClick: (event: MouseEvent<HTMLElement>) => void;
|
|
42
|
+
onAuxClick: (event: MouseEvent<HTMLElement>) => void;
|
|
43
|
+
onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
|
|
44
|
+
role: 'link';
|
|
45
|
+
/** -1 when disabled so the element is removed from the tab order. */
|
|
46
|
+
tabIndex: 0 | -1;
|
|
47
|
+
/** Forwarded to the consumer element so AT announces disabled state. */
|
|
48
|
+
'aria-disabled'?: true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const INTERACTIVE_SELECTOR =
|
|
52
|
+
'a,button,input,textarea,select,label,[role="button"],[role="link"],[role="checkbox"],[role="switch"],[role="tab"],[role="menuitem"]';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* True if the click target is inside another interactive element
|
|
56
|
+
* nested within `currentTarget` — in which case we shouldn't intercept.
|
|
57
|
+
* Uses Element.closest for a single C-side traversal that also handles
|
|
58
|
+
* SVG/MathML, custom elements, and `:where()` semantics correctly.
|
|
59
|
+
*/
|
|
60
|
+
function isInsideNestedInteractive(event: MouseEvent<HTMLElement>): boolean {
|
|
61
|
+
const target = event.target as Element | null;
|
|
62
|
+
const current = event.currentTarget;
|
|
63
|
+
if (!target || target === current) return false;
|
|
64
|
+
const interactive = target.closest(INTERACTIVE_SELECTOR);
|
|
65
|
+
// Found an interactive ancestor strictly between target and currentTarget.
|
|
66
|
+
return !!interactive && interactive !== current && current.contains(interactive);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** True if the user has selected text — don't navigate, they're reading. */
|
|
70
|
+
function hasTextSelection(): boolean {
|
|
71
|
+
if (typeof window === 'undefined') return false;
|
|
72
|
+
const selection = window.getSelection();
|
|
73
|
+
return !!selection && selection.toString().length > 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Hook variant — gives an element link semantics with keyboard + new-tab support.
|
|
78
|
+
*/
|
|
79
|
+
export function useSmartLink(
|
|
80
|
+
href: string,
|
|
81
|
+
options?: UseSmartLinkOptions
|
|
82
|
+
): SmartLinkHandlers {
|
|
83
|
+
const { navigate } = useNavigate();
|
|
84
|
+
const disabled = options?.disabled ?? false;
|
|
85
|
+
const ignoreModifiers = options?.ignoreModifiers ?? false;
|
|
86
|
+
const replace = options?.replace;
|
|
87
|
+
const scroll = options?.scroll;
|
|
88
|
+
|
|
89
|
+
const openInNewTab = useCallback((url: string) => {
|
|
90
|
+
if (typeof window === 'undefined') return;
|
|
91
|
+
window.open(url, '_blank', 'noopener,noreferrer');
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
94
|
+
const onClick = useCallback(
|
|
95
|
+
(event: MouseEvent<HTMLElement>) => {
|
|
96
|
+
if (disabled) return;
|
|
97
|
+
if (event.defaultPrevented) return;
|
|
98
|
+
if (isInsideNestedInteractive(event)) return;
|
|
99
|
+
if (hasTextSelection()) return;
|
|
100
|
+
|
|
101
|
+
// Modifier click → open in new tab. Don't preventDefault on a
|
|
102
|
+
// real <a>, but for div/span our default IS to navigate so we
|
|
103
|
+
// can branch freely.
|
|
104
|
+
if (
|
|
105
|
+
!ignoreModifiers &&
|
|
106
|
+
(event.metaKey || event.ctrlKey || event.shiftKey)
|
|
107
|
+
) {
|
|
108
|
+
event.preventDefault();
|
|
109
|
+
openInNewTab(href);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
event.preventDefault();
|
|
114
|
+
navigate(href, { replace, scroll });
|
|
115
|
+
},
|
|
116
|
+
[disabled, ignoreModifiers, navigate, href, replace, scroll, openInNewTab]
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const onAuxClick = useCallback(
|
|
120
|
+
(event: MouseEvent<HTMLElement>) => {
|
|
121
|
+
if (disabled) return;
|
|
122
|
+
if (event.defaultPrevented) return;
|
|
123
|
+
if (isInsideNestedInteractive(event)) return;
|
|
124
|
+
// Middle-click only.
|
|
125
|
+
if (event.button !== 1) return;
|
|
126
|
+
if (ignoreModifiers) return;
|
|
127
|
+
event.preventDefault();
|
|
128
|
+
openInNewTab(href);
|
|
129
|
+
},
|
|
130
|
+
[disabled, ignoreModifiers, href, openInNewTab]
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const onKeyDown = useCallback(
|
|
134
|
+
(event: KeyboardEvent<HTMLElement>) => {
|
|
135
|
+
if (disabled) return;
|
|
136
|
+
// Only handle when the focused element IS the link container,
|
|
137
|
+
// otherwise we steal Enter from inputs etc.
|
|
138
|
+
if (event.target !== event.currentTarget) return;
|
|
139
|
+
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
140
|
+
event.preventDefault();
|
|
141
|
+
navigate(href, { replace, scroll });
|
|
142
|
+
},
|
|
143
|
+
[disabled, navigate, href, replace, scroll]
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
return useMemo<SmartLinkHandlers>(
|
|
147
|
+
() => ({
|
|
148
|
+
onClick,
|
|
149
|
+
onAuxClick,
|
|
150
|
+
onKeyDown,
|
|
151
|
+
role: 'link',
|
|
152
|
+
tabIndex: disabled ? -1 : 0,
|
|
153
|
+
...(disabled ? { 'aria-disabled': true as const } : {}),
|
|
154
|
+
}),
|
|
155
|
+
[onClick, onAuxClick, onKeyDown, disabled]
|
|
156
|
+
);
|
|
157
|
+
}
|