@djangocfg/ui-core 2.1.293 → 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.
@@ -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
+ }