@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
package/README.md CHANGED
@@ -83,28 +83,62 @@ Default **`TooltipContent`** styling uses semantic **popover** tokens (`bg-popov
83
83
  ### Specialized (7)
84
84
  `Kbd` `TokenIcon` `Item` `Portal` `ImageWithFallback` `CopyButton` `CopyField`
85
85
 
86
- ## Hooks (17)
86
+ ## Hooks (30+)
87
+
88
+ Hooks are organized by domain inside the package (`src/hooks/<group>/`).
89
+ Public import path is the single barrel:
90
+
91
+ ```tsx
92
+ import { useIsMobile, useScroll, useNavigate } from '@djangocfg/ui-core/hooks';
93
+ ```
94
+
95
+ ### `dom/` — DOM & viewport
87
96
 
88
97
  | Hook | Description |
89
98
  |------|-------------|
90
- | `useMediaQuery(query)` | Raw media query pass any CSS query string. Exports `BREAKPOINTS` constants (Tailwind v4 defaults) |
91
- | `useIsPhone()` | `< 640px` — phones only |
92
- | `useIsMobile()` | `< 768px` phones + small tablets |
93
- | `useIsTabletOrBelow()` | `< 1024px` phones + tablets |
94
- | `useBodyScrollLock(locked)` | Lock body scroll while `locked=true`; counter-based (multi-consumer safe), iOS-safe via `position: fixed` fallback |
95
- | `useCopy` | Copy to clipboard |
96
- | `useCountdown` | Countdown timer |
97
- | `useDebounce` | Debounce values |
98
- | `useDebouncedCallback` | Debounced callbacks |
99
- | `useImageLoader` | Image loading state |
100
- | `useToast` / `toast` | Toast notifications (Sonner) |
101
- | `useEventListener` | Event bus |
102
- | `useDebugTools` | Debug utilities |
103
- | `useHotkey` | Keyboard shortcuts (react-hotkeys-hook) |
104
- | `useShortcutModLabel()` | Returns `⌘` or `Ctrl` for shortcut hints (Apple vs Windows/Linux); pairs with `metaKey \|\| ctrlKey` handlers |
105
- | `useBrowserDetect` | Browser detection (Chrome, Safari, in-app browsers, etc.) |
106
- | `useDeviceDetect` | Device detection (mobile, tablet, desktop, OS, etc.) |
107
- | `useResolvedTheme` | Current resolved theme (light/dark/system) |
99
+ | `useScroll(target?)` | Reactive `{ x, y, direction, isScrolling }` for window or any scrollable element. `useSyncExternalStore` + module-level shared store + rAF throttle + passive listener. |
100
+ | `useScrollPosition(target?)` / `useScrollDirection(target?)` / `useIsScrolling(target?)` | Single-field variants re-render only when their slice changes. |
101
+ | `useBodyScrollLock(locked)` | Lock body scroll while `locked=true`; counter-based (multi-consumer safe), iOS-safe via `position: fixed` fallback. |
102
+ | `useCopy` | Copy to clipboard. |
103
+ | `useImageLoader` | Image loading state. |
104
+
105
+ ### `media/` viewport size
106
+
107
+ | Hook | Description |
108
+ |------|-------------|
109
+ | `useMediaQuery(query)` | Raw media query — pass any CSS query string. Exports `BREAKPOINTS` constants (Tailwind v4 defaults). |
110
+ | `useIsPhone()` | `< 640px` — phones only. |
111
+ | `useIsMobile()` | `< 768px` — phones + small tablets. |
112
+ | `useIsTabletOrBelow()` | `< 1024px` phones + tablets. |
113
+
114
+ ### `state/` state primitives
115
+
116
+ | Hook | Description |
117
+ |------|-------------|
118
+ | `useDebounce` | Debounce values. |
119
+ | `useDebouncedCallback` | Debounced callbacks. |
120
+ | `useLocalStorage` / `useSessionStorage` | Type-safe wrappers with TTL. |
121
+ | `useStoredValue` | Unified API over local/session storage. |
122
+
123
+ ### `device/` — environment detection
124
+
125
+ | Hook | Description |
126
+ |------|-------------|
127
+ | `useBrowserDetect` | Browser detection (Chrome, Safari, in-app browsers, etc.). |
128
+ | `useDeviceDetect` | Device detection (mobile, tablet, desktop, OS, etc.). |
129
+ | `useShortcutModLabel()` | Returns `⌘` or `Ctrl` for shortcut hints (Apple vs Windows/Linux); pairs with `metaKey \|\| ctrlKey` handlers. |
130
+
131
+ ### Other groups
132
+
133
+ | Group | Hooks |
134
+ |-------|-------|
135
+ | `feedback/` | `useToast`, `toast` (Sonner). |
136
+ | `theme/` | `useResolvedTheme` — current resolved theme (light/dark/system). |
137
+ | `time/` | `useCountdown`, `useCountdownFromSeconds`. |
138
+ | `events/` | `useEventListener`, `events` (PubSub bus). |
139
+ | `hotkey/` | `useHotkey`, `HotkeysProvider` (react-hotkeys-hook). |
140
+ | `debug/` | `useDebugTools`. |
141
+ | `router/` | See [Router Hooks](#router-hooks) below — its own subsection. |
108
142
 
109
143
  ```tsx
110
144
  import { useMediaQuery, useIsPhone, useIsMobile, BREAKPOINTS } from '@djangocfg/ui-core/hooks'
@@ -116,8 +150,61 @@ const isMobile = useIsMobile() // < 768px
116
150
  // custom with constants
117
151
  const isNarrow = useMediaQuery(`(max-width: ${BREAKPOINTS.sm - 1}px)`)
118
152
  const isDark = useMediaQuery('(prefers-color-scheme: dark)')
153
+
154
+ // scroll snapshot — direction-aware navbar
155
+ const { direction, isScrolling } = useScroll()
156
+ const hideNav = direction === 'down' && isScrolling
157
+ ```
158
+
159
+ ## Router Hooks
160
+
161
+ Framework-agnostic navigation primitives — work on plain History API by default, plug into any router via the adapter pattern. Full docs in [`src/hooks/router/README.md`](./src/hooks/router/README.md).
162
+
163
+ | Hook | Purpose |
164
+ |------|---------|
165
+ | `useLocation()` | Reactive `{ pathname, search, hash, href }`. |
166
+ | `useLocationProperty(get, getSsr)` | Subscribe to ONE field — skip re-renders on unrelated changes. |
167
+ | `useNavigate()` | `navigate`, `navigateExternal`, `push`, `replace`, `back`, `forward`. |
168
+ | `useQueryParams()` | Record-style read/write of `?key=value` URL state. |
169
+ | `useQueryState(key, parser)` | Typed `useState`-style hook bound to one URL key (with `clearOnDefault`). |
170
+ | `useBackOrFallback()` | Smart Back that falls back to a route when there's no in-app history. |
171
+ | `useUrlBuilder()` | Pure URL assembly: `build`, `withCurrentParams`. |
172
+ | `useSmartLink(href)` | Make any element a link (cmd-click, middle-click, Enter, Space). |
173
+ | `useIsActive(href)` | Boolean for nav-item highlighting. |
174
+ | `useRouter()` | Convenience facade composing everything. |
175
+ | `RouterAdapterProvider` | Swap the navigation backend. |
176
+ | `parseAsString` / `parseAsInteger` / `parseAsFloat` / `parseAsBoolean` / `parseAsIsoDate` / `parseAsStringEnum` / `parseAsArrayOf` / `parseAsJson` | Parsers for `useQueryState`. Each has `.withDefault(value)`. |
177
+
178
+ ```tsx
179
+ import {
180
+ useNavigate,
181
+ useQueryState,
182
+ parseAsInteger,
183
+ } from '@djangocfg/ui-core/hooks';
184
+
185
+ const { navigate } = useNavigate();
186
+ const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
187
+
188
+ navigate('/products');
189
+ setPage((p) => p + 1); // ?page=2 — `page=1` is dropped (clearOnDefault)
119
190
  ```
120
191
 
192
+ ### Next.js adapter
193
+
194
+ In Next apps, mount both adapters once near the root so navigation flows through `next/navigation` (server components + prefetch keep working) and `<Link>` delegates to `next/link`:
195
+
196
+ ```tsx
197
+ import { NextRouterAdapter, NextLinkProvider } from '@djangocfg/ui-core/adapters/nextjs';
198
+
199
+ <NextRouterAdapter>
200
+ <NextLinkProvider>
201
+ <App />
202
+ </NextLinkProvider>
203
+ </NextRouterAdapter>
204
+ ```
205
+
206
+ `next` is an **optional peer dependency** — Wails / Electron / Vite consumers don't pull it in. `@djangocfg/layouts/BaseApp` mounts both adapters automatically.
207
+
121
208
  ## Theme Palette Hooks
122
209
 
123
210
  Hooks for accessing theme colors from CSS variables (useful for Canvas, SVG, charts, diagrams, etc.):
@@ -290,7 +377,8 @@ import '@djangocfg/ui-core/styles/globals';
290
377
  |------|---------|
291
378
  | `@djangocfg/ui-core` | All components & hooks |
292
379
  | `@djangocfg/ui-core/components` | Components only |
293
- | `@djangocfg/ui-core/hooks` | Hooks only |
380
+ | `@djangocfg/ui-core/hooks` | Hooks only (incl. router hooks) |
381
+ | `@djangocfg/ui-core/adapters/nextjs` | `<NextRouterAdapter>` + `<NextLinkProvider>` for Next.js apps (optional peer: `next`) |
294
382
  | `@djangocfg/ui-core/lib` | Utilities (cn, etc.) |
295
383
  | `@djangocfg/ui-core/lib/dialog-service` | Dialog service |
296
384
  | `@djangocfg/ui-core/utils` | Runtime utilities (emitRuntimeError) |
@@ -311,18 +399,31 @@ try {
311
399
  }
312
400
  ```
313
401
 
402
+ ## Links
403
+
404
+ `<Link>` and `<ButtonLink>` ship in `ui-core` itself — no Next.js required.
405
+ Default behavior renders `<a>` and routes clicks through `useNavigate`.
406
+
407
+ In Next.js apps, mount `NextLinkProvider` (from `@djangocfg/ui-core/adapters/nextjs`)
408
+ near the root and the same components delegate to `next/link` automatically.
409
+ `@djangocfg/layouts/BaseApp` does this for you.
410
+
411
+ ```tsx
412
+ import { Link, ButtonLink } from '@djangocfg/ui-core/components';
413
+
414
+ <Link href="/about">About</Link>
415
+ <ButtonLink href="/docs" variant="outline">Docs</ButtonLink>
416
+ ```
417
+
314
418
  ## What's NOT included (use ui-nextjs)
315
419
 
316
420
  These features require Next.js or browser storage APIs:
317
421
 
318
- - `Sidebar` — uses next/link
319
- - `Breadcrumb`, `BreadcrumbNavigation` — uses next/link
320
- - `NavigationMenu`, `Menubar` — uses next/link
321
- - `Pagination`, `SSRPagination` — uses next/link
322
- - `DropdownMenu` — uses next/link
422
+ - `Sidebar` — `'use client'` heavy, lives in ui-nextjs
423
+ - `Breadcrumb`, `BreadcrumbNavigation` — same
424
+ - `Pagination`, `SSRPagination` — same
323
425
  - `DownloadButton` — uses localStorage
324
426
  - `useTheme` — uses next-themes
325
- - `useQueryParams`, `useCfgRouter` — uses next/router
326
427
 
327
428
  ## Requirements
328
429
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-core",
3
- "version": "2.1.293",
3
+ "version": "2.1.297",
4
4
  "description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
5
5
  "keywords": [
6
6
  "ui-components",
@@ -48,6 +48,11 @@
48
48
  "import": "./src/hooks/index.ts",
49
49
  "require": "./src/hooks/index.ts"
50
50
  },
51
+ "./adapters/nextjs": {
52
+ "types": "./src/hooks/router/adapters/nextjs.tsx",
53
+ "import": "./src/hooks/router/adapters/nextjs.tsx",
54
+ "require": "./src/hooks/router/adapters/nextjs.tsx"
55
+ },
51
56
  "./lib": {
52
57
  "types": "./src/lib/index.ts",
53
58
  "import": "./src/lib/index.ts",
@@ -86,10 +91,11 @@
86
91
  "playground": "playground dev"
87
92
  },
88
93
  "peerDependencies": {
89
- "@djangocfg/i18n": "^2.1.293",
94
+ "@djangocfg/i18n": "^2.1.297",
90
95
  "consola": "^3.4.2",
91
96
  "lucide-react": "^0.545.0",
92
97
  "moment": "^2.30.1",
98
+ "next": ">=14.0.0",
93
99
  "react": "^19.1.0",
94
100
  "react-device-detect": "^2.2.3",
95
101
  "react-dom": "^19.1.0",
@@ -98,6 +104,11 @@
98
104
  "zod": "^4.3.6",
99
105
  "zustand": "^5.0.0"
100
106
  },
107
+ "peerDependenciesMeta": {
108
+ "next": {
109
+ "optional": true
110
+ }
111
+ },
101
112
  "dependencies": {
102
113
  "@hookform/resolvers": "^5.2.2",
103
114
  "@radix-ui/react-accordion": "^1.2.12",
@@ -148,13 +159,14 @@
148
159
  "vaul": "1.1.2"
149
160
  },
150
161
  "devDependencies": {
151
- "@djangocfg/i18n": "^2.1.293",
162
+ "@djangocfg/i18n": "^2.1.297",
152
163
  "@djangocfg/playground": "workspace:*",
153
- "@djangocfg/typescript-config": "^2.1.293",
164
+ "@djangocfg/typescript-config": "^2.1.297",
154
165
  "@types/node": "^24.7.2",
155
166
  "@types/react": "^19.1.0",
156
167
  "@types/react-dom": "^19.1.0",
157
168
  "lucide-react": "^0.545.0",
169
+ "next": "^16.2.4",
158
170
  "typescript": "^5.9.3"
159
171
  },
160
172
  "publishConfig": {
@@ -1,7 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  import { Toaster as Sonner } from 'sonner';
4
- import { useResolvedTheme } from '../../../hooks/useResolvedTheme';
4
+ import { useResolvedTheme } from '../../../hooks';
5
5
 
6
6
  type ToasterProps = React.ComponentProps<typeof Sonner>
7
7
 
@@ -7,6 +7,7 @@ import * as React from 'react';
7
7
  import { Slot } from '@radix-ui/react-slot';
8
8
 
9
9
  import { cn } from '../../../lib/utils';
10
+ import { Link } from '../../navigation/link';
10
11
 
11
12
  const buttonVariants = cva(
12
13
  "inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
@@ -101,24 +102,39 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
101
102
  )
102
103
  Button.displayName = "Button"
103
104
 
104
- // ButtonLink using native anchor tag (no Next.js dependency)
105
+ /**
106
+ * ButtonLink — anchor styled as a Button.
107
+ *
108
+ * Renders the framework-agnostic `<Link>` underneath, so SPA navigation,
109
+ * cmd-click, and the Next.js adapter (if mounted) all work out of the box.
110
+ * Drop-in replacement for the previous native-`<a>` version.
111
+ */
105
112
  export interface ButtonLinkProps
106
- extends React.AnchorHTMLAttributes<HTMLAnchorElement>,
113
+ extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, 'href'>,
107
114
  VariantProps<typeof buttonVariants> {
108
115
  href: string
116
+ /** Use replaceState instead of pushState. Default: false. */
117
+ replace?: boolean
118
+ /** Scroll to top after SPA navigation. Default: false. */
119
+ scroll?: boolean
120
+ /** Hint for the host framework's prefetcher (Next.js). */
121
+ prefetch?: boolean | null
109
122
  }
110
123
 
111
124
  const ButtonLink = React.forwardRef<HTMLAnchorElement, ButtonLinkProps>(
112
- ({ className, variant, size, href, children, ...props }, ref) => {
125
+ ({ className, variant, size, href, replace, scroll, prefetch, children, ...props }, ref) => {
113
126
  return (
114
- <a
127
+ <Link
115
128
  href={href}
129
+ replace={replace}
130
+ scroll={scroll}
131
+ prefetch={prefetch}
116
132
  className={cn(buttonVariants({ variant, size, className }))}
117
133
  ref={ref}
118
134
  {...props}
119
135
  >
120
136
  {children}
121
- </a>
137
+ </Link>
122
138
  )
123
139
  }
124
140
  )
@@ -5,7 +5,7 @@ import * as React from 'react';
5
5
 
6
6
  import { Button, type ButtonProps } from '../../forms/button';
7
7
  import { cn } from '../../../lib';
8
- import { useLocalStorage } from '../../../hooks/useLocalStorage';
8
+ import { useLocalStorage } from '../../../hooks';
9
9
 
10
10
  // Token key used by the API client
11
11
  const TOKEN_KEY = "auth_token"
@@ -1,6 +1,6 @@
1
1
  import * as React from 'react';
2
2
 
3
- import { useStoredValue, type StorageType, type UseStoredValueOptions } from '../../../hooks/useStoredValue';
3
+ import { useStoredValue, type StorageType, type UseStoredValueOptions } from '../../../hooks';
4
4
  import { cn } from '../../../lib/utils';
5
5
 
6
6
  export interface InputProps extends React.ComponentProps<"input"> {
@@ -5,7 +5,7 @@ import * as React from 'react';
5
5
 
6
6
  import { InputOTP, InputOTPGroup, InputOTPSlot } from '../input-otp';
7
7
  import { cn } from '../../../lib';
8
- import { useIsMobile } from '../../../hooks/useMobile';
8
+ import { useIsMobile } from '../../../hooks';
9
9
 
10
10
  import { createPasteHandler, useSmartOTP } from './use-otp-input';
11
11
 
@@ -4,7 +4,7 @@ import * as React from 'react';
4
4
 
5
5
  import * as SliderPrimitive from '@radix-ui/react-slider';
6
6
 
7
- import { useStoredValue, type StorageType, type UseStoredValueOptions } from '../../../hooks/useStoredValue';
7
+ import { useStoredValue, type StorageType, type UseStoredValueOptions } from '../../../hooks';
8
8
  import { cn } from '../../../lib/utils';
9
9
 
10
10
  export interface SliderProps extends React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> {
@@ -1,6 +1,6 @@
1
1
  import * as React from 'react';
2
2
 
3
- import { useStoredValue, type StorageType, type UseStoredValueOptions } from '../../../hooks/useStoredValue';
3
+ import { useStoredValue, type StorageType, type UseStoredValueOptions } from '../../../hooks';
4
4
  import { cn } from '../../../lib/utils';
5
5
 
6
6
  export interface TextareaProps extends React.ComponentProps<"textarea"> {
@@ -60,6 +60,8 @@ export { Tabs, TabsContent, TabsList, TabsTrigger } from './navigation/tabs';
60
60
  export { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './navigation/accordion';
61
61
  export { Collapsible, CollapsibleContent, CollapsibleTrigger } from './navigation/collapsible';
62
62
  export { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandShortcut } from './navigation/command';
63
+ export { Link, LinkProvider, LinkComponentContext, useLinkComponent } from './navigation/link';
64
+ export type { LinkProps, LinkComponent, LinkComponentProps, LinkProviderProps } from './navigation/link';
63
65
 
64
66
  // ─────────────────────────────────────────────────────────────────────────────
65
67
  // Layout
@@ -35,7 +35,7 @@
35
35
  import * as React from 'react';
36
36
  import StickyBox from 'react-sticky-box';
37
37
 
38
- import { useIsMobile } from '../../../hooks/useMobile';
38
+ import { useIsMobile } from '../../../hooks';
39
39
 
40
40
  export interface StickyProps extends React.HTMLAttributes<HTMLDivElement> {
41
41
  /** Stick to bottom instead of top */
@@ -5,7 +5,7 @@ import * as React from 'react';
5
5
  import * as AccordionPrimitive from '@radix-ui/react-accordion';
6
6
  import { ChevronDownIcon } from '@radix-ui/react-icons';
7
7
 
8
- import { useStoredValue, type StorageType, type UseStoredValueOptions } from '../../../hooks/useStoredValue';
8
+ import { useStoredValue, type StorageType, type UseStoredValueOptions } from '../../../hooks';
9
9
  import { cn } from '../../../lib/utils';
10
10
 
11
11
  type AccordionSingleProps = React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Root> & {
@@ -5,6 +5,7 @@ import * as React from 'react';
5
5
 
6
6
  import { cn } from '../../../lib';
7
7
  import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
8
+ import { Link } from '../link';
8
9
 
9
10
  const DropdownMenu = DropdownMenuPrimitive.Root
10
11
 
@@ -92,9 +93,9 @@ const DropdownMenuItem = React.forwardRef<
92
93
  if (href) {
93
94
  return (
94
95
  <DropdownMenuPrimitive.Item asChild ref={ref} {...props}>
95
- <a href={href} className={classes}>
96
+ <Link href={href} className={classes}>
96
97
  {children}
97
- </a>
98
+ </Link>
98
99
  </DropdownMenuPrimitive.Item>
99
100
  )
100
101
  }
@@ -0,0 +1,124 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * <Link> — framework-agnostic link component.
5
+ *
6
+ * WHY:
7
+ * We want a single Link import everywhere, regardless of whether the
8
+ * host app is Next.js, Wails, Electron, Vite, or plain CRA. When a
9
+ * framework adapter is mounted (e.g. NextLinkProvider) the native
10
+ * component handles prefetch / locale / RSC wiring. Otherwise we
11
+ * render a plain <a> and route clicks through the active router
12
+ * adapter (which itself defaults to History API).
13
+ *
14
+ * Behavior parity with `<a>`:
15
+ * - Cmd/Ctrl/Shift+click and middle-click open a new tab natively
16
+ * (we let the browser handle modifier clicks — DON'T preventDefault).
17
+ * - External / non-internal hrefs (http://, mailto:, tel:, target=_blank)
18
+ * skip SPA nav entirely and behave as the browser would.
19
+ *
20
+ * @example
21
+ * import { Link } from '@djangocfg/ui-core/components';
22
+ * <Link href="/dashboard">Dashboard</Link>
23
+ * <Link href="/dashboard" replace scroll>Reset</Link>
24
+ * <Link href="https://example.com" target="_blank" rel="noopener noreferrer">External</Link>
25
+ */
26
+
27
+ import { forwardRef, useCallback, type AnchorHTMLAttributes, type MouseEvent, type ReactNode } from 'react';
28
+ import { useNavigate } from '../../../hooks/router/useNavigate';
29
+ import { useLinkComponent } from './LinkContext';
30
+
31
+ type AnchorProps = Omit<
32
+ AnchorHTMLAttributes<HTMLAnchorElement>,
33
+ 'href' | 'onClick'
34
+ >;
35
+
36
+ export interface LinkProps extends AnchorProps {
37
+ href: string;
38
+ /** Use replaceState instead of pushState. Default: false. */
39
+ replace?: boolean;
40
+ /** Scroll to top after SPA navigation. Default: false. */
41
+ scroll?: boolean;
42
+ /**
43
+ * Hint for the host framework's prefetcher (Next.js).
44
+ * Ignored by the agnostic fallback.
45
+ */
46
+ prefetch?: boolean | null;
47
+ children?: ReactNode;
48
+ onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
49
+ }
50
+
51
+ /** True for protocols / schemes the browser must handle natively. */
52
+ function isExternalHref(href: string): boolean {
53
+ // Allow same-origin absolute paths (`/foo`, `./foo`, `#hash`, `?q=1`).
54
+ if (href.startsWith('/') && !href.startsWith('//')) return false;
55
+ if (href.startsWith('#') || href.startsWith('?')) return false;
56
+ // Anything with a scheme (http:, https:, mailto:, tel:, ipfs:, …).
57
+ if (/^[a-z][a-z0-9+.-]*:/i.test(href)) return true;
58
+ // `//example.com` protocol-relative.
59
+ if (href.startsWith('//')) return true;
60
+ return false;
61
+ }
62
+
63
+ /**
64
+ * True when the click should be left to the browser:
65
+ * modifier keys, non-primary mouse button, or `target=_blank`.
66
+ */
67
+ function shouldBypassClick(event: MouseEvent<HTMLAnchorElement>, target?: string): boolean {
68
+ if (event.defaultPrevented) return true;
69
+ if (event.button !== 0) return true;
70
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return true;
71
+ if (target && target !== '' && target !== '_self') return true;
72
+ return false;
73
+ }
74
+
75
+ export const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link(
76
+ { href, replace, scroll, prefetch, target, rel, onClick, children, ...rest },
77
+ ref
78
+ ) {
79
+ const Adapter = useLinkComponent();
80
+ const { navigate } = useNavigate();
81
+
82
+ const handleClick = useCallback(
83
+ (event: MouseEvent<HTMLAnchorElement>) => {
84
+ onClick?.(event);
85
+ if (event.defaultPrevented) return;
86
+ if (isExternalHref(href)) return;
87
+ if (shouldBypassClick(event, target)) return;
88
+ event.preventDefault();
89
+ navigate(href, { replace, scroll });
90
+ },
91
+ [onClick, href, target, navigate, replace, scroll]
92
+ );
93
+
94
+ if (Adapter) {
95
+ return (
96
+ <Adapter
97
+ href={href}
98
+ replace={replace}
99
+ scroll={scroll}
100
+ prefetch={prefetch}
101
+ target={target}
102
+ rel={rel}
103
+ ref={ref}
104
+ onClick={onClick}
105
+ {...rest}
106
+ >
107
+ {children}
108
+ </Adapter>
109
+ );
110
+ }
111
+
112
+ return (
113
+ <a
114
+ href={href}
115
+ target={target}
116
+ rel={rel}
117
+ ref={ref}
118
+ onClick={handleClick}
119
+ {...rest}
120
+ >
121
+ {children}
122
+ </a>
123
+ );
124
+ });
@@ -0,0 +1,52 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * LinkContext
5
+ *
6
+ * Adapter slot for the framework's native link component (e.g. `next/link`).
7
+ * `<Link>` reads this context: when populated, it delegates rendering so
8
+ * the host framework's prefetching / locale handling kicks in. When empty,
9
+ * `<Link>` falls back to a pure `<a>` driven by `useNavigate`.
10
+ *
11
+ * Mount once near the root via the matching adapter
12
+ * (e.g. `NextLinkProvider` from `@djangocfg/ui-core/adapters/nextjs`).
13
+ */
14
+
15
+ import { createContext, useContext, type ComponentType, type ReactNode, type Ref } from 'react';
16
+
17
+ export interface LinkComponentProps {
18
+ href: string;
19
+ replace?: boolean;
20
+ scroll?: boolean;
21
+ prefetch?: boolean | null;
22
+ className?: string;
23
+ children?: ReactNode;
24
+ target?: string;
25
+ rel?: string;
26
+ ref?: Ref<HTMLAnchorElement>;
27
+ onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
28
+ [dataAttr: `data-${string}`]: unknown;
29
+ [ariaAttr: `aria-${string}`]: unknown;
30
+ }
31
+
32
+ export type LinkComponent = ComponentType<LinkComponentProps>;
33
+
34
+ export const LinkComponentContext = createContext<LinkComponent | null>(null);
35
+
36
+ export interface LinkProviderProps {
37
+ value: LinkComponent;
38
+ children: ReactNode;
39
+ }
40
+
41
+ export function LinkProvider({ value, children }: LinkProviderProps) {
42
+ return (
43
+ <LinkComponentContext.Provider value={value}>
44
+ {children}
45
+ </LinkComponentContext.Provider>
46
+ );
47
+ }
48
+
49
+ /** Returns the host-framework Link if a provider is mounted, else `null`. */
50
+ export function useLinkComponent(): LinkComponent | null {
51
+ return useContext(LinkComponentContext);
52
+ }
@@ -0,0 +1,8 @@
1
+ export { Link } from './Link';
2
+ export type { LinkProps } from './Link';
3
+ export {
4
+ LinkComponentContext,
5
+ LinkProvider,
6
+ useLinkComponent,
7
+ } from './LinkContext';
8
+ export type { LinkComponent, LinkComponentProps, LinkProviderProps } from './LinkContext';
@@ -5,6 +5,7 @@ import * as React from 'react';
5
5
 
6
6
  import { cn } from '../../../lib';
7
7
  import * as MenubarPrimitive from '@radix-ui/react-menubar';
8
+ import { Link } from '../link';
8
9
 
9
10
  function MenubarMenu({
10
11
  ...props
@@ -144,9 +145,9 @@ const MenubarItem = React.forwardRef<
144
145
  if (href) {
145
146
  return (
146
147
  <MenubarPrimitive.Item asChild ref={ref} {...props}>
147
- <a href={href} className={classes}>
148
+ <Link href={href} className={classes}>
148
149
  {children}
149
- </a>
150
+ </Link>
150
151
  </MenubarPrimitive.Item>
151
152
  )
152
153
  }
@@ -6,6 +6,7 @@ import * as React from 'react';
6
6
 
7
7
  import { cn } from '../../../lib';
8
8
  import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu';
9
+ import { Link } from '../link';
9
10
 
10
11
  const NavigationMenu = React.forwardRef<
11
12
  React.ElementRef<typeof NavigationMenuPrimitive.Root>,
@@ -88,7 +89,7 @@ const NavigationMenuLink = React.forwardRef<
88
89
  if (href) {
89
90
  return (
90
91
  <NavigationMenuPrimitive.Link asChild ref={ref} {...props}>
91
- <a href={href}>{children}</a>
92
+ <Link href={href}>{children}</Link>
92
93
  </NavigationMenuPrimitive.Link>
93
94
  )
94
95
  }
@@ -6,7 +6,7 @@ import * as React from 'react';
6
6
  import * as TabsPrimitive from '@radix-ui/react-tabs';
7
7
 
8
8
  import { useIsMobile } from '../../../hooks';
9
- import { useStoredValue, type StorageType } from '../../../hooks/useStoredValue';
9
+ import { useStoredValue, type StorageType } from '../../../hooks';
10
10
  import { useAppT } from '@djangocfg/i18n';
11
11
  import { cn } from '../../../lib/utils';
12
12
  import { Button } from '../../forms/button';
@@ -9,7 +9,7 @@
9
9
 
10
10
  import * as React from 'react';
11
11
 
12
- import { useIsMobile } from '../../../hooks/useMobile';
12
+ import { useIsMobile } from '../../../hooks';
13
13
 
14
14
  import {
15
15
  Dialog,