@djangocfg/ui-core 2.1.293 → 2.1.297
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 +127 -26
- package/package.json +16 -4
- package/src/components/feedback/sonner/index.tsx +1 -1
- package/src/components/forms/button/index.tsx +21 -5
- package/src/components/forms/button-download/index.tsx +1 -1
- package/src/components/forms/input/index.tsx +1 -1
- package/src/components/forms/otp/index.tsx +1 -1
- package/src/components/forms/slider/index.tsx +1 -1
- package/src/components/forms/textarea/index.tsx +1 -1
- package/src/components/index.ts +2 -0
- package/src/components/layout/sticky/index.tsx +1 -1
- package/src/components/navigation/accordion/index.tsx +1 -1
- package/src/components/navigation/dropdown-menu/index.tsx +3 -2
- package/src/components/navigation/link/Link.tsx +124 -0
- package/src/components/navigation/link/LinkContext.tsx +52 -0
- package/src/components/navigation/link/index.ts +8 -0
- package/src/components/navigation/menubar/index.tsx +3 -2
- package/src/components/navigation/navigation-menu/index.tsx +2 -1
- package/src/components/navigation/tabs/index.tsx +1 -1
- package/src/components/overlay/responsive-sheet/index.tsx +1 -1
- package/src/components/select/combobox.tsx +1 -1
- package/src/components/select/multi-select.tsx +1 -1
- package/src/components/specialized/image-with-fallback/index.tsx +1 -1
- package/src/hooks/debug/index.ts +3 -0
- package/src/hooks/device/index.ts +7 -0
- package/src/hooks/dom/index.ts +12 -0
- package/src/hooks/{useBodyScrollLock.ts → dom/useBodyScrollLock.ts} +1 -1
- package/src/hooks/{useCopy.ts → dom/useCopy.ts} +1 -1
- package/src/hooks/dom/useScroll.ts +322 -0
- package/src/hooks/events/index.ts +3 -0
- package/src/hooks/feedback/index.ts +3 -0
- package/src/hooks/hotkey/index.ts +4 -0
- package/src/hooks/index.ts +82 -26
- package/src/hooks/media/index.ts +5 -0
- package/src/hooks/router/README.md +121 -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 +140 -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
- package/src/hooks/state/index.ts +8 -0
- package/src/hooks/theme/index.ts +4 -0
- package/src/hooks/time/index.ts +4 -0
- package/src/lib/dialog-service/dialogs/AlertDialogUI.tsx +1 -1
- package/src/lib/dialog-service/dialogs/ConfirmDialogUI.tsx +1 -1
- package/src/lib/dialog-service/dialogs/PromptDialogUI.tsx +1 -1
- package/src/styles/palette/useThemePalette.ts +1 -1
- /package/src/hooks/{useDebugTools.ts → debug/useDebugTools.ts} +0 -0
- /package/src/hooks/{useBrowserDetect.ts → device/useBrowserDetect.ts} +0 -0
- /package/src/hooks/{useDeviceDetect.ts → device/useDeviceDetect.ts} +0 -0
- /package/src/hooks/{useShortcutModLabel.ts → device/useShortcutModLabel.ts} +0 -0
- /package/src/hooks/{useImageLoader.ts → dom/useImageLoader.ts} +0 -0
- /package/src/hooks/{useEventsBus.ts → events/useEventsBus.ts} +0 -0
- /package/src/hooks/{useToast.ts → feedback/useToast.ts} +0 -0
- /package/src/hooks/{useHotkey.ts → hotkey/useHotkey.ts} +0 -0
- /package/src/hooks/{useMediaQuery.ts → media/useMediaQuery.ts} +0 -0
- /package/src/hooks/{useMobile.tsx → media/useMobile.tsx} +0 -0
- /package/src/hooks/{useDebounce.ts → state/useDebounce.ts} +0 -0
- /package/src/hooks/{useDebouncedCallback.ts → state/useDebouncedCallback.ts} +0 -0
- /package/src/hooks/{useLocalStorage.ts → state/useLocalStorage.ts} +0 -0
- /package/src/hooks/{useSessionStorage.ts → state/useSessionStorage.ts} +0 -0
- /package/src/hooks/{useStoredValue.ts → state/useStoredValue.ts} +0 -0
- /package/src/hooks/{useResolvedTheme.ts → theme/useResolvedTheme.ts} +0 -0
- /package/src/hooks/{useCountdown.ts → time/useCountdown.ts} +0 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useIsActive — boolean for "current pathname matches this href".
|
|
5
|
+
*
|
|
6
|
+
* WHY:
|
|
7
|
+
* Highlighting the active item in a navbar / sidebar is the most-copy-
|
|
8
|
+
* pasted snippet in any SPA: `pathname === href || pathname.startsWith(href + '/')`.
|
|
9
|
+
* This hook bakes in the right semantics (exact-vs-prefix, trailing-slash
|
|
10
|
+
* tolerance, query/hash ignoring) so consumers don't have to reinvent them.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* const isActive = useIsActive('/dashboard');
|
|
14
|
+
* const isProductsActive = useIsActive('/products', { exact: false });
|
|
15
|
+
* <Link className={isActive ? 'active' : ''}>...</Link>
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { useMemo } from 'react';
|
|
19
|
+
import { useLocation } from './useLocation';
|
|
20
|
+
|
|
21
|
+
export interface UseIsActiveOptions {
|
|
22
|
+
/**
|
|
23
|
+
* `true` (default) — match only when pathname === href.
|
|
24
|
+
* `false` — match when pathname is href OR a sub-path (`href/...`).
|
|
25
|
+
* Most navbar items want `false`; tab strips usually want `true`.
|
|
26
|
+
*/
|
|
27
|
+
exact?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Strip a trailing slash from both sides before comparing.
|
|
30
|
+
* Default: true.
|
|
31
|
+
*/
|
|
32
|
+
ignoreTrailingSlash?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalize(path: string, ignoreTrailingSlash: boolean): string {
|
|
36
|
+
if (!ignoreTrailingSlash) return path;
|
|
37
|
+
if (path.length > 1 && path.endsWith('/')) return path.slice(0, -1);
|
|
38
|
+
return path;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Returns `true` if `href` matches the current pathname.
|
|
43
|
+
* Pure path-based — search and hash are ignored.
|
|
44
|
+
*/
|
|
45
|
+
export function useIsActive(href: string, options?: UseIsActiveOptions): boolean {
|
|
46
|
+
const { pathname } = useLocation();
|
|
47
|
+
const exact = options?.exact ?? false;
|
|
48
|
+
const ignoreTrailingSlash = options?.ignoreTrailingSlash ?? true;
|
|
49
|
+
|
|
50
|
+
return useMemo(() => {
|
|
51
|
+
// Strip search/hash from href if the consumer passed a full URL.
|
|
52
|
+
const cleanHref = href.split('?')[0]?.split('#')[0] ?? href;
|
|
53
|
+
const a = normalize(pathname, ignoreTrailingSlash);
|
|
54
|
+
const b = normalize(cleanHref, ignoreTrailingSlash);
|
|
55
|
+
if (a === b) return true;
|
|
56
|
+
if (exact) return false;
|
|
57
|
+
// Sub-path match: prefix + boundary so `/foobar` doesn't match `/foo`.
|
|
58
|
+
return a.startsWith(b + '/');
|
|
59
|
+
}, [pathname, href, exact, ignoreTrailingSlash]);
|
|
60
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useLocation — reactive snapshot of `window.location`.
|
|
5
|
+
*
|
|
6
|
+
* WHY:
|
|
7
|
+
* Native `popstate` only fires on back/forward, NOT on `pushState` /
|
|
8
|
+
* `replaceState`. To make SPA navigations observable we monkey-patch
|
|
9
|
+
* those two methods once (idempotent) and dispatch a custom
|
|
10
|
+
* `djc:navigate` event after each call. All router hooks, the default
|
|
11
|
+
* adapter, and any code that calls history.* APIs anywhere in the page
|
|
12
|
+
* will trigger this event automatically.
|
|
13
|
+
*
|
|
14
|
+
* We use `useSyncExternalStore` because that is the React-19-blessed
|
|
15
|
+
* way to bridge external mutable state (window.location) into React's
|
|
16
|
+
* render model — it gets concurrent-render and SSR right (via
|
|
17
|
+
* getServerSnapshot).
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* const { pathname, search, hash, href } = useLocation();
|
|
21
|
+
* useEffect(() => { analytics.page(pathname); }, [pathname]);
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { useSyncExternalStore } from 'react';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Browser-event name we dispatch when pushState/replaceState run.
|
|
28
|
+
* `pushState` and `replaceState` events fire alongside this for consumers
|
|
29
|
+
* that want to distinguish between the two history operations (wouter
|
|
30
|
+
* uses the same convention: separate per-method events PLUS a generic one).
|
|
31
|
+
*/
|
|
32
|
+
export const NAVIGATE_EVENT = 'djc:navigate';
|
|
33
|
+
export const PUSH_STATE_EVENT = 'pushState';
|
|
34
|
+
export const REPLACE_STATE_EVENT = 'replaceState';
|
|
35
|
+
|
|
36
|
+
/** Frozen snapshot returned to React. Identity changes only on URL change. */
|
|
37
|
+
export interface LocationSnapshot {
|
|
38
|
+
pathname: string;
|
|
39
|
+
search: string;
|
|
40
|
+
hash: string;
|
|
41
|
+
href: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const SSR_SNAPSHOT: LocationSnapshot = Object.freeze({
|
|
45
|
+
pathname: '/',
|
|
46
|
+
search: '',
|
|
47
|
+
hash: '',
|
|
48
|
+
href: '/',
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Patch-once guard via a Symbol on `window`. We use a Symbol-on-window
|
|
52
|
+
// (wouter's pattern) instead of tagging the function because it survives
|
|
53
|
+
// other libraries re-patching the methods on top of us — once anybody
|
|
54
|
+
// (us or them) has installed a patch, the marker stays until full reload.
|
|
55
|
+
const PATCH_KEY = Symbol.for('djc.router.historyPatched');
|
|
56
|
+
|
|
57
|
+
function patchHistoryOnce(): void {
|
|
58
|
+
if (typeof window === 'undefined') return;
|
|
59
|
+
const w = window as Window & { [PATCH_KEY]?: true };
|
|
60
|
+
if (w[PATCH_KEY]) return;
|
|
61
|
+
|
|
62
|
+
const originalPush = window.history.pushState.bind(window.history);
|
|
63
|
+
const originalReplace = window.history.replaceState.bind(window.history);
|
|
64
|
+
|
|
65
|
+
window.history.pushState = function patchedPushState(
|
|
66
|
+
...args: Parameters<History['pushState']>
|
|
67
|
+
) {
|
|
68
|
+
const result = originalPush(...args);
|
|
69
|
+
// Two events: the per-method one (consumers can listen narrowly)
|
|
70
|
+
// and a generic NAVIGATE_EVENT for "any url change" subscribers.
|
|
71
|
+
window.dispatchEvent(new Event(PUSH_STATE_EVENT));
|
|
72
|
+
window.dispatchEvent(new Event(NAVIGATE_EVENT));
|
|
73
|
+
return result;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
window.history.replaceState = function patchedReplaceState(
|
|
77
|
+
...args: Parameters<History['replaceState']>
|
|
78
|
+
) {
|
|
79
|
+
const result = originalReplace(...args);
|
|
80
|
+
window.dispatchEvent(new Event(REPLACE_STATE_EVENT));
|
|
81
|
+
window.dispatchEvent(new Event(NAVIGATE_EVENT));
|
|
82
|
+
return result;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
Object.defineProperty(w, PATCH_KEY, { value: true });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Cache the snapshot so getSnapshot returns the same reference between
|
|
89
|
+
// notifications. useSyncExternalStore demands referential stability between
|
|
90
|
+
// reads — recomputing the object on every call would loop in React 18+.
|
|
91
|
+
let cachedSnapshot: LocationSnapshot = SSR_SNAPSHOT;
|
|
92
|
+
|
|
93
|
+
function readLocation(): LocationSnapshot {
|
|
94
|
+
if (typeof window === 'undefined') return SSR_SNAPSHOT;
|
|
95
|
+
const { pathname, search, hash, href } = window.location;
|
|
96
|
+
// Only mint a new object if something actually changed.
|
|
97
|
+
if (
|
|
98
|
+
cachedSnapshot.pathname === pathname &&
|
|
99
|
+
cachedSnapshot.search === search &&
|
|
100
|
+
cachedSnapshot.hash === hash &&
|
|
101
|
+
cachedSnapshot.href === href
|
|
102
|
+
) {
|
|
103
|
+
return cachedSnapshot;
|
|
104
|
+
}
|
|
105
|
+
cachedSnapshot = Object.freeze({ pathname, search, hash, href });
|
|
106
|
+
return cachedSnapshot;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function subscribe(onChange: () => void): () => void {
|
|
110
|
+
if (typeof window === 'undefined') return () => {};
|
|
111
|
+
patchHistoryOnce();
|
|
112
|
+
// Wrap the callback so both popstate and our custom event refresh
|
|
113
|
+
// the cached snapshot before React re-reads it.
|
|
114
|
+
const handler = () => {
|
|
115
|
+
// Force re-read on next getSnapshot call by resetting the cache.
|
|
116
|
+
// We intentionally don't read here — getSnapshot will compare and
|
|
117
|
+
// return a stable ref if nothing changed (e.g. duplicate events).
|
|
118
|
+
onChange();
|
|
119
|
+
};
|
|
120
|
+
window.addEventListener('popstate', handler);
|
|
121
|
+
window.addEventListener(NAVIGATE_EVENT, handler);
|
|
122
|
+
// hashchange covers programmatic location.hash assignments that bypass
|
|
123
|
+
// pushState. Cheap to subscribe to.
|
|
124
|
+
window.addEventListener('hashchange', handler);
|
|
125
|
+
return () => {
|
|
126
|
+
window.removeEventListener('popstate', handler);
|
|
127
|
+
window.removeEventListener(NAVIGATE_EVENT, handler);
|
|
128
|
+
window.removeEventListener('hashchange', handler);
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function getServerSnapshot(): LocationSnapshot {
|
|
133
|
+
return SSR_SNAPSHOT;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Reactive snapshot of the current URL.
|
|
138
|
+
* Re-renders the calling component on every history navigation
|
|
139
|
+
* (pushState / replaceState / back / forward / hashchange).
|
|
140
|
+
*
|
|
141
|
+
* If you only need ONE field (e.g. just pathname), prefer
|
|
142
|
+
* `useLocationProperty(() => location.pathname)` — it subscribes to
|
|
143
|
+
* the same store but lets React skip re-renders when other fields
|
|
144
|
+
* change. Same pattern as `wouter`'s `usePathname` / `useSearch`.
|
|
145
|
+
*/
|
|
146
|
+
export function useLocation(): LocationSnapshot {
|
|
147
|
+
return useSyncExternalStore(subscribe, readLocation, getServerSnapshot);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Subscribe to one derived value from `window.location`. React only
|
|
152
|
+
* re-renders when the returned value changes — so a component reading
|
|
153
|
+
* just `pathname` won't re-render on `?page=2` updates.
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* const pathname = useLocationProperty(() => window.location.pathname, () => '/');
|
|
157
|
+
*/
|
|
158
|
+
export function useLocationProperty<T>(
|
|
159
|
+
getValue: () => T,
|
|
160
|
+
getServerValue: () => T
|
|
161
|
+
): T {
|
|
162
|
+
return useSyncExternalStore(subscribe, getValue, getServerValue);
|
|
163
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useNavigate — programmatic navigation primitive.
|
|
5
|
+
*
|
|
6
|
+
* WHY:
|
|
7
|
+
* Wraps the active adapter's push/replace and adds the small ergonomics
|
|
8
|
+
* we want on every nav: optional scroll-to-top, replace flag, and a
|
|
9
|
+
* distinct `navigateExternal` for full-reload flows (logout, OAuth,
|
|
10
|
+
* cross-origin redirects) where SPA navigation would be wrong.
|
|
11
|
+
*
|
|
12
|
+
* No `useTransition` here — that's a Next/React-internal concern; if
|
|
13
|
+
* the consumer wants pending state they wrap our calls themselves.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* const { navigate, navigateExternal } = useNavigate();
|
|
17
|
+
* navigate('/dashboard');
|
|
18
|
+
* navigate('/dashboard', { replace: true, scroll: false });
|
|
19
|
+
* navigateExternal('/api/auth/logout');
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { useCallback, useMemo } from 'react';
|
|
23
|
+
import { useRouterAdapter } from './adapter';
|
|
24
|
+
|
|
25
|
+
export interface NavigateOptions {
|
|
26
|
+
/** Use `replaceState` instead of `pushState`. Default: false. */
|
|
27
|
+
replace?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Scroll to top after navigation. Default: false.
|
|
30
|
+
*
|
|
31
|
+
* Most SPA flows (filter/pagination/tab switches) shouldn't jump,
|
|
32
|
+
* which is why this is opt-in. Pass `true` for top-level page
|
|
33
|
+
* transitions where you want the user back at the masthead.
|
|
34
|
+
*/
|
|
35
|
+
scroll?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface UseNavigateReturn {
|
|
39
|
+
/**
|
|
40
|
+
* SPA navigation through the active adapter.
|
|
41
|
+
* Default: pushState + scroll to top.
|
|
42
|
+
*/
|
|
43
|
+
navigate: (href: string, opts?: NavigateOptions) => void;
|
|
44
|
+
/**
|
|
45
|
+
* Hard navigation via `window.location.assign` — full page reload.
|
|
46
|
+
* Use for logout, OAuth, or any cross-origin handoff.
|
|
47
|
+
*/
|
|
48
|
+
navigateExternal: (href: string) => void;
|
|
49
|
+
/** Pass-through: adapter.push */
|
|
50
|
+
push: (url: string) => void;
|
|
51
|
+
/** Pass-through: adapter.replace */
|
|
52
|
+
replace: (url: string) => void;
|
|
53
|
+
/** Pass-through: adapter.back */
|
|
54
|
+
back: () => void;
|
|
55
|
+
/** Pass-through: adapter.forward */
|
|
56
|
+
forward: () => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Returns stable navigation functions backed by the active router adapter.
|
|
61
|
+
* Safe to put returned functions in deps arrays.
|
|
62
|
+
*/
|
|
63
|
+
export function useNavigate(): UseNavigateReturn {
|
|
64
|
+
const adapter = useRouterAdapter();
|
|
65
|
+
|
|
66
|
+
const navigate = useCallback(
|
|
67
|
+
(href: string, opts?: NavigateOptions) => {
|
|
68
|
+
const replace = opts?.replace ?? false;
|
|
69
|
+
const scroll = opts?.scroll ?? false;
|
|
70
|
+
if (replace) {
|
|
71
|
+
adapter.replace(href);
|
|
72
|
+
} else {
|
|
73
|
+
adapter.push(href);
|
|
74
|
+
}
|
|
75
|
+
if (scroll && typeof window !== 'undefined') {
|
|
76
|
+
window.scrollTo(0, 0);
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
[adapter]
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const navigateExternal = useCallback((href: string) => {
|
|
83
|
+
if (typeof window === 'undefined') return;
|
|
84
|
+
window.location.assign(href);
|
|
85
|
+
}, []);
|
|
86
|
+
|
|
87
|
+
const push = useCallback((url: string) => adapter.push(url), [adapter]);
|
|
88
|
+
const replace = useCallback((url: string) => adapter.replace(url), [adapter]);
|
|
89
|
+
const back = useCallback(() => adapter.back(), [adapter]);
|
|
90
|
+
const forward = useCallback(() => adapter.forward(), [adapter]);
|
|
91
|
+
|
|
92
|
+
return useMemo<UseNavigateReturn>(
|
|
93
|
+
() => ({ navigate, navigateExternal, push, replace, back, forward }),
|
|
94
|
+
[navigate, navigateExternal, push, replace, back, forward]
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -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
|
+
}
|