@djangocfg/ui-core 2.1.294 → 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 (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
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 (28+)
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
96
+
97
+ | Hook | Description |
98
+ |------|-------------|
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
87
115
 
88
116
  | Hook | Description |
89
117
  |------|-------------|
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) |
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,6 +150,10 @@ 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
119
157
  ```
120
158
 
121
159
  ## Router Hooks
@@ -153,17 +191,19 @@ setPage((p) => p + 1); // ?page=2 — `page=1` is dropped (clearOnDefault)
153
191
 
154
192
  ### Next.js adapter
155
193
 
156
- In Next apps, mount the adapter once near the root so navigation flows through `next/navigation` (server components + prefetch keep working):
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`:
157
195
 
158
196
  ```tsx
159
- import { NextRouterAdapter } from '@djangocfg/ui-core/adapters/nextjs';
197
+ import { NextRouterAdapter, NextLinkProvider } from '@djangocfg/ui-core/adapters/nextjs';
160
198
 
161
199
  <NextRouterAdapter>
162
- <App />
200
+ <NextLinkProvider>
201
+ <App />
202
+ </NextLinkProvider>
163
203
  </NextRouterAdapter>
164
204
  ```
165
205
 
166
- `next` is an **optional peer dependency** — Wails / Electron / Vite consumers don't pull it in.
206
+ `next` is an **optional peer dependency** — Wails / Electron / Vite consumers don't pull it in. `@djangocfg/layouts/BaseApp` mounts both adapters automatically.
167
207
 
168
208
  ## Theme Palette Hooks
169
209
 
@@ -338,7 +378,7 @@ import '@djangocfg/ui-core/styles/globals';
338
378
  | `@djangocfg/ui-core` | All components & hooks |
339
379
  | `@djangocfg/ui-core/components` | Components only |
340
380
  | `@djangocfg/ui-core/hooks` | Hooks only (incl. router hooks) |
341
- | `@djangocfg/ui-core/adapters/nextjs` | `<NextRouterAdapter>` for Next.js apps (optional peer: `next`) |
381
+ | `@djangocfg/ui-core/adapters/nextjs` | `<NextRouterAdapter>` + `<NextLinkProvider>` for Next.js apps (optional peer: `next`) |
342
382
  | `@djangocfg/ui-core/lib` | Utilities (cn, etc.) |
343
383
  | `@djangocfg/ui-core/lib/dialog-service` | Dialog service |
344
384
  | `@djangocfg/ui-core/utils` | Runtime utilities (emitRuntimeError) |
@@ -359,15 +399,29 @@ try {
359
399
  }
360
400
  ```
361
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
+
362
418
  ## What's NOT included (use ui-nextjs)
363
419
 
364
420
  These features require Next.js or browser storage APIs:
365
421
 
366
- - `Sidebar` — uses next/link
367
- - `Breadcrumb`, `BreadcrumbNavigation` — uses next/link
368
- - `NavigationMenu`, `Menubar` — uses next/link
369
- - `Pagination`, `SSRPagination` — uses next/link
370
- - `DropdownMenu` — uses next/link
422
+ - `Sidebar` — `'use client'` heavy, lives in ui-nextjs
423
+ - `Breadcrumb`, `BreadcrumbNavigation` — same
424
+ - `Pagination`, `SSRPagination` — same
371
425
  - `DownloadButton` — uses localStorage
372
426
  - `useTheme` — uses next-themes
373
427
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-core",
3
- "version": "2.1.294",
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",
@@ -91,7 +91,7 @@
91
91
  "playground": "playground dev"
92
92
  },
93
93
  "peerDependencies": {
94
- "@djangocfg/i18n": "^2.1.294",
94
+ "@djangocfg/i18n": "^2.1.297",
95
95
  "consola": "^3.4.2",
96
96
  "lucide-react": "^0.545.0",
97
97
  "moment": "^2.30.1",
@@ -159,9 +159,9 @@
159
159
  "vaul": "1.1.2"
160
160
  },
161
161
  "devDependencies": {
162
- "@djangocfg/i18n": "^2.1.294",
162
+ "@djangocfg/i18n": "^2.1.297",
163
163
  "@djangocfg/playground": "workspace:*",
164
- "@djangocfg/typescript-config": "^2.1.294",
164
+ "@djangocfg/typescript-config": "^2.1.297",
165
165
  "@types/node": "^24.7.2",
166
166
  "@types/react": "^19.1.0",
167
167
  "@types/react-dom": "^19.1.0",
@@ -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,
@@ -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/useStoredValue';
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/useStoredValue';
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/useImageLoader';
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,3 @@
1
+ 'use client';
2
+
3
+ export { useDebugTools } from './useDebugTools';
@@ -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';