@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.
Files changed (60) hide show
  1. package/README.md +83 -29
  2. package/package.json +4 -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 +15 -26
  34. package/src/hooks/media/index.ts +5 -0
  35. package/src/hooks/router/README.md +7 -5
  36. package/src/hooks/router/adapters/nextjs.tsx +41 -1
  37. package/src/hooks/state/index.ts +8 -0
  38. package/src/hooks/theme/index.ts +4 -0
  39. package/src/hooks/time/index.ts +4 -0
  40. package/src/lib/dialog-service/dialogs/AlertDialogUI.tsx +1 -1
  41. package/src/lib/dialog-service/dialogs/ConfirmDialogUI.tsx +1 -1
  42. package/src/lib/dialog-service/dialogs/PromptDialogUI.tsx +1 -1
  43. package/src/styles/palette/useThemePalette.ts +1 -1
  44. /package/src/hooks/{useDebugTools.ts → debug/useDebugTools.ts} +0 -0
  45. /package/src/hooks/{useBrowserDetect.ts → device/useBrowserDetect.ts} +0 -0
  46. /package/src/hooks/{useDeviceDetect.ts → device/useDeviceDetect.ts} +0 -0
  47. /package/src/hooks/{useShortcutModLabel.ts → device/useShortcutModLabel.ts} +0 -0
  48. /package/src/hooks/{useImageLoader.ts → dom/useImageLoader.ts} +0 -0
  49. /package/src/hooks/{useEventsBus.ts → events/useEventsBus.ts} +0 -0
  50. /package/src/hooks/{useToast.ts → feedback/useToast.ts} +0 -0
  51. /package/src/hooks/{useHotkey.ts → hotkey/useHotkey.ts} +0 -0
  52. /package/src/hooks/{useMediaQuery.ts → media/useMediaQuery.ts} +0 -0
  53. /package/src/hooks/{useMobile.tsx → media/useMobile.tsx} +0 -0
  54. /package/src/hooks/{useDebounce.ts → state/useDebounce.ts} +0 -0
  55. /package/src/hooks/{useDebouncedCallback.ts → state/useDebouncedCallback.ts} +0 -0
  56. /package/src/hooks/{useLocalStorage.ts → state/useLocalStorage.ts} +0 -0
  57. /package/src/hooks/{useSessionStorage.ts → state/useSessionStorage.ts} +0 -0
  58. /package/src/hooks/{useStoredValue.ts → state/useStoredValue.ts} +0 -0
  59. /package/src/hooks/{useResolvedTheme.ts → theme/useResolvedTheme.ts} +0 -0
  60. /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';
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useEffect } from 'react';
4
4
 
5
- import { useDeviceDetect } from './useDeviceDetect';
5
+ import { useDeviceDetect } from '../device/useDeviceDetect';
6
6
 
7
7
  /**
8
8
  * useBodyScrollLock — locks body scroll while `locked` is true.
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useCallback } from 'react';
4
4
 
5
- import { useToast } from './useToast';
5
+ import { useToast } from '../feedback/useToast';
6
6
 
7
7
  interface UseCopyOptions {
8
8
  successMessage?: string;
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ 'use client';
2
+
3
+ export { useEventListener, events } from './useEventsBus';
@@ -0,0 +1,3 @@
1
+ 'use client';
2
+
3
+ export { useToast, toast } from './useToast';
@@ -0,0 +1,4 @@
1
+ 'use client';
2
+
3
+ export { useHotkey, useHotkeysContext, HotkeysProvider, isHotkeyPressed } from './useHotkey';
4
+ export type { UseHotkeyOptions, HotkeyCallback, Keys, HotkeyRefType } from './useHotkey';
@@ -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 { useCountdown, useCountdownFromSeconds } from './useCountdown';
9
- export type { CountdownState } from './useCountdown';
10
- export { useDebouncedCallback } from './useDebouncedCallback';
11
- export { useDebounce } from './useDebounce';
12
- export { useDebugTools } from './useDebugTools';
13
- export { useEventListener, events } from './useEventsBus';
14
- export { useIsMobile, useIsPhone, useIsTabletOrBelow, BREAKPOINTS } from './useMobile';
15
- export { useBodyScrollLock } from './useBodyScrollLock';
16
- export type { Breakpoint } from './useMobile';
17
- export { useMediaQuery, BREAKPOINTS as MEDIA_BREAKPOINTS } from './useMediaQuery';
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.
@@ -0,0 +1,5 @@
1
+ 'use client';
2
+
3
+ export { useMediaQuery, BREAKPOINTS as MEDIA_BREAKPOINTS } from './useMediaQuery';
4
+ export { useIsMobile, useIsPhone, useIsTabletOrBelow, BREAKPOINTS } from './useMobile';
5
+ export type { Breakpoint } from './useMobile';
@@ -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 the built-in adapter once near the root. It bridges every hook to `next/navigation` so server components, route loaders, and prefetch fire correctly:
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
- <AppLayout>{children}</AppLayout>
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 | not yet (use `useSmartLink` on any element) |
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, `<Link>`. Add them in consumer code or in higher-level packages.
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';
@@ -0,0 +1,4 @@
1
+ 'use client';
2
+
3
+ export { useResolvedTheme } from './useResolvedTheme';
4
+ export type { ResolvedTheme } from './useResolvedTheme';
@@ -0,0 +1,4 @@
1
+ 'use client';
2
+
3
+ export { useCountdown, useCountdownFromSeconds } from './useCountdown';
4
+ export type { CountdownState } from './useCountdown';
@@ -11,7 +11,7 @@ import {
11
11
  AlertDialogHeader,
12
12
  AlertDialogTitle,
13
13
  } from '../../../components/overlay/alert-dialog';
14
- import { useHotkey } from '../../../hooks/useHotkey';
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/useHotkey';
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/useHotkey';
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/useResolvedTheme';
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