@djangocfg/ui-core 2.1.294 → 2.1.298
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 +83 -29
- package/package.json +4 -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 +15 -26
- package/src/hooks/media/index.ts +5 -0
- package/src/hooks/router/README.md +7 -5
- package/src/hooks/router/adapters/nextjs.tsx +41 -1
- 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,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,36 +1,25 @@
|
|
|
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
|
-
export { useCopy } from './useCopy';
|
|
19
|
-
export { useImageLoader } from './useImageLoader';
|
|
20
|
-
export { useToast, toast } from './useToast';
|
|
21
|
-
export { useResolvedTheme } from './useResolvedTheme';
|
|
22
|
-
export type { ResolvedTheme } from './useResolvedTheme';
|
|
23
|
-
export { useLocalStorage } from './useLocalStorage';
|
|
24
|
-
export { useSessionStorage } from './useSessionStorage';
|
|
25
|
-
export { useStoredValue } from './useStoredValue';
|
|
26
|
-
export type { UseStoredValueOptions, StorageType } from './useStoredValue';
|
|
27
|
-
export { useHotkey, useHotkeysContext, HotkeysProvider, isHotkeyPressed } from './useHotkey';
|
|
28
|
-
export type { UseHotkeyOptions, HotkeyCallback, Keys, HotkeyRefType } from './useHotkey';
|
|
29
|
-
export { useShortcutModLabel } from './useShortcutModLabel';
|
|
30
|
-
export { useBrowserDetect } from './useBrowserDetect';
|
|
31
|
-
export type { BrowserInfo } from './useBrowserDetect';
|
|
32
|
-
export { useDeviceDetect } from './useDeviceDetect';
|
|
33
|
-
export type { DeviceDetectResult } from './useDeviceDetect';
|
|
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';
|
|
34
23
|
|
|
35
24
|
// ----------------------------------------------------------------------------
|
|
36
25
|
// Router — framework-agnostic navigation primitives.
|
|
@@ -29,15 +29,17 @@ Each atomic hook subscribes to exactly what it needs. A component that only call
|
|
|
29
29
|
|
|
30
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
31
|
|
|
32
|
-
For Next.js — mount
|
|
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
33
|
|
|
34
34
|
```tsx
|
|
35
35
|
// app/[locale]/layout.tsx (or wherever your client provider stack lives)
|
|
36
|
-
import { NextRouterAdapter } from '@djangocfg/ui-core/adapters/nextjs';
|
|
36
|
+
import { NextRouterAdapter, NextLinkProvider } from '@djangocfg/ui-core/adapters/nextjs';
|
|
37
37
|
|
|
38
38
|
<I18nProvider locale={locale} messages={messages}>
|
|
39
39
|
<NextRouterAdapter>
|
|
40
|
-
<
|
|
40
|
+
<NextLinkProvider>
|
|
41
|
+
<AppLayout>{children}</AppLayout>
|
|
42
|
+
</NextLinkProvider>
|
|
41
43
|
</NextRouterAdapter>
|
|
42
44
|
</I18nProvider>
|
|
43
45
|
```
|
|
@@ -114,6 +116,6 @@ Browsers don't fire `popstate` for programmatic `pushState` / `replaceState`. We
|
|
|
114
116
|
| Locale-prefix handling | yes | no — wrap if needed |
|
|
115
117
|
| Route matching / dynamic segments | yes | no — out of scope |
|
|
116
118
|
| Works outside Next | no | yes — anywhere React runs |
|
|
117
|
-
| `<Link>` component | yes |
|
|
119
|
+
| `<Link>` component | yes | yes — `<Link>` / `<ButtonLink>` in `@djangocfg/ui-core/components` (Next-aware via `NextLinkProvider`) |
|
|
118
120
|
|
|
119
|
-
Out of scope: locale prefixes, route matching, dynamic segments, transitions
|
|
121
|
+
Out of scope: locale prefixes, route matching, dynamic segments, transitions. Add them in consumer code or in higher-level packages.
|
|
@@ -32,14 +32,20 @@
|
|
|
32
32
|
* never resolved.
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
|
-
import { useMemo, type ReactNode } from 'react';
|
|
35
|
+
import { forwardRef, useMemo, type ReactNode } from 'react';
|
|
36
36
|
import { useRouter as useNextRouter } from 'next/navigation';
|
|
37
|
+
import NextLink from 'next/link';
|
|
37
38
|
|
|
38
39
|
import {
|
|
39
40
|
RouterAdapterProvider,
|
|
40
41
|
type RouterAdapter,
|
|
41
42
|
type RouterLocation,
|
|
42
43
|
} from '../adapter';
|
|
44
|
+
import {
|
|
45
|
+
LinkProvider,
|
|
46
|
+
type LinkComponent,
|
|
47
|
+
type LinkComponentProps,
|
|
48
|
+
} from '../../../components/navigation/link';
|
|
43
49
|
|
|
44
50
|
const SSR_LOCATION: RouterLocation = Object.freeze({
|
|
45
51
|
pathname: '/',
|
|
@@ -98,3 +104,37 @@ export function NextRouterAdapter({
|
|
|
98
104
|
<RouterAdapterProvider value={adapter}>{children}</RouterAdapterProvider>
|
|
99
105
|
);
|
|
100
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,8 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
export { useDebounce } from './useDebounce';
|
|
4
|
+
export { useDebouncedCallback } from './useDebouncedCallback';
|
|
5
|
+
export { useLocalStorage } from './useLocalStorage';
|
|
6
|
+
export { useSessionStorage } from './useSessionStorage';
|
|
7
|
+
export { useStoredValue } from './useStoredValue';
|
|
8
|
+
export type { UseStoredValueOptions, StorageType } from './useStoredValue';
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
AlertDialogHeader,
|
|
12
12
|
AlertDialogTitle,
|
|
13
13
|
} from '../../../components/overlay/alert-dialog';
|
|
14
|
-
import { useHotkey } from '../../../hooks
|
|
14
|
+
import { useHotkey } from '../../../hooks';
|
|
15
15
|
import { I18N_KEYS } from '../constants';
|
|
16
16
|
import type { DialogOptions } from '../types';
|
|
17
17
|
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
AlertDialogTitle,
|
|
14
14
|
} from '../../../components/overlay/alert-dialog';
|
|
15
15
|
import { buttonVariants } from '../../../components/forms/button';
|
|
16
|
-
import { useHotkey } from '../../../hooks
|
|
16
|
+
import { useHotkey } from '../../../hooks';
|
|
17
17
|
import { cn } from '../../utils';
|
|
18
18
|
import { I18N_KEYS } from '../constants';
|
|
19
19
|
import type { DialogOptions } from '../types';
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
} from '../../../components/overlay/dialog';
|
|
13
13
|
import { Button } from '../../../components/forms/button';
|
|
14
14
|
import { Input } from '../../../components/forms/input';
|
|
15
|
-
import { useHotkey } from '../../../hooks
|
|
15
|
+
import { useHotkey } from '../../../hooks';
|
|
16
16
|
import { I18N_KEYS } from '../constants';
|
|
17
17
|
import type { DialogOptions } from '../types';
|
|
18
18
|
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { useMemo } from 'react';
|
|
18
|
-
import { useResolvedTheme } from '../../hooks
|
|
18
|
+
import { useResolvedTheme } from '../../hooks';
|
|
19
19
|
import { hslToHex } from './utils';
|
|
20
20
|
import type { ThemePalette, StylePresets, BoxColors } from './types';
|
|
21
21
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|