@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,139 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Router adapter — pluggable navigation backend.
|
|
5
|
+
*
|
|
6
|
+
* WHY:
|
|
7
|
+
* `@djangocfg/ui-core` is framework-agnostic, but consumers using Next.js,
|
|
8
|
+
* TanStack Router, wouter, etc. need SPA navigations to flow through their
|
|
9
|
+
* own router so server components / data loaders / route guards fire
|
|
10
|
+
* correctly. The adapter pattern lets the consumer swap the implementation
|
|
11
|
+
* without changing call-sites inside library components.
|
|
12
|
+
*
|
|
13
|
+
* Default behavior uses `window.history.pushState` + `window.location` (works
|
|
14
|
+
* in any browser, zero deps). When no provider is mounted, the default is
|
|
15
|
+
* silently used.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* // In a Next.js app:
|
|
19
|
+
* import { useRouter as useNextRouter } from 'next/navigation';
|
|
20
|
+
* import { RouterAdapterProvider } from '@djangocfg/ui-core/hooks';
|
|
21
|
+
*
|
|
22
|
+
* function NextAdapter({ children }: { children: React.ReactNode }) {
|
|
23
|
+
* const next = useNextRouter();
|
|
24
|
+
* const adapter = useMemo(() => ({
|
|
25
|
+
* push: (url: string) => next.push(url),
|
|
26
|
+
* replace: (url: string) => next.replace(url),
|
|
27
|
+
* back: () => next.back(),
|
|
28
|
+
* forward: () => next.forward(),
|
|
29
|
+
* getLocation: () => ({
|
|
30
|
+
* pathname: window.location.pathname,
|
|
31
|
+
* search: window.location.search,
|
|
32
|
+
* hash: window.location.hash,
|
|
33
|
+
* }),
|
|
34
|
+
* }), [next]);
|
|
35
|
+
* return <RouterAdapterProvider value={adapter}>{children}</RouterAdapterProvider>;
|
|
36
|
+
* }
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { createContext, useContext, useMemo, type ReactNode } from 'react';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Snapshot of the current URL location.
|
|
43
|
+
* Mirrors the parts of `window.location` we actually use.
|
|
44
|
+
*/
|
|
45
|
+
export interface RouterLocation {
|
|
46
|
+
pathname: string;
|
|
47
|
+
search: string;
|
|
48
|
+
hash: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Pluggable navigation backend. Implementations must be SSR-safe
|
|
53
|
+
* (mutations should no-op when `typeof window === 'undefined'`).
|
|
54
|
+
*/
|
|
55
|
+
export interface RouterAdapter {
|
|
56
|
+
/** Push a new entry onto the history stack. */
|
|
57
|
+
push: (url: string) => void;
|
|
58
|
+
/** Replace the current history entry. */
|
|
59
|
+
replace: (url: string) => void;
|
|
60
|
+
/** Go back one entry. */
|
|
61
|
+
back: () => void;
|
|
62
|
+
/** Go forward one entry. */
|
|
63
|
+
forward: () => void;
|
|
64
|
+
/** Read the current location (synchronous). */
|
|
65
|
+
getLocation: () => RouterLocation;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const SSR_LOCATION: RouterLocation = Object.freeze({
|
|
69
|
+
pathname: '/',
|
|
70
|
+
search: '',
|
|
71
|
+
hash: '',
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Default adapter — uses History API + window.location.
|
|
76
|
+
* No-ops on the server. Triggers our internal `djc:navigate` event so
|
|
77
|
+
* `useLocation` reflects the change (see `useLocation.ts`).
|
|
78
|
+
*/
|
|
79
|
+
export const defaultAdapter: RouterAdapter = Object.freeze({
|
|
80
|
+
push(url: string) {
|
|
81
|
+
if (typeof window === 'undefined') return;
|
|
82
|
+
window.history.pushState(null, '', url);
|
|
83
|
+
},
|
|
84
|
+
replace(url: string) {
|
|
85
|
+
if (typeof window === 'undefined') return;
|
|
86
|
+
window.history.replaceState(null, '', url);
|
|
87
|
+
},
|
|
88
|
+
back() {
|
|
89
|
+
if (typeof window === 'undefined') return;
|
|
90
|
+
window.history.back();
|
|
91
|
+
},
|
|
92
|
+
forward() {
|
|
93
|
+
if (typeof window === 'undefined') return;
|
|
94
|
+
window.history.forward();
|
|
95
|
+
},
|
|
96
|
+
getLocation(): RouterLocation {
|
|
97
|
+
if (typeof window === 'undefined') return SSR_LOCATION;
|
|
98
|
+
return {
|
|
99
|
+
pathname: window.location.pathname,
|
|
100
|
+
search: window.location.search,
|
|
101
|
+
hash: window.location.hash,
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* React context carrying the active adapter. `null` means "use default".
|
|
108
|
+
* Kept intentionally nullable so we don't burn a Provider for the default case.
|
|
109
|
+
*/
|
|
110
|
+
export const RouterAdapterContext = createContext<RouterAdapter | null>(null);
|
|
111
|
+
|
|
112
|
+
export interface RouterAdapterProviderProps {
|
|
113
|
+
/** Adapter implementation. Will be used by every router hook in subtree. */
|
|
114
|
+
value: RouterAdapter;
|
|
115
|
+
children: ReactNode;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Wrap a subtree to override the navigation backend used by router hooks.
|
|
120
|
+
*/
|
|
121
|
+
export function RouterAdapterProvider({ value, children }: RouterAdapterProviderProps) {
|
|
122
|
+
// Memoizing here is the consumer's job (the value usually comes from another hook).
|
|
123
|
+
// We don't double-memo — that just adds noise.
|
|
124
|
+
return (
|
|
125
|
+
<RouterAdapterContext.Provider value={value}>
|
|
126
|
+
{children}
|
|
127
|
+
</RouterAdapterContext.Provider>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Read the active router adapter. Returns the default History API adapter
|
|
133
|
+
* if no provider is mounted.
|
|
134
|
+
*/
|
|
135
|
+
export function useRouterAdapter(): RouterAdapter {
|
|
136
|
+
const ctx = useContext(RouterAdapterContext);
|
|
137
|
+
// useMemo so consumers can put the returned value in deps without churn.
|
|
138
|
+
return useMemo(() => ctx ?? defaultAdapter, [ctx]);
|
|
139
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Adapters live behind sub-path imports so framework-specific peer
|
|
2
|
+
// deps (next, react-router, etc.) load only when the consumer asks
|
|
3
|
+
// for them. Do NOT re-export adapters from the package barrel — that
|
|
4
|
+
// would force every consumer to resolve all peer deps.
|
|
5
|
+
export type {} from '../adapter';
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Next.js adapter — bridges our framework-agnostic router hooks
|
|
5
|
+
* to `next/navigation` so server components, route loaders, and
|
|
6
|
+
* `prefetch` all fire correctly.
|
|
7
|
+
*
|
|
8
|
+
* USAGE:
|
|
9
|
+
* Mount once near the root of the App Router tree, e.g. inside
|
|
10
|
+
* the locale layout's client provider stack:
|
|
11
|
+
*
|
|
12
|
+
* ```tsx
|
|
13
|
+
* import { NextRouterAdapter } from '@djangocfg/ui-core/adapters/nextjs';
|
|
14
|
+
*
|
|
15
|
+
* <I18nProvider locale={locale} messages={messages}>
|
|
16
|
+
* <NextRouterAdapter>
|
|
17
|
+
* <AppLayout>{children}</AppLayout>
|
|
18
|
+
* </NextRouterAdapter>
|
|
19
|
+
* </I18nProvider>
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* Without this provider, our hooks fall back to the History API
|
|
23
|
+
* adapter — which works in plain SPAs (Wails, Electron, Vite, CRA)
|
|
24
|
+
* but in Next.js means: no server component refetch on navigation,
|
|
25
|
+
* no route loader, no prefetch. So always mount it in Next apps.
|
|
26
|
+
*
|
|
27
|
+
* PEER DEPENDENCY:
|
|
28
|
+
* `next` is an OPTIONAL peer of @djangocfg/ui-core. The base package
|
|
29
|
+
* never imports from `next/*`. This sub-path entry does — so it only
|
|
30
|
+
* loads when the consumer explicitly imports `/adapters/nextjs`.
|
|
31
|
+
* Wails / Electron consumers: don't import this file and `next` is
|
|
32
|
+
* never resolved.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { forwardRef, useMemo, type ReactNode } from 'react';
|
|
36
|
+
import { useRouter as useNextRouter } from 'next/navigation';
|
|
37
|
+
import NextLink from 'next/link';
|
|
38
|
+
|
|
39
|
+
import {
|
|
40
|
+
RouterAdapterProvider,
|
|
41
|
+
type RouterAdapter,
|
|
42
|
+
type RouterLocation,
|
|
43
|
+
} from '../adapter';
|
|
44
|
+
import {
|
|
45
|
+
LinkProvider,
|
|
46
|
+
type LinkComponent,
|
|
47
|
+
type LinkComponentProps,
|
|
48
|
+
} from '../../../components/navigation/link';
|
|
49
|
+
|
|
50
|
+
const SSR_LOCATION: RouterLocation = Object.freeze({
|
|
51
|
+
pathname: '/',
|
|
52
|
+
search: '',
|
|
53
|
+
hash: '',
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export interface NextRouterAdapterProps {
|
|
57
|
+
children: ReactNode;
|
|
58
|
+
/**
|
|
59
|
+
* Pass `scroll: false` to every Next router call by default.
|
|
60
|
+
* Useful for filter/pagination apps where the page should never
|
|
61
|
+
* jump on URL changes. Default: undefined (use Next's own default).
|
|
62
|
+
*/
|
|
63
|
+
defaultScroll?: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Wraps a subtree so all `@djangocfg/ui-core/hooks` router calls go
|
|
68
|
+
* through Next's App Router. Server components, loaders, and prefetch
|
|
69
|
+
* keep working as if you used `next/navigation` directly.
|
|
70
|
+
*/
|
|
71
|
+
export function NextRouterAdapter({
|
|
72
|
+
children,
|
|
73
|
+
defaultScroll,
|
|
74
|
+
}: NextRouterAdapterProps) {
|
|
75
|
+
const next = useNextRouter();
|
|
76
|
+
|
|
77
|
+
const adapter = useMemo<RouterAdapter>(
|
|
78
|
+
() => ({
|
|
79
|
+
push(url) {
|
|
80
|
+
next.push(url, defaultScroll === undefined ? undefined : { scroll: defaultScroll });
|
|
81
|
+
},
|
|
82
|
+
replace(url) {
|
|
83
|
+
next.replace(url, defaultScroll === undefined ? undefined : { scroll: defaultScroll });
|
|
84
|
+
},
|
|
85
|
+
back() {
|
|
86
|
+
next.back();
|
|
87
|
+
},
|
|
88
|
+
forward() {
|
|
89
|
+
next.forward();
|
|
90
|
+
},
|
|
91
|
+
getLocation() {
|
|
92
|
+
if (typeof window === 'undefined') return SSR_LOCATION;
|
|
93
|
+
return {
|
|
94
|
+
pathname: window.location.pathname,
|
|
95
|
+
search: window.location.search,
|
|
96
|
+
hash: window.location.hash,
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
}),
|
|
100
|
+
[next, defaultScroll]
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<RouterAdapterProvider value={adapter}>{children}</RouterAdapterProvider>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Maps our agnostic Link API → next/link props. Lives at module scope so
|
|
110
|
+
* the component identity is stable (avoids tree remounts on every render).
|
|
111
|
+
*/
|
|
112
|
+
const NextLinkAdapter: LinkComponent = forwardRef<HTMLAnchorElement, LinkComponentProps>(
|
|
113
|
+
function NextLinkAdapter({ href, replace, scroll, prefetch, children, ...rest }, ref) {
|
|
114
|
+
return (
|
|
115
|
+
<NextLink
|
|
116
|
+
href={href}
|
|
117
|
+
replace={replace}
|
|
118
|
+
scroll={scroll}
|
|
119
|
+
prefetch={prefetch ?? undefined}
|
|
120
|
+
ref={ref}
|
|
121
|
+
{...rest}
|
|
122
|
+
>
|
|
123
|
+
{children}
|
|
124
|
+
</NextLink>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
export interface NextLinkProviderProps {
|
|
130
|
+
children: ReactNode;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Wires `<Link>` from `@djangocfg/ui-core/components` to `next/link`.
|
|
135
|
+
* Mount alongside `NextRouterAdapter` near the root of a Next app so
|
|
136
|
+
* every Link picks up Next's prefetch / RSC handling automatically.
|
|
137
|
+
*/
|
|
138
|
+
export function NextLinkProvider({ children }: NextLinkProviderProps) {
|
|
139
|
+
return <LinkProvider value={NextLinkAdapter}>{children}</LinkProvider>;
|
|
140
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Router hooks — framework-agnostic navigation primitives.
|
|
3
|
+
// See ./README.md for design rationale and Next.js adapter example.
|
|
4
|
+
// ============================================================================
|
|
5
|
+
|
|
6
|
+
'use client';
|
|
7
|
+
|
|
8
|
+
// Adapter (context + provider + default + types)
|
|
9
|
+
export {
|
|
10
|
+
RouterAdapterContext,
|
|
11
|
+
RouterAdapterProvider,
|
|
12
|
+
defaultAdapter,
|
|
13
|
+
useRouterAdapter,
|
|
14
|
+
} from './adapter';
|
|
15
|
+
export type {
|
|
16
|
+
RouterAdapter,
|
|
17
|
+
RouterAdapterProviderProps,
|
|
18
|
+
RouterLocation,
|
|
19
|
+
} from './adapter';
|
|
20
|
+
|
|
21
|
+
// useLocation (+ per-property subscription)
|
|
22
|
+
export {
|
|
23
|
+
useLocation,
|
|
24
|
+
useLocationProperty,
|
|
25
|
+
NAVIGATE_EVENT,
|
|
26
|
+
PUSH_STATE_EVENT,
|
|
27
|
+
REPLACE_STATE_EVENT,
|
|
28
|
+
} from './useLocation';
|
|
29
|
+
export type { LocationSnapshot } from './useLocation';
|
|
30
|
+
|
|
31
|
+
// useNavigate
|
|
32
|
+
export { useNavigate } from './useNavigate';
|
|
33
|
+
export type { NavigateOptions, UseNavigateReturn } from './useNavigate';
|
|
34
|
+
|
|
35
|
+
// useQueryParams
|
|
36
|
+
export { useQueryParams } from './useQueryParams';
|
|
37
|
+
export type {
|
|
38
|
+
QueryParamsSnapshot,
|
|
39
|
+
QueryParamValue,
|
|
40
|
+
QueryParamUpdates,
|
|
41
|
+
SetQueryParamsOptions,
|
|
42
|
+
UseQueryParamsReturn,
|
|
43
|
+
} from './useQueryParams';
|
|
44
|
+
|
|
45
|
+
// useBackOrFallback
|
|
46
|
+
export { useBackOrFallback } from './useBackOrFallback';
|
|
47
|
+
export type { UseBackOrFallbackReturn } from './useBackOrFallback';
|
|
48
|
+
|
|
49
|
+
// useUrlBuilder (+ pure helpers)
|
|
50
|
+
export { useUrlBuilder, buildUrl, buildQueryString } from './useUrlBuilder';
|
|
51
|
+
export type {
|
|
52
|
+
QueryValue,
|
|
53
|
+
QueryParamsInput,
|
|
54
|
+
UseUrlBuilderReturn,
|
|
55
|
+
} from './useUrlBuilder';
|
|
56
|
+
|
|
57
|
+
// useSmartLink
|
|
58
|
+
export { useSmartLink } from './useSmartLink';
|
|
59
|
+
export type {
|
|
60
|
+
UseSmartLinkOptions,
|
|
61
|
+
SmartLinkHandlers,
|
|
62
|
+
} from './useSmartLink';
|
|
63
|
+
|
|
64
|
+
// useIsActive
|
|
65
|
+
export { useIsActive } from './useIsActive';
|
|
66
|
+
export type { UseIsActiveOptions } from './useIsActive';
|
|
67
|
+
|
|
68
|
+
// useQueryState (typed single-key URL state, nuqs-style)
|
|
69
|
+
export { useQueryState } from './useQueryState';
|
|
70
|
+
export type {
|
|
71
|
+
UseQueryStateOptions,
|
|
72
|
+
QueryStateUpdater,
|
|
73
|
+
} from './useQueryState';
|
|
74
|
+
|
|
75
|
+
// Parsers (typed marshalling between URL strings and JS values)
|
|
76
|
+
export {
|
|
77
|
+
parseAsString,
|
|
78
|
+
parseAsInteger,
|
|
79
|
+
parseAsFloat,
|
|
80
|
+
parseAsBoolean,
|
|
81
|
+
parseAsIsoDate,
|
|
82
|
+
parseAsStringEnum,
|
|
83
|
+
parseAsArrayOf,
|
|
84
|
+
parseAsJson,
|
|
85
|
+
} from './parsers';
|
|
86
|
+
export type { QueryParser, QueryParserBuilder } from './parsers';
|
|
87
|
+
|
|
88
|
+
// useRouter (composite facade)
|
|
89
|
+
export { useRouter } from './useRouter';
|
|
90
|
+
export type { UseRouterReturn } from './useRouter';
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query string parsers — typed marshalling between URL strings and JS values.
|
|
3
|
+
*
|
|
4
|
+
* WHY:
|
|
5
|
+
* `URLSearchParams.get('page')` is always a string. Coercing it
|
|
6
|
+
* inside every component (`Number(...) || 1`) is repetitive and
|
|
7
|
+
* error-prone. A parser bakes in `parse(string) → T | null` and
|
|
8
|
+
* `serialize(T) → string`, plus optional equality (so we can clear
|
|
9
|
+
* default values from the URL — known as `clearOnDefault`).
|
|
10
|
+
*
|
|
11
|
+
* Parsers are zero-runtime objects — pure functions in plain
|
|
12
|
+
* structures. No deps, no validation libraries. If a consumer wants
|
|
13
|
+
* zod / standard-schema, they wrap `parseAsJson` themselves.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* import { useQueryState, parseAsInteger, parseAsString } from '@djangocfg/ui-core/hooks';
|
|
17
|
+
* const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
|
|
18
|
+
* const [q, setQ] = useQueryState('q', parseAsString);
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Convert a URL string value into a typed value, or `null` on parse failure. */
|
|
22
|
+
export interface QueryParser<T> {
|
|
23
|
+
parse: (value: string) => T | null;
|
|
24
|
+
serialize: (value: T) => string;
|
|
25
|
+
/** Equality check, used by `clearOnDefault` to strip default values from the URL. */
|
|
26
|
+
eq: (a: T, b: T) => boolean;
|
|
27
|
+
/** Optional default. When set, missing key returns it instead of `null`. */
|
|
28
|
+
defaultValue?: T;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Builder — adds `withDefault` chain on top of a parser. */
|
|
32
|
+
export interface QueryParserBuilder<T> extends QueryParser<T> {
|
|
33
|
+
withDefault(value: T): QueryParser<T> & { defaultValue: T };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function builder<T>(parser: Omit<QueryParser<T>, 'eq'> & { eq?: QueryParser<T>['eq'] }): QueryParserBuilder<T> {
|
|
37
|
+
const eq = parser.eq ?? ((a, b) => a === b);
|
|
38
|
+
const base: QueryParser<T> = { ...parser, eq };
|
|
39
|
+
return {
|
|
40
|
+
...base,
|
|
41
|
+
withDefault(defaultValue: T) {
|
|
42
|
+
return { ...base, defaultValue };
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ────────────────────────────────────────────────────────────────────
|
|
48
|
+
// Built-in parsers
|
|
49
|
+
// ────────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
export const parseAsString: QueryParserBuilder<string> = builder({
|
|
52
|
+
parse: (v) => v,
|
|
53
|
+
serialize: (v) => v,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export const parseAsInteger: QueryParserBuilder<number> = builder({
|
|
57
|
+
parse: (v) => {
|
|
58
|
+
const n = Number.parseInt(v, 10);
|
|
59
|
+
return Number.isFinite(n) ? n : null;
|
|
60
|
+
},
|
|
61
|
+
serialize: (v) => String(v),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export const parseAsFloat: QueryParserBuilder<number> = builder({
|
|
65
|
+
parse: (v) => {
|
|
66
|
+
const n = Number.parseFloat(v);
|
|
67
|
+
return Number.isFinite(n) ? n : null;
|
|
68
|
+
},
|
|
69
|
+
serialize: (v) => String(v),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
export const parseAsBoolean: QueryParserBuilder<boolean> = builder({
|
|
73
|
+
// `?debug` (no value) → true, `?debug=true` → true, `?debug=false` → false.
|
|
74
|
+
parse: (v) => v === '' || v === 'true' || v === '1',
|
|
75
|
+
serialize: (v) => (v ? 'true' : 'false'),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
export const parseAsIsoDate: QueryParserBuilder<Date> = builder({
|
|
79
|
+
parse: (v) => {
|
|
80
|
+
const ms = Date.parse(v);
|
|
81
|
+
return Number.isFinite(ms) ? new Date(ms) : null;
|
|
82
|
+
},
|
|
83
|
+
serialize: (v) => v.toISOString(),
|
|
84
|
+
eq: (a, b) => a.getTime() === b.getTime(),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* `parseAsStringEnum(['asc','desc'])` — restricts values to a fixed list.
|
|
89
|
+
* Returns `null` on anything outside the set, so `withDefault` falls back.
|
|
90
|
+
*/
|
|
91
|
+
export function parseAsStringEnum<T extends string>(
|
|
92
|
+
values: readonly T[]
|
|
93
|
+
): QueryParserBuilder<T> {
|
|
94
|
+
const set = new Set<string>(values);
|
|
95
|
+
return builder({
|
|
96
|
+
parse: (v) => (set.has(v) ? (v as T) : null),
|
|
97
|
+
serialize: (v) => v,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* `parseAsArrayOf(parseAsInteger)` — comma-separated list of typed values.
|
|
103
|
+
* Empty array serializes to empty string (which `useQueryState` strips).
|
|
104
|
+
*/
|
|
105
|
+
export function parseAsArrayOf<T>(
|
|
106
|
+
itemParser: QueryParser<T>,
|
|
107
|
+
separator: string = ','
|
|
108
|
+
): QueryParserBuilder<T[]> {
|
|
109
|
+
return builder({
|
|
110
|
+
parse: (v) => {
|
|
111
|
+
if (v === '') return [];
|
|
112
|
+
const parts = v.split(separator);
|
|
113
|
+
const out: T[] = [];
|
|
114
|
+
for (const part of parts) {
|
|
115
|
+
const parsed = itemParser.parse(part);
|
|
116
|
+
if (parsed === null) return null;
|
|
117
|
+
out.push(parsed);
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
},
|
|
121
|
+
serialize: (arr) => arr.map(itemParser.serialize).join(separator),
|
|
122
|
+
eq: (a, b) => {
|
|
123
|
+
if (a.length !== b.length) return false;
|
|
124
|
+
for (let i = 0; i < a.length; i++) {
|
|
125
|
+
if (!itemParser.eq(a[i] as T, b[i] as T)) return false;
|
|
126
|
+
}
|
|
127
|
+
return true;
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* `parseAsJson<{ a: number }>()` — JSON-encode complex shapes.
|
|
134
|
+
* Pass a guard for runtime validation; without one any JSON-parsable
|
|
135
|
+
* value passes (TypeScript-only safety).
|
|
136
|
+
*/
|
|
137
|
+
export function parseAsJson<T>(
|
|
138
|
+
guard?: (value: unknown) => value is T
|
|
139
|
+
): QueryParserBuilder<T> {
|
|
140
|
+
return builder({
|
|
141
|
+
parse: (v) => {
|
|
142
|
+
try {
|
|
143
|
+
const parsed = JSON.parse(v) as unknown;
|
|
144
|
+
if (guard && !guard(parsed)) return null;
|
|
145
|
+
return parsed as T;
|
|
146
|
+
} catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
serialize: (value) => JSON.stringify(value),
|
|
151
|
+
// Identity by JSON shape — fine for small objects, expensive for big ones.
|
|
152
|
+
eq: (a, b) => JSON.stringify(a) === JSON.stringify(b),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useBackOrFallback — smart "go back" that doesn't escape the app.
|
|
5
|
+
*
|
|
6
|
+
* WHY:
|
|
7
|
+
* `history.back()` on the first entry of a tab takes the user out of
|
|
8
|
+
* the app (or does nothing in some browsers). For in-app Back buttons
|
|
9
|
+
* we want: go back if there's in-app history, otherwise navigate to
|
|
10
|
+
* a sensible fallback.
|
|
11
|
+
*
|
|
12
|
+
* We track app-internal entries by stamping a monotonically growing
|
|
13
|
+
* serial into `history.state` on every navigation. On click we compare
|
|
14
|
+
* the current serial to the entry serial — if current > 0, there's
|
|
15
|
+
* an earlier app entry to go back to. This is bullet-proof against
|
|
16
|
+
* forward/back jitter (unlike a depth counter that can drift) and
|
|
17
|
+
* against `replace`-style navigations (they preserve the serial).
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* const { back } = useBackOrFallback();
|
|
21
|
+
* <button onClick={() => back('/dashboard')}>Back</button>
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { useCallback, useEffect, useMemo } from 'react';
|
|
25
|
+
import { useNavigate } from './useNavigate';
|
|
26
|
+
import { NAVIGATE_EVENT } from './useLocation';
|
|
27
|
+
|
|
28
|
+
const SERIAL_KEY = '__djcSerial';
|
|
29
|
+
const COUNTER_KEY = '__djc_router_serial';
|
|
30
|
+
|
|
31
|
+
interface SerialState {
|
|
32
|
+
[SERIAL_KEY]?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readCounter(): number {
|
|
36
|
+
if (typeof window === 'undefined') return 0;
|
|
37
|
+
try {
|
|
38
|
+
const raw = window.sessionStorage.getItem(COUNTER_KEY);
|
|
39
|
+
if (!raw) return 0;
|
|
40
|
+
const parsed = Number.parseInt(raw, 10);
|
|
41
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
|
|
42
|
+
} catch {
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function writeCounter(value: number): void {
|
|
48
|
+
if (typeof window === 'undefined') return;
|
|
49
|
+
try {
|
|
50
|
+
window.sessionStorage.setItem(COUNTER_KEY, String(Math.max(0, value)));
|
|
51
|
+
} catch {
|
|
52
|
+
// sessionStorage can throw in privacy modes — degrade silently.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readEntrySerial(): number {
|
|
57
|
+
if (typeof window === 'undefined') return 0;
|
|
58
|
+
const state = window.history.state as SerialState | null;
|
|
59
|
+
return typeof state?.[SERIAL_KEY] === 'number' ? state[SERIAL_KEY] : 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function stampEntrySerial(value: number): void {
|
|
63
|
+
if (typeof window === 'undefined') return;
|
|
64
|
+
const current = (window.history.state ?? {}) as SerialState;
|
|
65
|
+
// Don't double-stamp the same entry — `djc:navigate` can fire twice
|
|
66
|
+
// if a custom adapter wraps push too.
|
|
67
|
+
if (current[SERIAL_KEY] === value) return;
|
|
68
|
+
try {
|
|
69
|
+
window.history.replaceState(
|
|
70
|
+
{ ...current, [SERIAL_KEY]: value },
|
|
71
|
+
'',
|
|
72
|
+
window.location.href
|
|
73
|
+
);
|
|
74
|
+
} catch {
|
|
75
|
+
// Some sandboxes disallow replaceState — skip.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Module-level guard: a single listener stamps every new app entry.
|
|
80
|
+
let serialListenerAttached = false;
|
|
81
|
+
|
|
82
|
+
function attachSerialListener(): void {
|
|
83
|
+
if (serialListenerAttached) return;
|
|
84
|
+
if (typeof window === 'undefined') return;
|
|
85
|
+
serialListenerAttached = true;
|
|
86
|
+
|
|
87
|
+
const onNavigate = () => {
|
|
88
|
+
// If the current entry already has a serial (e.g. `replace`), keep it.
|
|
89
|
+
// Otherwise stamp a fresh one and bump the counter.
|
|
90
|
+
if (readEntrySerial() > 0) return;
|
|
91
|
+
const next = readCounter() + 1;
|
|
92
|
+
writeCounter(next);
|
|
93
|
+
stampEntrySerial(next);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Stamp the very first entry so popstate-only flows still work.
|
|
97
|
+
if (readEntrySerial() === 0) {
|
|
98
|
+
const next = readCounter() + 1;
|
|
99
|
+
writeCounter(next);
|
|
100
|
+
stampEntrySerial(next);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
window.addEventListener(NAVIGATE_EVENT, onNavigate);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface UseBackOrFallbackReturn {
|
|
107
|
+
/**
|
|
108
|
+
* Go back if we have in-app history, otherwise navigate to `fallback`.
|
|
109
|
+
* @param fallback - Where to go when there's no app history. Defaults to `/`.
|
|
110
|
+
*/
|
|
111
|
+
back: (fallback?: string) => void;
|
|
112
|
+
/** True when there's an earlier app entry to go back to. */
|
|
113
|
+
canGoBack: boolean;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Smart "back" that falls back to a route when the user landed
|
|
118
|
+
* directly on the current page (no in-app history).
|
|
119
|
+
*/
|
|
120
|
+
export function useBackOrFallback(): UseBackOrFallbackReturn {
|
|
121
|
+
const { back: adapterBack, navigate } = useNavigate();
|
|
122
|
+
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
attachSerialListener();
|
|
125
|
+
}, []);
|
|
126
|
+
|
|
127
|
+
const back = useCallback(
|
|
128
|
+
(fallback: string = '/') => {
|
|
129
|
+
// Serial > 1 means at least one earlier app entry exists.
|
|
130
|
+
if (readEntrySerial() > 1) {
|
|
131
|
+
adapterBack();
|
|
132
|
+
} else {
|
|
133
|
+
navigate(fallback, { replace: true });
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
[adapterBack, navigate]
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// Snapshot once per render — back/forward changes the serial on the
|
|
140
|
+
// entry we're sitting on, so we'll get a fresh value next render
|
|
141
|
+
// anyway via React's normal re-render cycle.
|
|
142
|
+
const canGoBack = readEntrySerial() > 1;
|
|
143
|
+
|
|
144
|
+
return useMemo(() => ({ back, canGoBack }), [back, canGoBack]);
|
|
145
|
+
}
|