@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
|
@@ -4,7 +4,7 @@ import { Check, ChevronsUpDown } from 'lucide-react';
|
|
|
4
4
|
import * as React from 'react';
|
|
5
5
|
|
|
6
6
|
import { useAppT } from '@djangocfg/i18n';
|
|
7
|
-
import { useStoredValue, type StorageType, type UseStoredValueOptions } from '../../hooks
|
|
7
|
+
import { useStoredValue, type StorageType, type UseStoredValueOptions } from '../../hooks';
|
|
8
8
|
import { cn } from '../../lib/utils';
|
|
9
9
|
import { Badge } from '../data/badge';
|
|
10
10
|
import { Button } from '../forms/button';
|
|
@@ -4,7 +4,7 @@ import { Check, ChevronsUpDown, X } from 'lucide-react';
|
|
|
4
4
|
import * as React from 'react';
|
|
5
5
|
|
|
6
6
|
import { useAppT } from '@djangocfg/i18n';
|
|
7
|
-
import { useStoredValue, type StorageType, type UseStoredValueOptions } from '../../hooks
|
|
7
|
+
import { useStoredValue, type StorageType, type UseStoredValueOptions } from '../../hooks';
|
|
8
8
|
import { cn } from '../../lib/utils';
|
|
9
9
|
import { Badge } from '../data/badge';
|
|
10
10
|
import { Button } from '../forms/button';
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { Car, ImageIcon, MapPin, Package, User } from 'lucide-react';
|
|
8
8
|
import React, { forwardRef } from 'react';
|
|
9
9
|
|
|
10
|
-
import { useImageLoader } from '../../../hooks
|
|
10
|
+
import { useImageLoader } from '../../../hooks';
|
|
11
11
|
import { cn } from '../../../lib/utils';
|
|
12
12
|
|
|
13
13
|
export interface ImageWithFallbackProps extends Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'onLoad' | 'onError'> {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
export { useBrowserDetect } from './useBrowserDetect';
|
|
4
|
+
export type { BrowserInfo } from './useBrowserDetect';
|
|
5
|
+
export { useDeviceDetect } from './useDeviceDetect';
|
|
6
|
+
export type { DeviceDetectResult } from './useDeviceDetect';
|
|
7
|
+
export { useShortcutModLabel } from './useShortcutModLabel';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
export { useBodyScrollLock } from './useBodyScrollLock';
|
|
4
|
+
export { useCopy } from './useCopy';
|
|
5
|
+
export { useImageLoader } from './useImageLoader';
|
|
6
|
+
export {
|
|
7
|
+
useScroll,
|
|
8
|
+
useScrollPosition,
|
|
9
|
+
useScrollDirection,
|
|
10
|
+
useIsScrolling,
|
|
11
|
+
} from './useScroll';
|
|
12
|
+
export type { ScrollSnapshot, ScrollDirection, ScrollTarget } from './useScroll';
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useScroll — reactive snapshot of `scrollX` / `scrollY` for window or any
|
|
5
|
+
* scrollable element.
|
|
6
|
+
*
|
|
7
|
+
* WHY:
|
|
8
|
+
* The DOM exposes scroll position via mutating `Element.scrollTop` /
|
|
9
|
+
* `Element.scrollLeft`, with the only signal being a flood of `scroll`
|
|
10
|
+
* events. To bridge this to React safely we:
|
|
11
|
+
*
|
|
12
|
+
* 1. **`useSyncExternalStore`** — the React-19-blessed primitive for
|
|
13
|
+
* external mutable sources. Solves SSR (`getServerSnapshot` returns
|
|
14
|
+
* zeros) and concurrent-mode tearing for free. Existing libraries
|
|
15
|
+
* (`react-use`, `usehooks-ts`, `@uidotdev/usehooks`) all predate
|
|
16
|
+
* this API and use plain `useState` + `useEffect`, which costs
|
|
17
|
+
* them re-renders on every frame at rest if their equality bailout
|
|
18
|
+
* is wrong (see react-use issue #1473).
|
|
19
|
+
*
|
|
20
|
+
* 2. **rAF-throttled writes, not subscribes** — scroll fires up to
|
|
21
|
+
* ~120 events/s on a Magic Trackpad. Reading `scrollX` is cheap
|
|
22
|
+
* but waking React isn't. We coalesce to one snapshot per frame
|
|
23
|
+
* per target, then notify subscribers once.
|
|
24
|
+
*
|
|
25
|
+
* 3. **Module-level store keyed by EventTarget** — many components
|
|
26
|
+
* on a page subscribe to the same `window` scroll. Without a
|
|
27
|
+
* shared store you'd attach N listeners and run rAF N times per
|
|
28
|
+
* frame. The store deduplicates: one listener + one rAF per
|
|
29
|
+
* unique target, regardless of subscriber count.
|
|
30
|
+
*
|
|
31
|
+
* 4. **`{ passive: true }`** — non-passive scroll listeners block
|
|
32
|
+
* compositor scrolling on mobile (Chrome warns about this in
|
|
33
|
+
* DevTools). Always opt in.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* const { y, direction, isScrolling } = useScroll();
|
|
37
|
+
* <header className={direction === 'down' && isScrolling ? 'hidden' : ''} />
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* // Scroll inside a panel:
|
|
41
|
+
* const ref = useRef<HTMLDivElement>(null);
|
|
42
|
+
* const { y } = useScrollPosition(ref);
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* // Subscribe to ONE field — re-renders only when direction flips,
|
|
46
|
+
* // not on every pixel of scroll:
|
|
47
|
+
* const direction = useScrollDirection();
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { useCallback, useSyncExternalStore, type RefObject } from 'react';
|
|
51
|
+
|
|
52
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
53
|
+
// Types
|
|
54
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
export type ScrollDirection = 'up' | 'down' | 'left' | 'right' | null;
|
|
57
|
+
|
|
58
|
+
/** Frozen snapshot returned to React. Identity changes only when contents change. */
|
|
59
|
+
export interface ScrollSnapshot {
|
|
60
|
+
/** Horizontal scroll offset in CSS pixels. */
|
|
61
|
+
x: number;
|
|
62
|
+
/** Vertical scroll offset in CSS pixels. */
|
|
63
|
+
y: number;
|
|
64
|
+
/**
|
|
65
|
+
* Direction of the LAST delta. Resets to `null` after `isScrolling`
|
|
66
|
+
* falls to `false`. For cumulative direction (e.g. hide-on-scroll
|
|
67
|
+
* navbar), build on top of this.
|
|
68
|
+
*/
|
|
69
|
+
direction: ScrollDirection;
|
|
70
|
+
/** True until 150 ms have passed since the last scroll event. */
|
|
71
|
+
isScrolling: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type ScrollTarget = RefObject<Element | null> | Window;
|
|
75
|
+
|
|
76
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
77
|
+
// SSR snapshot
|
|
78
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
const SSR_SNAPSHOT: ScrollSnapshot = Object.freeze({
|
|
81
|
+
x: 0,
|
|
82
|
+
y: 0,
|
|
83
|
+
direction: null,
|
|
84
|
+
isScrolling: false,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
88
|
+
// Per-target store
|
|
89
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
interface TargetState {
|
|
92
|
+
snapshot: ScrollSnapshot;
|
|
93
|
+
subscribers: Set<() => void>;
|
|
94
|
+
rafId: number | null;
|
|
95
|
+
idleTimer: ReturnType<typeof setTimeout> | null;
|
|
96
|
+
removeListener: () => void;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* One entry per unique scroll source on the page (typically just `window`,
|
|
101
|
+
* plus any scrollable panels). Lives at module scope so every consumer
|
|
102
|
+
* shares the same listener + rAF.
|
|
103
|
+
*/
|
|
104
|
+
const stores = new WeakMap<EventTarget, TargetState>();
|
|
105
|
+
|
|
106
|
+
const IDLE_MS = 150;
|
|
107
|
+
|
|
108
|
+
/** Reads the current scroll offsets from a target. */
|
|
109
|
+
function readScroll(target: EventTarget): { x: number; y: number } {
|
|
110
|
+
if (target === window) {
|
|
111
|
+
return { x: window.scrollX, y: window.scrollY };
|
|
112
|
+
}
|
|
113
|
+
const el = target as Element;
|
|
114
|
+
return { x: el.scrollLeft, y: el.scrollTop };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function deriveDirection(
|
|
118
|
+
prev: { x: number; y: number },
|
|
119
|
+
next: { x: number; y: number }
|
|
120
|
+
): ScrollDirection {
|
|
121
|
+
const dx = next.x - prev.x;
|
|
122
|
+
const dy = next.y - prev.y;
|
|
123
|
+
// Vertical wins on ties — most pages scroll vertically.
|
|
124
|
+
if (Math.abs(dy) >= Math.abs(dx)) {
|
|
125
|
+
if (dy > 0) return 'down';
|
|
126
|
+
if (dy < 0) return 'up';
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
if (dx > 0) return 'right';
|
|
130
|
+
if (dx < 0) return 'left';
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function getOrCreateStore(target: EventTarget): TargetState {
|
|
135
|
+
const existing = stores.get(target);
|
|
136
|
+
if (existing) return existing;
|
|
137
|
+
|
|
138
|
+
// Initial read so the first snapshot is correct (don't wait for first event).
|
|
139
|
+
const initial = readScroll(target);
|
|
140
|
+
|
|
141
|
+
const state: TargetState = {
|
|
142
|
+
snapshot: Object.freeze({
|
|
143
|
+
x: initial.x,
|
|
144
|
+
y: initial.y,
|
|
145
|
+
direction: null,
|
|
146
|
+
isScrolling: false,
|
|
147
|
+
}),
|
|
148
|
+
subscribers: new Set(),
|
|
149
|
+
rafId: null,
|
|
150
|
+
idleTimer: null,
|
|
151
|
+
removeListener: () => {},
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const onScroll = () => {
|
|
155
|
+
// Coalesce bursts of scroll events to one rAF tick. Reading scrollX
|
|
156
|
+
// mid-event is cheap, but notifying React isn't.
|
|
157
|
+
if (state.rafId !== null) return;
|
|
158
|
+
state.rafId = requestAnimationFrame(() => {
|
|
159
|
+
state.rafId = null;
|
|
160
|
+
const next = readScroll(target);
|
|
161
|
+
const direction = deriveDirection(state.snapshot, next);
|
|
162
|
+
// Equality bailout: if nothing changed AND we're already marked as
|
|
163
|
+
// scrolling, skip the snapshot mint and the React notification.
|
|
164
|
+
// This catches duplicate scroll events on the same frame.
|
|
165
|
+
if (
|
|
166
|
+
next.x === state.snapshot.x &&
|
|
167
|
+
next.y === state.snapshot.y &&
|
|
168
|
+
state.snapshot.isScrolling
|
|
169
|
+
) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
state.snapshot = Object.freeze({
|
|
173
|
+
x: next.x,
|
|
174
|
+
y: next.y,
|
|
175
|
+
direction,
|
|
176
|
+
isScrolling: true,
|
|
177
|
+
});
|
|
178
|
+
// Reset idle timer — fires once user pauses for IDLE_MS.
|
|
179
|
+
if (state.idleTimer !== null) clearTimeout(state.idleTimer);
|
|
180
|
+
state.idleTimer = setTimeout(() => {
|
|
181
|
+
state.idleTimer = null;
|
|
182
|
+
state.snapshot = Object.freeze({
|
|
183
|
+
x: state.snapshot.x,
|
|
184
|
+
y: state.snapshot.y,
|
|
185
|
+
// Direction also resets — fresh scroll restarts the signal.
|
|
186
|
+
direction: null,
|
|
187
|
+
isScrolling: false,
|
|
188
|
+
});
|
|
189
|
+
for (const cb of state.subscribers) cb();
|
|
190
|
+
}, IDLE_MS);
|
|
191
|
+
for (const cb of state.subscribers) cb();
|
|
192
|
+
});
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
target.addEventListener('scroll', onScroll, { passive: true, capture: false });
|
|
196
|
+
state.removeListener = () => {
|
|
197
|
+
target.removeEventListener('scroll', onScroll, { capture: false });
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
stores.set(target, state);
|
|
201
|
+
return state;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function teardownStore(target: EventTarget): void {
|
|
205
|
+
const state = stores.get(target);
|
|
206
|
+
if (!state || state.subscribers.size > 0) return;
|
|
207
|
+
state.removeListener();
|
|
208
|
+
if (state.rafId !== null) cancelAnimationFrame(state.rafId);
|
|
209
|
+
if (state.idleTimer !== null) clearTimeout(state.idleTimer);
|
|
210
|
+
stores.delete(target);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
214
|
+
// Subscribe / getSnapshot factories
|
|
215
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Resolves a `RefObject | Window | undefined` to an actual EventTarget at
|
|
219
|
+
* call time. Must be called inside subscribe/getSnapshot — if the ref's
|
|
220
|
+
* `.current` flips between renders, we want to follow it.
|
|
221
|
+
*/
|
|
222
|
+
function resolveTarget(target: ScrollTarget | undefined): EventTarget | null {
|
|
223
|
+
if (typeof window === 'undefined') return null;
|
|
224
|
+
if (!target || target === window) return window;
|
|
225
|
+
// RefObject — read current at call site, may be null before mount.
|
|
226
|
+
return (target as RefObject<Element | null>).current ?? null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function subscribeFactory(target: ScrollTarget | undefined) {
|
|
230
|
+
return (onChange: () => void): (() => void) => {
|
|
231
|
+
const t = resolveTarget(target);
|
|
232
|
+
if (!t) return () => {};
|
|
233
|
+
const state = getOrCreateStore(t);
|
|
234
|
+
state.subscribers.add(onChange);
|
|
235
|
+
return () => {
|
|
236
|
+
state.subscribers.delete(onChange);
|
|
237
|
+
teardownStore(t);
|
|
238
|
+
};
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function getSnapshotFactory(target: ScrollTarget | undefined) {
|
|
243
|
+
return (): ScrollSnapshot => {
|
|
244
|
+
const t = resolveTarget(target);
|
|
245
|
+
if (!t) return SSR_SNAPSHOT;
|
|
246
|
+
const state = stores.get(t);
|
|
247
|
+
return state ? state.snapshot : SSR_SNAPSHOT;
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const getServerSnapshot = (): ScrollSnapshot => SSR_SNAPSHOT;
|
|
252
|
+
|
|
253
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
254
|
+
// Public hooks
|
|
255
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Reactive snapshot of scroll position + direction + isScrolling for the
|
|
259
|
+
* given target (default: `window`). Returns a stable object reference until
|
|
260
|
+
* something actually changes — safe to put in deps arrays.
|
|
261
|
+
*
|
|
262
|
+
* If you only need ONE field, prefer `useScrollPosition`,
|
|
263
|
+
* `useScrollDirection`, or `useIsScrolling` — they subscribe to the same
|
|
264
|
+
* underlying store but let React skip re-renders when other fields change.
|
|
265
|
+
*/
|
|
266
|
+
export function useScroll(target?: ScrollTarget): ScrollSnapshot {
|
|
267
|
+
const subscribe = useCallback(subscribeFactory(target), [target]);
|
|
268
|
+
const getSnapshot = useCallback(getSnapshotFactory(target), [target]);
|
|
269
|
+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Subscribe to just `{x, y}`. Re-renders only when the scroll offsets
|
|
274
|
+
* change — direction flips and isScrolling transitions are ignored.
|
|
275
|
+
*/
|
|
276
|
+
export function useScrollPosition(target?: ScrollTarget): { x: number; y: number } {
|
|
277
|
+
const subscribe = useCallback(subscribeFactory(target), [target]);
|
|
278
|
+
const getServerXY = useCallback(() => SSR_XY, []);
|
|
279
|
+
const getXY = useCallback(() => {
|
|
280
|
+
const snap = getSnapshotFactory(target)();
|
|
281
|
+
// Cache by snapshot identity — every snapshot is frozen, so identity
|
|
282
|
+
// change ⇔ content change. Avoids minting a new {x,y} on every read.
|
|
283
|
+
return positionCache.get(snap) ?? cachePosition(snap);
|
|
284
|
+
}, [target]);
|
|
285
|
+
return useSyncExternalStore(subscribe, getXY, getServerXY);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const SSR_XY = Object.freeze({ x: 0, y: 0 });
|
|
289
|
+
const positionCache = new WeakMap<ScrollSnapshot, { x: number; y: number }>();
|
|
290
|
+
function cachePosition(snap: ScrollSnapshot): { x: number; y: number } {
|
|
291
|
+
const xy = Object.freeze({ x: snap.x, y: snap.y });
|
|
292
|
+
positionCache.set(snap, xy);
|
|
293
|
+
return xy;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Subscribe to just the direction signal. Re-renders only when direction
|
|
298
|
+
* flips (e.g. user reverses scroll).
|
|
299
|
+
*/
|
|
300
|
+
export function useScrollDirection(target?: ScrollTarget): ScrollDirection {
|
|
301
|
+
const subscribe = useCallback(subscribeFactory(target), [target]);
|
|
302
|
+
const getDirection = useCallback(
|
|
303
|
+
() => getSnapshotFactory(target)().direction,
|
|
304
|
+
[target]
|
|
305
|
+
);
|
|
306
|
+
const getServerDirection = useCallback((): ScrollDirection => null, []);
|
|
307
|
+
return useSyncExternalStore(subscribe, getDirection, getServerDirection);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* `true` while the user is actively scrolling, `false` after a 150 ms
|
|
312
|
+
* idle gap. Useful for hiding hover overlays while scrolling.
|
|
313
|
+
*/
|
|
314
|
+
export function useIsScrolling(target?: ScrollTarget): boolean {
|
|
315
|
+
const subscribe = useCallback(subscribeFactory(target), [target]);
|
|
316
|
+
const getIsScrolling = useCallback(
|
|
317
|
+
() => getSnapshotFactory(target)().isScrolling,
|
|
318
|
+
[target]
|
|
319
|
+
);
|
|
320
|
+
const getServerIsScrolling = useCallback(() => false, []);
|
|
321
|
+
return useSyncExternalStore(subscribe, getIsScrolling, getServerIsScrolling);
|
|
322
|
+
}
|
package/src/hooks/index.ts
CHANGED
|
@@ -1,33 +1,89 @@
|
|
|
1
1
|
// ============================================================================
|
|
2
2
|
// Hooks - Reusable React Hooks (No Next.js/Browser Storage dependencies)
|
|
3
3
|
// For use in Electron, Vite, CRA apps
|
|
4
|
+
//
|
|
5
|
+
// Hooks are grouped by domain (see ./<group>/index.ts). This top-level barrel
|
|
6
|
+
// re-exports everything for convenience: `from '@djangocfg/ui-core/hooks'`.
|
|
7
|
+
// For tree-shaking or clarity, import the group directly:
|
|
8
|
+
// `from '@djangocfg/ui-core/hooks/dom'`, etc.
|
|
4
9
|
// ============================================================================
|
|
5
10
|
|
|
6
11
|
'use client';
|
|
7
12
|
|
|
8
|
-
export
|
|
9
|
-
export
|
|
10
|
-
export
|
|
11
|
-
export
|
|
12
|
-
export
|
|
13
|
-
export
|
|
14
|
-
export
|
|
15
|
-
export
|
|
16
|
-
export
|
|
17
|
-
export
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
export {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
13
|
+
export * from './dom';
|
|
14
|
+
export * from './state';
|
|
15
|
+
export * from './media';
|
|
16
|
+
export * from './device';
|
|
17
|
+
export * from './feedback';
|
|
18
|
+
export * from './theme';
|
|
19
|
+
export * from './time';
|
|
20
|
+
export * from './events';
|
|
21
|
+
export * from './hotkey';
|
|
22
|
+
export * from './debug';
|
|
23
|
+
|
|
24
|
+
// ----------------------------------------------------------------------------
|
|
25
|
+
// Router — framework-agnostic navigation primitives.
|
|
26
|
+
// See ./router/README.md for design notes and the Next.js adapter example.
|
|
27
|
+
// ----------------------------------------------------------------------------
|
|
28
|
+
export {
|
|
29
|
+
// Adapter
|
|
30
|
+
RouterAdapterContext,
|
|
31
|
+
RouterAdapterProvider,
|
|
32
|
+
defaultAdapter,
|
|
33
|
+
useRouterAdapter,
|
|
34
|
+
// useLocation
|
|
35
|
+
useLocation,
|
|
36
|
+
NAVIGATE_EVENT,
|
|
37
|
+
// useNavigate
|
|
38
|
+
useNavigate,
|
|
39
|
+
// useQueryParams
|
|
40
|
+
useQueryParams,
|
|
41
|
+
// useBackOrFallback
|
|
42
|
+
useBackOrFallback,
|
|
43
|
+
// useUrlBuilder
|
|
44
|
+
useUrlBuilder,
|
|
45
|
+
buildUrl,
|
|
46
|
+
buildQueryString,
|
|
47
|
+
// useSmartLink
|
|
48
|
+
useSmartLink,
|
|
49
|
+
// useIsActive
|
|
50
|
+
useIsActive,
|
|
51
|
+
// useQueryState (typed single-key URL state)
|
|
52
|
+
useQueryState,
|
|
53
|
+
// Parsers
|
|
54
|
+
parseAsString,
|
|
55
|
+
parseAsInteger,
|
|
56
|
+
parseAsFloat,
|
|
57
|
+
parseAsBoolean,
|
|
58
|
+
parseAsIsoDate,
|
|
59
|
+
parseAsStringEnum,
|
|
60
|
+
parseAsArrayOf,
|
|
61
|
+
parseAsJson,
|
|
62
|
+
// useRouter (composite facade)
|
|
63
|
+
useRouter,
|
|
64
|
+
} from './router';
|
|
65
|
+
export type {
|
|
66
|
+
RouterAdapter,
|
|
67
|
+
RouterAdapterProviderProps,
|
|
68
|
+
RouterLocation,
|
|
69
|
+
LocationSnapshot,
|
|
70
|
+
NavigateOptions,
|
|
71
|
+
UseNavigateReturn,
|
|
72
|
+
QueryParamsSnapshot,
|
|
73
|
+
QueryParamValue,
|
|
74
|
+
QueryParamUpdates,
|
|
75
|
+
SetQueryParamsOptions,
|
|
76
|
+
UseQueryParamsReturn,
|
|
77
|
+
UseBackOrFallbackReturn,
|
|
78
|
+
QueryValue,
|
|
79
|
+
QueryParamsInput,
|
|
80
|
+
UseUrlBuilderReturn,
|
|
81
|
+
UseSmartLinkOptions,
|
|
82
|
+
SmartLinkHandlers,
|
|
83
|
+
UseIsActiveOptions,
|
|
84
|
+
UseQueryStateOptions,
|
|
85
|
+
QueryStateUpdater,
|
|
86
|
+
QueryParser,
|
|
87
|
+
QueryParserBuilder,
|
|
88
|
+
UseRouterReturn,
|
|
89
|
+
} from './router';
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Router hooks
|
|
2
|
+
|
|
3
|
+
Framework-agnostic navigation primitives for `@djangocfg/ui-core`. Built on plain browser APIs (`window.history`, `window.location`, `URLSearchParams`, `popstate`). No `next/*`, no `react-router`, no third-party deps.
|
|
4
|
+
|
|
5
|
+
## Surface
|
|
6
|
+
|
|
7
|
+
| Hook | Purpose |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| `useLocation` | Reactive snapshot of `window.location` (`pathname`, `search`, `hash`, `href`). |
|
|
10
|
+
| `useNavigate` | Programmatic navigation: `navigate`, `navigateExternal`, `push`, `replace`, `back`, `forward`. |
|
|
11
|
+
| `useQueryParams` | Read/write `?key=value` URL state with typed coercion (`get`, `getNumber`, `getBoolean`, `set`, `remove`, `clear`). |
|
|
12
|
+
| `useBackOrFallback` | Smart "back" that falls back to a route when there's no in-app history. |
|
|
13
|
+
| `useUrlBuilder` | Pure URL/querystring assembly: `build`, `withCurrentParams`. |
|
|
14
|
+
| `useSmartLink` | Click + keyboard handlers that turn any element into a proper link (cmd-click, middle-click, Enter, Space). |
|
|
15
|
+
| `useIsActive` | `boolean` for "current pathname matches this href" — for nav-item highlighting. |
|
|
16
|
+
| `useQueryState` | Typed `useState`-style hook bound to ONE URL key (with parsers + `clearOnDefault`). |
|
|
17
|
+
| `useLocationProperty` | Subscribe to ONE derived field of `window.location` (avoids re-renders on unrelated fields). |
|
|
18
|
+
| `useRouter` | Convenience facade composing the above. |
|
|
19
|
+
| `RouterAdapterProvider` | Swap the navigation backend (e.g. Next.js's router). |
|
|
20
|
+
| `parseAsString` / `parseAsInteger` / `parseAsFloat` / `parseAsBoolean` / `parseAsIsoDate` / `parseAsStringEnum` / `parseAsArrayOf` / `parseAsJson` | Parser builders for `useQueryState`. Each has `.withDefault(value)`. |
|
|
21
|
+
|
|
22
|
+
## Decomposition rationale
|
|
23
|
+
|
|
24
|
+
Each atomic hook subscribes to exactly what it needs. A component that only calls `navigate(...)` shouldn't re-render every time the querystring changes — `useNavigate` doesn't subscribe to location, so it doesn't. Same for `useUrlBuilder` (only re-renders on `search` change), `useQueryParams` (same), and so on.
|
|
25
|
+
|
|
26
|
+
`useRouter` exists for convenience and ergonomic familiarity. Use it when you want everything in one return; use the atomic hooks for fewer re-renders and better tree-shaking.
|
|
27
|
+
|
|
28
|
+
## Adapter pattern
|
|
29
|
+
|
|
30
|
+
Default behavior uses `window.history.pushState` + `window.location` and works in any browser (Wails / Electron / Vite / CRA — nothing to mount, it just works).
|
|
31
|
+
|
|
32
|
+
For Next.js — mount both adapters once near the root. They bridge router hooks to `next/navigation` and `<Link>` to `next/link` so server components, route loaders, and prefetch fire correctly:
|
|
33
|
+
|
|
34
|
+
```tsx
|
|
35
|
+
// app/[locale]/layout.tsx (or wherever your client provider stack lives)
|
|
36
|
+
import { NextRouterAdapter, NextLinkProvider } from '@djangocfg/ui-core/adapters/nextjs';
|
|
37
|
+
|
|
38
|
+
<I18nProvider locale={locale} messages={messages}>
|
|
39
|
+
<NextRouterAdapter>
|
|
40
|
+
<NextLinkProvider>
|
|
41
|
+
<AppLayout>{children}</AppLayout>
|
|
42
|
+
</NextLinkProvider>
|
|
43
|
+
</NextRouterAdapter>
|
|
44
|
+
</I18nProvider>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`next` is an **optional peer dependency** — the package never imports from `next/*` from the main entry. The Next adapter lives behind the `/adapters/nextjs` sub-path entry, so non-Next consumers don't pull `next` into their bundle.
|
|
48
|
+
|
|
49
|
+
For other routers (TanStack Router, wouter, Remix, custom transports) — write a ~20-line custom adapter:
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
'use client';
|
|
53
|
+
|
|
54
|
+
import { useMemo } from 'react';
|
|
55
|
+
import { RouterAdapterProvider, type RouterAdapter } from '@djangocfg/ui-core/hooks';
|
|
56
|
+
|
|
57
|
+
export function MyRouterAdapter({ children }: { children: React.ReactNode }) {
|
|
58
|
+
const myRouter = useMyRouter();
|
|
59
|
+
const adapter = useMemo<RouterAdapter>(() => ({
|
|
60
|
+
push: (url) => myRouter.push(url),
|
|
61
|
+
replace: (url) => myRouter.replace(url),
|
|
62
|
+
back: () => myRouter.back(),
|
|
63
|
+
forward: () => myRouter.forward(),
|
|
64
|
+
getLocation: () => ({
|
|
65
|
+
pathname: window.location.pathname,
|
|
66
|
+
search: window.location.search,
|
|
67
|
+
hash: window.location.hash,
|
|
68
|
+
}),
|
|
69
|
+
}), [myRouter]);
|
|
70
|
+
|
|
71
|
+
return <RouterAdapterProvider value={adapter}>{children}</RouterAdapterProvider>;
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**Adapter contract gotcha:** custom `push` / `replace` implementations should ultimately route through `window.history.pushState` (Next does, so does react-router). If yours doesn't, dispatch `'djc:navigate'` after each navigation manually so `useLocation` re-reads.
|
|
76
|
+
|
|
77
|
+
## `useQueryState` (typed URL state)
|
|
78
|
+
|
|
79
|
+
Bound to one query key with a typed parser. Inspired by `nuqs` but framework-agnostic via the same adapter context.
|
|
80
|
+
|
|
81
|
+
```tsx
|
|
82
|
+
import {
|
|
83
|
+
useQueryState,
|
|
84
|
+
parseAsInteger,
|
|
85
|
+
parseAsStringEnum,
|
|
86
|
+
} from '@djangocfg/ui-core/hooks';
|
|
87
|
+
|
|
88
|
+
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
|
|
89
|
+
const [tab, setTab] = useQueryState('tab', parseAsStringEnum(['list', 'grid']).withDefault('list'));
|
|
90
|
+
|
|
91
|
+
setPage((prev) => prev + 1); // functional updater, just like useState
|
|
92
|
+
setPage(null); // clears the key from the URL
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`clearOnDefault` (default `true`) drops the key from the URL when the value equals the parser default — keeps URLs short. Pass `{ clearOnDefault: false }` to keep explicit `?page=1` for shareable links.
|
|
96
|
+
|
|
97
|
+
Parsers: `parseAsString`, `parseAsInteger`, `parseAsFloat`, `parseAsBoolean`, `parseAsIsoDate`, `parseAsStringEnum([...])`, `parseAsArrayOf(item)`, `parseAsJson<T>()`. Each has `.withDefault(value)`. Build your own by satisfying the `QueryParser<T>` interface — it's three functions (`parse`, `serialize`, `eq`).
|
|
98
|
+
|
|
99
|
+
## How `useLocation` knows about `pushState`
|
|
100
|
+
|
|
101
|
+
Browsers don't fire `popstate` for programmatic `pushState` / `replaceState`. We monkey-patch both methods once (idempotent, module-level guard) on first mount and dispatch a custom `'djc:navigate'` event after each call. Anyone calling history APIs anywhere in the page will trigger an update — including the consumer's own router, third-party scripts, and our default adapter.
|
|
102
|
+
|
|
103
|
+
## SSR
|
|
104
|
+
|
|
105
|
+
- All hooks return safe defaults on the server (`pathname: '/'`, etc.).
|
|
106
|
+
- `useSyncExternalStore`'s `getServerSnapshot` is wired up correctly.
|
|
107
|
+
- Mutating methods (`push`, `replace`, `navigate`, `navigateExternal`) no-op when `window` is undefined.
|
|
108
|
+
- No hydration mismatches — first client render reads real `window.location` after mount via `useSyncExternalStore`'s `getSnapshot`.
|
|
109
|
+
|
|
110
|
+
## Trade-offs vs. `next/navigation`
|
|
111
|
+
|
|
112
|
+
| Concern | `next/navigation` | This library |
|
|
113
|
+
| --- | --- | --- |
|
|
114
|
+
| Server components fire | yes (built-in) | only if you mount the Next adapter |
|
|
115
|
+
| Pending state (`useTransition`) | bundled in | wrap calls yourself |
|
|
116
|
+
| Locale-prefix handling | yes | no — wrap if needed |
|
|
117
|
+
| Route matching / dynamic segments | yes | no — out of scope |
|
|
118
|
+
| Works outside Next | no | yes — anywhere React runs |
|
|
119
|
+
| `<Link>` component | yes | yes — `<Link>` / `<ButtonLink>` in `@djangocfg/ui-core/components` (Next-aware via `NextLinkProvider`) |
|
|
120
|
+
|
|
121
|
+
Out of scope: locale prefixes, route matching, dynamic segments, transitions. Add them in consumer code or in higher-level packages.
|