@djangocfg/layouts 2.1.274 → 2.1.276

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 (40) hide show
  1. package/README.md +52 -180
  2. package/package.json +18 -18
  3. package/src/layouts/AppLayout/AppLayout.tsx +14 -14
  4. package/src/layouts/PublicLayout/README.md +144 -0
  5. package/src/layouts/PublicLayout/{components/PublicFooter/PublicFooter.tsx → footers/DefaultFooter/DefaultFooter.tsx} +14 -8
  6. package/src/layouts/PublicLayout/{components/PublicFooter → footers/DefaultFooter}/DjangoCFGLogo.tsx +0 -6
  7. package/src/layouts/PublicLayout/{components/PublicFooter → footers/DefaultFooter}/FooterBottom.tsx +0 -4
  8. package/src/layouts/PublicLayout/{components/PublicFooter → footers/DefaultFooter}/FooterMenuSections.tsx +0 -4
  9. package/src/layouts/PublicLayout/{components/PublicFooter → footers/DefaultFooter}/FooterProjectInfo.tsx +0 -4
  10. package/src/layouts/PublicLayout/{components/PublicFooter → footers/DefaultFooter}/FooterSocialLinks.tsx +0 -5
  11. package/src/layouts/PublicLayout/{components/PublicFooter → footers/DefaultFooter}/index.ts +2 -12
  12. package/src/layouts/PublicLayout/{components/PublicFooter → footers/DefaultFooter}/types.ts +21 -26
  13. package/src/layouts/PublicLayout/footers/index.ts +1 -0
  14. package/src/layouts/PublicLayout/hooks/index.ts +1 -0
  15. package/src/layouts/PublicLayout/hooks/useResponsiveOverflow.ts +140 -0
  16. package/src/layouts/PublicLayout/index.ts +22 -22
  17. package/src/layouts/PublicLayout/navbarTypes.ts +27 -4
  18. package/src/layouts/PublicLayout/navbars/FloatingNavbar/FloatingMobileDrawer.tsx +29 -0
  19. package/src/layouts/PublicLayout/navbars/FloatingNavbar/FloatingNavbar.tsx +117 -0
  20. package/src/layouts/PublicLayout/navbars/FloatingNavbar/index.ts +3 -0
  21. package/src/layouts/PublicLayout/navbars/FlushNavbar/FlushMobileDrawer.tsx +19 -0
  22. package/src/layouts/PublicLayout/navbars/FlushNavbar/FlushNavbar.tsx +112 -0
  23. package/src/layouts/PublicLayout/navbars/FlushNavbar/index.ts +3 -0
  24. package/src/layouts/PublicLayout/navbars/MinimalNavbar/MinimalMobileDrawer.tsx +19 -0
  25. package/src/layouts/PublicLayout/navbars/MinimalNavbar/MinimalNavbar.tsx +169 -0
  26. package/src/layouts/PublicLayout/navbars/MinimalNavbar/index.ts +3 -0
  27. package/src/layouts/PublicLayout/navbars/index.ts +3 -0
  28. package/src/layouts/PublicLayout/primitives/NavActionItem.tsx +94 -0
  29. package/src/layouts/PublicLayout/{components → primitives}/NavActions.tsx +26 -1
  30. package/src/layouts/PublicLayout/{components → primitives}/NavDesktopItems.tsx +100 -56
  31. package/src/layouts/PublicLayout/{components → primitives}/ThemeBrandMark.tsx +0 -8
  32. package/src/layouts/PublicLayout/primitives/index.ts +7 -0
  33. package/src/layouts/PublicLayout/shared/MobileDrawerShell.tsx +205 -0
  34. package/src/layouts/PublicLayout/shared/NavbarShell.tsx +295 -0
  35. package/src/layouts/PublicLayout/shared/index.ts +4 -0
  36. package/src/layouts/PublicLayout/components/PublicMobileDrawer.tsx +0 -211
  37. package/src/layouts/PublicLayout/components/PublicNavbar.tsx +0 -99
  38. package/src/layouts/PublicLayout/components/PublicNavigation.tsx +0 -287
  39. package/src/layouts/PublicLayout/components/index.ts +0 -11
  40. /package/src/layouts/PublicLayout/{components → primitives}/NavBrand.tsx +0 -0
@@ -0,0 +1,112 @@
1
+ /**
2
+ * FlushNavbar — edge-to-edge bar with a subtle bottom border.
3
+ *
4
+ * Pairs with FlushMobileDrawer (no rounding/shadow).
5
+ */
6
+
7
+ 'use client';
8
+
9
+ import React from 'react';
10
+
11
+ import { cn } from '@djangocfg/ui-core/lib';
12
+
13
+ import type { NavAction } from '../../primitives/NavActionItem';
14
+ import { NavbarShell } from '../../shared';
15
+ import type {
16
+ PublicDesktopDropdownRenderer,
17
+ PublicNavbarHeight,
18
+ PublicNavbarPosition,
19
+ PublicNavbarShellConfig,
20
+ PublicNavLayout,
21
+ } from '../../navbarTypes';
22
+ import type { NavigationItem, UserMenuConfig } from '../../../types';
23
+
24
+ import { FlushMobileDrawer } from './FlushMobileDrawer';
25
+
26
+ export interface FlushNavbarConfig {
27
+ shell?: PublicNavbarShellConfig;
28
+ brand?: React.ReactNode;
29
+ /** @default '/' */
30
+ brandHref?: string;
31
+ navigation?: NavigationItem[];
32
+ userMenu?: UserMenuConfig;
33
+ /** @default 'sticky' */
34
+ navbarPosition?: PublicNavbarPosition;
35
+ renderDesktopDropdown?: PublicDesktopDropdownRenderer;
36
+ desktopMaxPrimaryItems?: number;
37
+ /** @default 'default' */
38
+ navLayout?: PublicNavLayout;
39
+ /** @default 'md' */
40
+ navbarHeight?: PublicNavbarHeight;
41
+ /** @default false */
42
+ hideNavOnScroll?: boolean;
43
+ /** @default false */
44
+ transparent?: boolean;
45
+ /** @default 40 */
46
+ transparentThreshold?: number;
47
+ /** Typed CTA pills (Book a demo / Get started / …) before UserMenu. */
48
+ actions?: NavAction[];
49
+ /** Arbitrary ReactNode between actions and UserMenu. */
50
+ actionsLeadingSlot?: React.ReactNode;
51
+ /** Arbitrary ReactNode after the mobile toggle. */
52
+ actionsTrailingSlot?: React.ReactNode;
53
+ }
54
+
55
+ export interface FlushNavbarProps {
56
+ config: FlushNavbarConfig;
57
+ }
58
+
59
+ export function FlushNavbar({ config }: FlushNavbarProps) {
60
+ const navigation = config.navigation ?? [];
61
+ const containerClassName = config.shell?.className;
62
+ const position = config.navbarPosition ?? 'sticky';
63
+
64
+ const outerClassName = cn(
65
+ position === 'fixed' ? 'fixed' : position === 'static' ? 'static' : 'sticky',
66
+ 'top-0',
67
+ );
68
+
69
+ const shapeClassName = cn(
70
+ 'mx-auto w-full',
71
+ 'rounded-none border-x-0 border-t-0 border-b border-border/40 dark:border-border/70 shadow-none',
72
+ containerClassName,
73
+ );
74
+
75
+ return (
76
+ <>
77
+ <NavbarShell
78
+ variant="flush"
79
+ position={position}
80
+ brand={config.brand}
81
+ brandHref={config.brandHref}
82
+ navigation={navigation}
83
+ userMenu={config.userMenu}
84
+ renderDesktopDropdown={config.renderDesktopDropdown}
85
+ desktopMaxPrimaryItems={config.desktopMaxPrimaryItems}
86
+ navLayout={config.navLayout}
87
+ navbarHeight={config.navbarHeight}
88
+ hideNavOnScroll={config.hideNavOnScroll}
89
+ transparent={config.transparent}
90
+ transparentThreshold={config.transparentThreshold}
91
+ actions={config.actions}
92
+ actionsLeadingSlot={config.actionsLeadingSlot}
93
+ actionsTrailingSlot={config.actionsTrailingSlot}
94
+ outerClassName={outerClassName}
95
+ shapeClassName={shapeClassName}
96
+ shapeForState={({ scrolled, transparent }) =>
97
+ cn(
98
+ transparent && 'transition-[background-color,backdrop-filter] duration-200 ease-out',
99
+ !transparent || scrolled
100
+ ? 'bg-background/72 backdrop-blur-[10px] dark:bg-card/80'
101
+ : 'bg-transparent backdrop-blur-0 dark:bg-transparent',
102
+ )
103
+ }
104
+ />
105
+ <FlushMobileDrawer
106
+ navigation={navigation}
107
+ userMenu={config.userMenu}
108
+ containerClassName={containerClassName}
109
+ />
110
+ </>
111
+ );
112
+ }
@@ -0,0 +1,3 @@
1
+ export { FlushNavbar } from './FlushNavbar';
2
+ export type { FlushNavbarConfig, FlushNavbarProps } from './FlushNavbar';
3
+ export { FlushMobileDrawer } from './FlushMobileDrawer';
@@ -0,0 +1,19 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+
5
+ import { MobileDrawerShell, type MobileDrawerShellProps } from '../../shared';
6
+
7
+ type MinimalMobileDrawerProps = Omit<MobileDrawerShellProps, 'panelClassName' | 'outerClassName'> & {
8
+ containerClassName?: string;
9
+ };
10
+
11
+ export function MinimalMobileDrawer({ containerClassName, ...rest }: MinimalMobileDrawerProps) {
12
+ return (
13
+ <MobileDrawerShell
14
+ {...rest}
15
+ outerClassName={containerClassName}
16
+ panelClassName="rounded-none shadow-none border-0"
17
+ />
18
+ );
19
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * MinimalNavbar — borderless, transparent-by-default, eesel-style.
3
+ *
4
+ * Differences from Floating/Flush:
5
+ * - no shell border/shadow, no rounded chrome
6
+ * - default container: max-w-[1400px], px-4 lg:px-10
7
+ * - right-hand side: pill CTAs + UserMenu + mobile toggle (no default UserMenu-only)
8
+ * - transparent at top of page by default; solid + backdrop-blur after scroll
9
+ */
10
+
11
+ 'use client';
12
+
13
+ import { Menu, X } from 'lucide-react';
14
+ import React, { type ReactNode } from 'react';
15
+
16
+ import { Button } from '@djangocfg/ui-core/components';
17
+ import { cn } from '@djangocfg/ui-core/lib';
18
+
19
+ import { UserMenu } from '../../../_components/UserMenu';
20
+ import { NavActionItem, type NavAction } from '../../primitives/NavActionItem';
21
+ import { NavbarShell, type NavbarActionsContext } from '../../shared';
22
+ import type {
23
+ PublicDesktopDropdownRenderer,
24
+ PublicNavbarHeight,
25
+ PublicNavbarPosition,
26
+ PublicNavLayout,
27
+ } from '../../navbarTypes';
28
+ import type { NavigationItem, UserMenuConfig } from '../../../types';
29
+
30
+ import { MinimalMobileDrawer } from './MinimalMobileDrawer';
31
+
32
+ /**
33
+ * @deprecated Use `NavAction` from `@djangocfg/layouts`. Kept as an alias so
34
+ * existing imports keep working.
35
+ */
36
+ export type MinimalNavbarAction = NavAction;
37
+
38
+ export interface MinimalNavbarConfig {
39
+ brand?: ReactNode;
40
+ /** @default '/' */
41
+ brandHref?: string;
42
+ navigation?: NavigationItem[];
43
+ userMenu?: UserMenuConfig;
44
+ /** Right-hand CTA pills. Rendered before UserMenu / mobile toggle. */
45
+ actions?: NavAction[];
46
+
47
+ /** @default 'sticky' */
48
+ navbarPosition?: PublicNavbarPosition;
49
+ renderDesktopDropdown?: PublicDesktopDropdownRenderer;
50
+ desktopMaxPrimaryItems?: number;
51
+ /** @default 'default' */
52
+ navLayout?: PublicNavLayout;
53
+ /** @default 'md' */
54
+ navbarHeight?: PublicNavbarHeight;
55
+ /** @default false */
56
+ hideNavOnScroll?: boolean;
57
+ /**
58
+ * Transparent at page top, opaque after `transparentThreshold`.
59
+ * @default true
60
+ */
61
+ transparent?: boolean;
62
+ /** @default 40 */
63
+ transparentThreshold?: number;
64
+
65
+ /**
66
+ * Outer wrapper container classes — e.g. `mx-auto max-w-[1400px] px-4 nav:px-10`.
67
+ * @default 'mx-auto max-w-[1400px] px-4 sm:px-6 lg:px-10'
68
+ */
69
+ containerClassName?: string;
70
+ }
71
+
72
+ export interface MinimalNavbarProps {
73
+ config: MinimalNavbarConfig;
74
+ }
75
+
76
+ function MinimalActions({
77
+ ctx,
78
+ actions = [],
79
+ }: {
80
+ ctx: NavbarActionsContext;
81
+ actions?: NavAction[];
82
+ }) {
83
+ return (
84
+ <div className="flex shrink-0 items-center gap-4">
85
+ {actions.length > 0 && (
86
+ <div className="flex shrink-0 items-center gap-1.5">
87
+ {actions.map((a) => (
88
+ <NavActionItem key={`${a.label}-${a.href}`} action={a} />
89
+ ))}
90
+ </div>
91
+ )}
92
+
93
+ <div className="hidden lg:flex">
94
+ <UserMenu
95
+ variant="desktop"
96
+ groups={ctx.userMenu?.groups}
97
+ authPath={ctx.userMenu?.authPath}
98
+ i18n={ctx.userMenu?.i18n}
99
+ />
100
+ </div>
101
+
102
+ <Button
103
+ variant="ghost"
104
+ size="icon"
105
+ aria-label={ctx.toggleMobileLabel}
106
+ data-mobile-menu-trigger="true"
107
+ className={cn(
108
+ 'rounded-full text-foreground/90 hover:bg-foreground/10',
109
+ ctx.navLayout === 'split' ? '' : 'lg:hidden',
110
+ )}
111
+ onClick={ctx.toggleMobileMenu}
112
+ >
113
+ {ctx.mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
114
+ </Button>
115
+ </div>
116
+ );
117
+ }
118
+
119
+ export function MinimalNavbar({ config }: MinimalNavbarProps) {
120
+ const navigation = config.navigation ?? [];
121
+ const position = config.navbarPosition ?? 'sticky';
122
+ const transparent = config.transparent ?? true;
123
+ const containerClassName = config.containerClassName ?? 'mx-auto max-w-[1400px] px-4 sm:px-6 lg:px-10';
124
+
125
+ const outerClassName = cn(
126
+ position === 'fixed' ? 'fixed' : position === 'static' ? 'static' : 'sticky',
127
+ 'top-0',
128
+ );
129
+
130
+ // No border, no rounding, no shadow. Full-bleed.
131
+ const shapeClassName = 'w-full rounded-none border-0 shadow-none';
132
+
133
+ return (
134
+ <>
135
+ <NavbarShell
136
+ variant="minimal"
137
+ position={position}
138
+ brand={config.brand}
139
+ brandHref={config.brandHref}
140
+ navigation={navigation}
141
+ userMenu={config.userMenu}
142
+ renderDesktopDropdown={config.renderDesktopDropdown}
143
+ desktopMaxPrimaryItems={config.desktopMaxPrimaryItems}
144
+ navLayout={config.navLayout}
145
+ navbarHeight={config.navbarHeight}
146
+ hideNavOnScroll={config.hideNavOnScroll}
147
+ transparent={transparent}
148
+ transparentThreshold={config.transparentThreshold}
149
+ outerClassName={outerClassName}
150
+ shapeClassName={shapeClassName}
151
+ innerPadding={containerClassName}
152
+ shapeForState={({ scrolled }) =>
153
+ cn(
154
+ 'transition-[background-color,backdrop-filter] duration-200 ease-out',
155
+ transparent && !scrolled
156
+ ? 'bg-transparent backdrop-blur-0'
157
+ : 'bg-background/72 backdrop-blur-[10px] dark:bg-card/80',
158
+ )
159
+ }
160
+ renderActions={(ctx) => <MinimalActions ctx={ctx} actions={config.actions} />}
161
+ />
162
+ <MinimalMobileDrawer
163
+ navigation={navigation}
164
+ userMenu={config.userMenu}
165
+ containerClassName={containerClassName}
166
+ />
167
+ </>
168
+ );
169
+ }
@@ -0,0 +1,3 @@
1
+ export { MinimalNavbar } from './MinimalNavbar';
2
+ export type { MinimalNavbarConfig, MinimalNavbarProps, MinimalNavbarAction } from './MinimalNavbar';
3
+ export { MinimalMobileDrawer } from './MinimalMobileDrawer';
@@ -0,0 +1,3 @@
1
+ export * from './FloatingNavbar';
2
+ export * from './FlushNavbar';
3
+ export * from './MinimalNavbar';
@@ -0,0 +1,94 @@
1
+ /**
2
+ * NavAction — shared CTA primitive used by every PublicLayout navbar.
3
+ *
4
+ * Previously lived on MinimalNavbar only. Promoted so Floating/Flush can accept
5
+ * the same typed CTA pills (Book a demo / Get started …) without a fork.
6
+ */
7
+
8
+ 'use client';
9
+
10
+ import Link from 'next/link';
11
+ import React, { type ReactNode } from 'react';
12
+
13
+ import { cn } from '@djangocfg/ui-core/lib';
14
+
15
+ export interface NavAction {
16
+ label: string;
17
+ href: string;
18
+ /** Open in a new tab. @default false */
19
+ external?: boolean;
20
+ /**
21
+ * Visual style.
22
+ * - `link` — plain text link
23
+ * - `ghost` — transparent pill, fills on hover (default)
24
+ * - `outline` — bordered pill
25
+ * - `primary` — solid high-contrast pill (main CTA)
26
+ * @default 'ghost'
27
+ */
28
+ variant?: 'link' | 'ghost' | 'outline' | 'primary';
29
+ /** Hide on screens below lg (useful for secondary CTAs). @default false */
30
+ hideOnSmall?: boolean;
31
+ /** Optional inline icon (use lucide-react or any ReactNode). */
32
+ icon?: ReactNode;
33
+ /** Custom click handler (still navigates via href unless preventDefault'd). */
34
+ onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
35
+ }
36
+
37
+ const baseCls =
38
+ 'inline-flex shrink-0 items-center justify-center gap-1.5 rounded-full text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/35';
39
+
40
+ const variantCls: Record<NonNullable<NavAction['variant']>, string> = {
41
+ link:
42
+ 'px-1.5 py-1 text-foreground/85 hover:text-foreground',
43
+ ghost:
44
+ 'px-4 py-1.5 min-h-9 text-foreground/90 hover:bg-accent/55 hover:text-foreground',
45
+ outline:
46
+ 'px-4 py-1.5 min-h-9 border border-border/70 text-foreground/90 hover:bg-accent/40 hover:text-foreground',
47
+ primary:
48
+ 'px-4 py-1.5 min-h-9 bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm',
49
+ };
50
+
51
+ interface NavActionItemProps {
52
+ action: NavAction;
53
+ className?: string;
54
+ }
55
+
56
+ export function NavActionItem({ action, className }: NavActionItemProps) {
57
+ const variant = action.variant ?? 'ghost';
58
+ const cls = cn(
59
+ baseCls,
60
+ variantCls[variant],
61
+ action.hideOnSmall && 'hidden lg:inline-flex',
62
+ className,
63
+ );
64
+
65
+ const content = (
66
+ <>
67
+ {action.icon && (
68
+ <span className="inline-flex size-3.5 shrink-0 items-center justify-center" aria-hidden>
69
+ {action.icon}
70
+ </span>
71
+ )}
72
+ <span className="truncate">{action.label}</span>
73
+ </>
74
+ );
75
+
76
+ if (action.external) {
77
+ return (
78
+ <a
79
+ href={action.href}
80
+ target="_blank"
81
+ rel="noopener noreferrer"
82
+ className={cls}
83
+ onClick={action.onClick}
84
+ >
85
+ {content}
86
+ </a>
87
+ );
88
+ }
89
+ return (
90
+ <Link href={action.href} className={cls} onClick={action.onClick}>
91
+ {content}
92
+ </Link>
93
+ );
94
+ }
@@ -1,13 +1,15 @@
1
1
  'use client';
2
2
 
3
3
  import { Menu, X } from 'lucide-react';
4
- import React from 'react';
4
+ import React, { type ReactNode } from 'react';
5
5
 
6
6
  import { Button } from '@djangocfg/ui-core/components';
7
7
 
8
8
  import { UserMenu } from '../../_components/UserMenu';
9
9
  import type { UserMenuConfig } from '../../types';
10
10
 
11
+ import { NavActionItem, type NavAction } from './NavActionItem';
12
+
11
13
  interface NavActionsProps {
12
14
  userMenu?: UserMenuConfig;
13
15
  mobileMenuOpen: boolean;
@@ -15,6 +17,12 @@ interface NavActionsProps {
15
17
  toggleMobileLabel: string;
16
18
  /** When true, mobile trigger is always visible (not hidden on lg+). Used for `split` layout. */
17
19
  forceShowMobileTrigger?: boolean;
20
+ /** Typed CTA pills rendered before UserMenu (desktop only). */
21
+ actions?: NavAction[];
22
+ /** Arbitrary slot rendered between actions and UserMenu (desktop only). */
23
+ leadingSlot?: ReactNode;
24
+ /** Arbitrary slot rendered after the mobile toggle (desktop + mobile). */
25
+ trailingSlot?: ReactNode;
18
26
  }
19
27
 
20
28
  export function NavActions({
@@ -23,9 +31,24 @@ export function NavActions({
23
31
  onMobileMenuToggle,
24
32
  toggleMobileLabel,
25
33
  forceShowMobileTrigger = false,
34
+ actions,
35
+ leadingSlot,
36
+ trailingSlot,
26
37
  }: NavActionsProps) {
38
+ const hasActions = actions && actions.length > 0;
39
+
27
40
  return (
28
41
  <div className="flex items-center gap-4">
42
+ {hasActions && (
43
+ <div className="hidden lg:flex shrink-0 items-center gap-1.5">
44
+ {actions!.map((a) => (
45
+ <NavActionItem key={`${a.label}-${a.href}`} action={a} />
46
+ ))}
47
+ </div>
48
+ )}
49
+
50
+ {leadingSlot && <div className="hidden lg:flex shrink-0 items-center">{leadingSlot}</div>}
51
+
29
52
  <div className="hidden lg:flex">
30
53
  <UserMenu
31
54
  variant="desktop"
@@ -45,6 +68,8 @@ export function NavActions({
45
68
  >
46
69
  {mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
47
70
  </Button>
71
+
72
+ {trailingSlot && <div className="shrink-0 flex items-center">{trailingSlot}</div>}
48
73
  </div>
49
74
  );
50
75
  }
@@ -9,11 +9,14 @@ import { cn } from '@djangocfg/ui-core/lib';
9
9
 
10
10
  import type { NavigationItem } from '../../types';
11
11
  import type { UseDropdownMenuReturn } from '../hooks/useDropdownMenu';
12
- import type { PublicDesktopDropdownRenderer } from './PublicNavigation';
12
+ import { useResponsiveOverflow } from '../hooks/useResponsiveOverflow';
13
+ import type { PublicDesktopDropdownRenderer } from '../navbarTypes';
13
14
 
14
15
  interface NavDesktopItemsProps {
15
- primaryItems: NavigationItem[];
16
- overflowItems: NavigationItem[];
16
+ /** Full ordered item list; overflow is computed responsively. */
17
+ items: NavigationItem[];
18
+ /** Optional hard cap — measurer will never show more than this. */
19
+ maxVisible?: number;
17
20
  isActivePath: (href: string) => boolean;
18
21
  isGroupActive: (item: NavigationItem) => boolean;
19
22
  dropdown: UseDropdownMenuReturn;
@@ -29,7 +32,7 @@ const navItemCls = cn(
29
32
  );
30
33
 
31
34
  const navItemActiveCls =
32
- 'border-0 bg-accent font-semibold text-foreground shadow-sm dark:border dark:border-border dark:bg-muted dark:shadow-none';
35
+ 'border-0 bg-accent font-semibold text-foreground shadow-sm dark:bg-muted dark:shadow-none';
33
36
 
34
37
  const labelCls = 'min-w-0 max-w-[11rem] truncate sm:max-w-[13rem]';
35
38
 
@@ -38,7 +41,7 @@ function subMenuLinkCls(active: boolean) {
38
41
  'flex min-h-9 min-w-0 max-w-[min(17rem,calc(100vw-5rem))] items-center rounded-full border-0 px-4 py-2 text-sm font-medium transition-colors',
39
42
  'hover:bg-accent/55',
40
43
  active
41
- ? 'border-0 bg-accent font-semibold text-foreground shadow-sm dark:border dark:border-border dark:bg-muted/90 dark:shadow-none'
44
+ ? 'border-0 bg-accent font-semibold text-foreground shadow-sm dark:bg-muted/90 dark:shadow-none'
42
45
  : 'border-0 text-foreground/90',
43
46
  );
44
47
  }
@@ -47,8 +50,8 @@ const popoverCls =
47
50
  'absolute left-0 top-full mt-1 z-[1200] min-w-[14.5rem] rounded-xl border border-border/70 bg-background/95 backdrop-blur-sm p-1.5 shadow-[0_1px_2px_rgba(0,0,0,0.05),0_6px_18px_rgba(0,0,0,0.035)] dark:shadow-[0_6px_20px_rgba(0,0,0,0.12)]';
48
51
 
49
52
  export function NavDesktopItems({
50
- primaryItems,
51
- overflowItems,
53
+ items,
54
+ maxVisible,
52
55
  isActivePath,
53
56
  isGroupActive,
54
57
  dropdown,
@@ -56,6 +59,16 @@ export function NavDesktopItems({
56
59
  }: NavDesktopItemsProps) {
57
60
  const { openDropdownKey, scheduleOpen, scheduleClose, closeDropdown } = dropdown;
58
61
 
62
+ const { visibleCount, containerRef, itemRef, measured } = useResponsiveOverflow({
63
+ total: items.length,
64
+ moreWidth: 88,
65
+ gap: 4,
66
+ });
67
+
68
+ const effectiveCount = Math.min(visibleCount, maxVisible ?? visibleCount);
69
+ const primaryItems = items.slice(0, effectiveCount);
70
+ const overflowItems = items.slice(effectiveCount);
71
+
59
72
  const renderItem = (item: NavigationItem) => {
60
73
  if (item.items && item.items.length > 0) {
61
74
  const key = `${item.label}-${item.href}`;
@@ -112,7 +125,6 @@ export function NavDesktopItems({
112
125
  'group h-auto min-h-9 max-w-[15rem] gap-1 shadow-none [&_svg]:size-3.5 [&_svg]:shrink-0',
113
126
  navItemCls,
114
127
  (isOpen || isActive) && navItemActiveCls,
115
- isOpen && 'border-0 dark:border dark:border-border',
116
128
  )}
117
129
  >
118
130
  <span className={labelCls} title={item.label}>{item.label}</span>
@@ -152,56 +164,88 @@ export function NavDesktopItems({
152
164
  const moreActive = isMoreOpen || overflowItems.some((i) => isGroupActive(i));
153
165
 
154
166
  return (
155
- <>
156
- {primaryItems.map(renderItem)}
157
-
158
- {hasOverflow && (
159
- <div
160
- className="relative"
161
- onMouseEnter={() => scheduleOpen('__overflow-more')}
162
- onMouseLeave={() => scheduleClose('__overflow-more')}
163
- >
164
- <Button
165
- variant="ghost"
166
- size="sm"
167
- className={cn(
168
- 'group h-auto min-h-9 max-w-[15rem] gap-1 shadow-none [&_svg]:size-3.5 [&_svg]:shrink-0',
169
- navItemCls,
170
- moreActive && navItemActiveCls,
171
- isMoreOpen && 'border-0 dark:border dark:border-border',
172
- )}
167
+ <div
168
+ ref={containerRef as React.Ref<HTMLDivElement>}
169
+ className="relative flex min-w-0 flex-1 items-center justify-center gap-1"
170
+ >
171
+ {/* Measurement layer: invisible, absolutely positioned, holds width-only copies. */}
172
+ <div
173
+ aria-hidden
174
+ className="pointer-events-none absolute inset-y-0 left-0 flex items-center gap-1 opacity-0"
175
+ style={{ visibility: 'hidden', whiteSpace: 'nowrap' }}
176
+ >
177
+ {items.map((item, i) => (
178
+ <span
179
+ key={`m-${item.label}-${item.href}`}
180
+ ref={itemRef(i) as React.Ref<HTMLSpanElement>}
181
+ className={cn(navItemCls, 'pointer-events-none')}
173
182
  >
174
- <span className={labelCls}>More</span>
175
- <span className="inline-flex size-3.5 shrink-0 items-center justify-center" aria-hidden>
176
- <ChevronDown
177
- className={cn(
178
- 'size-3.5 origin-center text-muted-foreground transition-transform duration-200 ease-out will-change-transform',
179
- isMoreOpen && 'rotate-180',
180
- )}
181
- />
182
- </span>
183
- </Button>
183
+ <span className={labelCls}>{item.label}</span>
184
+ {item.items && item.items.length > 0 && (
185
+ <span className="inline-flex size-3.5 shrink-0 items-center justify-center">
186
+ <ChevronDown className="size-3.5" />
187
+ </span>
188
+ )}
189
+ </span>
190
+ ))}
191
+ </div>
192
+
193
+ {/* Live row — only renders items that fit (hidden until first measurement). */}
194
+ <div
195
+ className={cn(
196
+ 'flex min-w-0 items-center gap-1',
197
+ !measured && 'invisible',
198
+ )}
199
+ >
200
+ {primaryItems.map(renderItem)}
184
201
 
185
- {isMoreOpen && (
186
- <div
187
- className={cn(popoverCls, 'left-auto right-0')}
188
- onMouseEnter={() => { scheduleOpen('__overflow-more'); }}
189
- onMouseLeave={() => scheduleClose('__overflow-more')}
202
+ {hasOverflow && (
203
+ <div
204
+ className="relative"
205
+ onMouseEnter={() => scheduleOpen('__overflow-more')}
206
+ onMouseLeave={() => scheduleClose('__overflow-more')}
207
+ >
208
+ <Button
209
+ variant="ghost"
210
+ size="sm"
211
+ className={cn(
212
+ 'group h-auto min-h-9 max-w-[15rem] gap-1 shadow-none [&_svg]:size-3.5 [&_svg]:shrink-0',
213
+ navItemCls,
214
+ moreActive && navItemActiveCls,
215
+ )}
190
216
  >
191
- {overflowItems.map((item) => {
192
- const active = isGroupActive(item);
193
- return (
194
- <div key={`overflow-${item.href}`} className="rounded-full">
195
- <Link href={item.href} className={subMenuLinkCls(active)}>
196
- <span className="min-w-0 truncate" title={item.label}>{item.label}</span>
197
- </Link>
198
- </div>
199
- );
200
- })}
201
- </div>
202
- )}
203
- </div>
204
- )}
205
- </>
217
+ <span className={labelCls}>More</span>
218
+ <span className="inline-flex size-3.5 shrink-0 items-center justify-center" aria-hidden>
219
+ <ChevronDown
220
+ className={cn(
221
+ 'size-3.5 origin-center text-muted-foreground transition-transform duration-200 ease-out will-change-transform',
222
+ isMoreOpen && 'rotate-180',
223
+ )}
224
+ />
225
+ </span>
226
+ </Button>
227
+
228
+ {isMoreOpen && (
229
+ <div
230
+ className={cn(popoverCls, 'left-auto right-0')}
231
+ onMouseEnter={() => { scheduleOpen('__overflow-more'); }}
232
+ onMouseLeave={() => scheduleClose('__overflow-more')}
233
+ >
234
+ {overflowItems.map((item) => {
235
+ const active = isGroupActive(item);
236
+ return (
237
+ <div key={`overflow-${item.href}`} className="rounded-full">
238
+ <Link href={item.href} className={subMenuLinkCls(active)}>
239
+ <span className="min-w-0 truncate" title={item.label}>{item.label}</span>
240
+ </Link>
241
+ </div>
242
+ );
243
+ })}
244
+ </div>
245
+ )}
246
+ </div>
247
+ )}
248
+ </div>
249
+ </div>
206
250
  );
207
251
  }