@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.
Files changed (73) hide show
  1. package/README.md +127 -26
  2. package/package.json +16 -4
  3. package/src/components/feedback/sonner/index.tsx +1 -1
  4. package/src/components/forms/button/index.tsx +21 -5
  5. package/src/components/forms/button-download/index.tsx +1 -1
  6. package/src/components/forms/input/index.tsx +1 -1
  7. package/src/components/forms/otp/index.tsx +1 -1
  8. package/src/components/forms/slider/index.tsx +1 -1
  9. package/src/components/forms/textarea/index.tsx +1 -1
  10. package/src/components/index.ts +2 -0
  11. package/src/components/layout/sticky/index.tsx +1 -1
  12. package/src/components/navigation/accordion/index.tsx +1 -1
  13. package/src/components/navigation/dropdown-menu/index.tsx +3 -2
  14. package/src/components/navigation/link/Link.tsx +124 -0
  15. package/src/components/navigation/link/LinkContext.tsx +52 -0
  16. package/src/components/navigation/link/index.ts +8 -0
  17. package/src/components/navigation/menubar/index.tsx +3 -2
  18. package/src/components/navigation/navigation-menu/index.tsx +2 -1
  19. package/src/components/navigation/tabs/index.tsx +1 -1
  20. package/src/components/overlay/responsive-sheet/index.tsx +1 -1
  21. package/src/components/select/combobox.tsx +1 -1
  22. package/src/components/select/multi-select.tsx +1 -1
  23. package/src/components/specialized/image-with-fallback/index.tsx +1 -1
  24. package/src/hooks/debug/index.ts +3 -0
  25. package/src/hooks/device/index.ts +7 -0
  26. package/src/hooks/dom/index.ts +12 -0
  27. package/src/hooks/{useBodyScrollLock.ts → dom/useBodyScrollLock.ts} +1 -1
  28. package/src/hooks/{useCopy.ts → dom/useCopy.ts} +1 -1
  29. package/src/hooks/dom/useScroll.ts +322 -0
  30. package/src/hooks/events/index.ts +3 -0
  31. package/src/hooks/feedback/index.ts +3 -0
  32. package/src/hooks/hotkey/index.ts +4 -0
  33. package/src/hooks/index.ts +82 -26
  34. package/src/hooks/media/index.ts +5 -0
  35. package/src/hooks/router/README.md +121 -0
  36. package/src/hooks/router/adapter.tsx +139 -0
  37. package/src/hooks/router/adapters/index.ts +5 -0
  38. package/src/hooks/router/adapters/nextjs.tsx +140 -0
  39. package/src/hooks/router/index.ts +90 -0
  40. package/src/hooks/router/parsers.ts +154 -0
  41. package/src/hooks/router/useBackOrFallback.ts +145 -0
  42. package/src/hooks/router/useIsActive.ts +60 -0
  43. package/src/hooks/router/useLocation.ts +163 -0
  44. package/src/hooks/router/useNavigate.ts +96 -0
  45. package/src/hooks/router/useQueryParams.ts +262 -0
  46. package/src/hooks/router/useQueryState.ts +106 -0
  47. package/src/hooks/router/useRouter.ts +81 -0
  48. package/src/hooks/router/useSmartLink.ts +157 -0
  49. package/src/hooks/router/useUrlBuilder.ts +118 -0
  50. package/src/hooks/state/index.ts +8 -0
  51. package/src/hooks/theme/index.ts +4 -0
  52. package/src/hooks/time/index.ts +4 -0
  53. package/src/lib/dialog-service/dialogs/AlertDialogUI.tsx +1 -1
  54. package/src/lib/dialog-service/dialogs/ConfirmDialogUI.tsx +1 -1
  55. package/src/lib/dialog-service/dialogs/PromptDialogUI.tsx +1 -1
  56. package/src/styles/palette/useThemePalette.ts +1 -1
  57. /package/src/hooks/{useDebugTools.ts → debug/useDebugTools.ts} +0 -0
  58. /package/src/hooks/{useBrowserDetect.ts → device/useBrowserDetect.ts} +0 -0
  59. /package/src/hooks/{useDeviceDetect.ts → device/useDeviceDetect.ts} +0 -0
  60. /package/src/hooks/{useShortcutModLabel.ts → device/useShortcutModLabel.ts} +0 -0
  61. /package/src/hooks/{useImageLoader.ts → dom/useImageLoader.ts} +0 -0
  62. /package/src/hooks/{useEventsBus.ts → events/useEventsBus.ts} +0 -0
  63. /package/src/hooks/{useToast.ts → feedback/useToast.ts} +0 -0
  64. /package/src/hooks/{useHotkey.ts → hotkey/useHotkey.ts} +0 -0
  65. /package/src/hooks/{useMediaQuery.ts → media/useMediaQuery.ts} +0 -0
  66. /package/src/hooks/{useMobile.tsx → media/useMobile.tsx} +0 -0
  67. /package/src/hooks/{useDebounce.ts → state/useDebounce.ts} +0 -0
  68. /package/src/hooks/{useDebouncedCallback.ts → state/useDebouncedCallback.ts} +0 -0
  69. /package/src/hooks/{useLocalStorage.ts → state/useLocalStorage.ts} +0 -0
  70. /package/src/hooks/{useSessionStorage.ts → state/useSessionStorage.ts} +0 -0
  71. /package/src/hooks/{useStoredValue.ts → state/useStoredValue.ts} +0 -0
  72. /package/src/hooks/{useResolvedTheme.ts → theme/useResolvedTheme.ts} +0 -0
  73. /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
+ }