@djangocfg/layouts 2.1.279 → 2.1.281
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +18 -18
- package/src/layouts/AppLayout/AppLayout.tsx +2 -10
- package/src/layouts/PrivateLayout/PrivateLayout.tsx +2 -1
- package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +1 -1
- package/src/layouts/PublicLayout/README.md +69 -1
- package/src/layouts/PublicLayout/footers/DefaultFooter/DefaultFooter.tsx +8 -7
- package/src/layouts/PublicLayout/footers/DefaultFooter/FooterBottom.tsx +4 -3
- package/src/layouts/PublicLayout/footers/DefaultFooter/FooterMenuSections.tsx +4 -3
- package/src/layouts/PublicLayout/footers/DefaultFooter/types.ts +1 -2
- package/src/layouts/PublicLayout/index.ts +7 -7
- package/src/layouts/PublicLayout/navbars/FloatingNavbar/FloatingNavbar.tsx +18 -11
- package/src/layouts/PublicLayout/navbars/FlushNavbar/FlushNavbar.tsx +18 -11
- package/src/layouts/PublicLayout/navbars/MinimalNavbar/MinimalNavbar.tsx +22 -12
- package/src/layouts/PublicLayout/primitives/LinkComponentContext.tsx +50 -0
- package/src/layouts/PublicLayout/primitives/NavActionItem.tsx +4 -3
- package/src/layouts/PublicLayout/primitives/NavActions.tsx +8 -0
- package/src/layouts/PublicLayout/primitives/NavBrand.tsx +5 -3
- package/src/layouts/PublicLayout/primitives/NavControls.tsx +114 -0
- package/src/layouts/PublicLayout/primitives/NavDesktopItems.tsx +8 -7
- package/src/layouts/PublicLayout/primitives/index.ts +9 -9
- package/src/layouts/PublicLayout/shared/MobileDrawerShell.tsx +32 -8
- package/src/layouts/PublicLayout/shared/NavbarShell.tsx +33 -0
- package/src/layouts/_components/PrivateSidebarAccount.tsx +1 -1
- package/src/layouts/types/index.ts +1 -1
- package/src/layouts/types/layout.types.ts +13 -0
- package/src/layouts/PublicLayout/primitives/ExternalPrefixesContext.tsx +0 -69
- package/src/layouts/PublicLayout/primitives/SmartNavLink.tsx +0 -81
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LinkComponentContext
|
|
3
|
+
*
|
|
4
|
+
* Dependency-injects the Link component used by navbars, footers, and mobile
|
|
5
|
+
* drawers. Apps wire in their own i18n-aware Link (e.g. next-intl's
|
|
6
|
+
* `createNavigation().Link`) so internal navigation picks up the locale
|
|
7
|
+
* prefix. Default falls back to plain `next/link`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use client';
|
|
11
|
+
|
|
12
|
+
import NextLink, { type LinkProps as NextLinkProps } from 'next/link';
|
|
13
|
+
import React, {
|
|
14
|
+
createContext,
|
|
15
|
+
useContext,
|
|
16
|
+
type AnchorHTMLAttributes,
|
|
17
|
+
type ComponentType,
|
|
18
|
+
type ReactNode,
|
|
19
|
+
} from 'react';
|
|
20
|
+
|
|
21
|
+
type AnchorProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'>;
|
|
22
|
+
|
|
23
|
+
export type LinkComponentProps = Omit<NextLinkProps, 'passHref' | 'legacyBehavior'> &
|
|
24
|
+
AnchorProps & {
|
|
25
|
+
children?: ReactNode;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type LinkComponent = ComponentType<LinkComponentProps>;
|
|
29
|
+
|
|
30
|
+
const LinkComponentContext = createContext<LinkComponent>(NextLink as LinkComponent);
|
|
31
|
+
|
|
32
|
+
export interface LinkComponentProviderProps {
|
|
33
|
+
value: LinkComponent;
|
|
34
|
+
children: ReactNode;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function LinkComponentProvider({
|
|
38
|
+
value,
|
|
39
|
+
children,
|
|
40
|
+
}: LinkComponentProviderProps) {
|
|
41
|
+
return (
|
|
42
|
+
<LinkComponentContext.Provider value={value}>
|
|
43
|
+
{children}
|
|
44
|
+
</LinkComponentContext.Provider>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function useLinkComponent(): LinkComponent {
|
|
49
|
+
return useContext(LinkComponentContext);
|
|
50
|
+
}
|
|
@@ -11,7 +11,7 @@ import React, { type ReactNode } from 'react';
|
|
|
11
11
|
|
|
12
12
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import { useLinkComponent } from './LinkComponentContext';
|
|
15
15
|
|
|
16
16
|
export interface NavAction {
|
|
17
17
|
label: string;
|
|
@@ -55,6 +55,7 @@ interface NavActionItemProps {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
export function NavActionItem({ action, className }: NavActionItemProps) {
|
|
58
|
+
const Link = useLinkComponent();
|
|
58
59
|
const variant = action.variant ?? 'ghost';
|
|
59
60
|
const cls = cn(
|
|
60
61
|
baseCls,
|
|
@@ -88,8 +89,8 @@ export function NavActionItem({ action, className }: NavActionItemProps) {
|
|
|
88
89
|
);
|
|
89
90
|
}
|
|
90
91
|
return (
|
|
91
|
-
<
|
|
92
|
+
<Link href={action.href} className={cls} onClick={action.onClick}>
|
|
92
93
|
{content}
|
|
93
|
-
</
|
|
94
|
+
</Link>
|
|
94
95
|
);
|
|
95
96
|
}
|
|
@@ -23,6 +23,11 @@ interface NavActionsProps {
|
|
|
23
23
|
leadingSlot?: ReactNode;
|
|
24
24
|
/** Arbitrary slot rendered after the mobile toggle (desktop + mobile). */
|
|
25
25
|
trailingSlot?: ReactNode;
|
|
26
|
+
/**
|
|
27
|
+
* Theme / locale controls rendered right before UserMenu (desktop only).
|
|
28
|
+
* Built by `NavbarShell` from `config.controls` + `config.i18n`.
|
|
29
|
+
*/
|
|
30
|
+
controlsSlot?: ReactNode;
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
export function NavActions({
|
|
@@ -34,6 +39,7 @@ export function NavActions({
|
|
|
34
39
|
actions,
|
|
35
40
|
leadingSlot,
|
|
36
41
|
trailingSlot,
|
|
42
|
+
controlsSlot,
|
|
37
43
|
}: NavActionsProps) {
|
|
38
44
|
const hasActions = actions && actions.length > 0;
|
|
39
45
|
|
|
@@ -49,6 +55,8 @@ export function NavActions({
|
|
|
49
55
|
|
|
50
56
|
{leadingSlot && <div className="hidden lg:flex shrink-0 items-center">{leadingSlot}</div>}
|
|
51
57
|
|
|
58
|
+
{controlsSlot && <div className="hidden lg:flex shrink-0 items-center">{controlsSlot}</div>}
|
|
59
|
+
|
|
52
60
|
<div className="hidden lg:flex">
|
|
53
61
|
<UserMenu
|
|
54
62
|
variant="desktop"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { type ReactNode } from 'react';
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { useLinkComponent } from './LinkComponentContext';
|
|
6
6
|
|
|
7
7
|
interface NavBrandProps {
|
|
8
8
|
brand?: ReactNode;
|
|
@@ -10,16 +10,18 @@ interface NavBrandProps {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export function NavBrand({ brand, brandHref = '/' }: NavBrandProps) {
|
|
13
|
+
const Link = useLinkComponent();
|
|
14
|
+
|
|
13
15
|
if (brand == null || brand === '' || brand === false) return null;
|
|
14
16
|
|
|
15
17
|
if (typeof brand === 'string') {
|
|
16
18
|
return (
|
|
17
|
-
<
|
|
19
|
+
<Link
|
|
18
20
|
href={brandHref}
|
|
19
21
|
className="font-bold text-[15px] text-foreground hover:opacity-90 transition-opacity"
|
|
20
22
|
>
|
|
21
23
|
{brand}
|
|
22
|
-
</
|
|
24
|
+
</Link>
|
|
23
25
|
);
|
|
24
26
|
}
|
|
25
27
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Laptop, Moon, Sun } from 'lucide-react';
|
|
4
|
+
import React, { useEffect, useState } from 'react';
|
|
5
|
+
|
|
6
|
+
import { Button } from '@djangocfg/ui-core/components';
|
|
7
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
8
|
+
import { useThemeContext } from '@djangocfg/ui-nextjs/theme';
|
|
9
|
+
|
|
10
|
+
import type { I18nLayoutConfig } from '../../types';
|
|
11
|
+
import { LocaleSwitcher } from '../../_components/LocaleSwitcher';
|
|
12
|
+
|
|
13
|
+
export interface NavControlsProps {
|
|
14
|
+
/** Optional i18n config. Required to render the locale switcher. */
|
|
15
|
+
i18n?: I18nLayoutConfig;
|
|
16
|
+
/** Show the theme (light / system / dark) pill. @default false */
|
|
17
|
+
showThemeSwitcher?: boolean;
|
|
18
|
+
/** Show the locale dropdown. Requires `i18n`. @default false */
|
|
19
|
+
showLocaleSwitcher?: boolean;
|
|
20
|
+
/** Visual size. `compact` matches navbar row, `default` matches footer. @default 'compact' */
|
|
21
|
+
size?: 'compact' | 'default';
|
|
22
|
+
/** Extra classes for the outer container. */
|
|
23
|
+
className?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function ThemeModeControl({ size }: { size: 'compact' | 'default' }) {
|
|
27
|
+
const { theme, setTheme } = useThemeContext();
|
|
28
|
+
const [mounted, setMounted] = useState(false);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
setMounted(true);
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
const currentTheme = mounted ? (theme || 'system') : 'system';
|
|
35
|
+
const isActive = (value: 'system' | 'light' | 'dark') => currentTheme === value;
|
|
36
|
+
|
|
37
|
+
const btnSize = size === 'compact' ? 'h-7 w-7' : 'h-8 w-8';
|
|
38
|
+
const iconSize = size === 'compact' ? 'h-3.5 w-3.5' : 'h-4 w-4';
|
|
39
|
+
const baseItemClass = `${btnSize} rounded-full p-0 text-muted-foreground hover:text-foreground`;
|
|
40
|
+
const activeItemClass = 'bg-background/80 text-foreground shadow-sm';
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="inline-flex items-center gap-0.5 rounded-full border border-border/60 bg-muted/30 p-0.5">
|
|
44
|
+
<Button
|
|
45
|
+
type="button"
|
|
46
|
+
variant="ghost"
|
|
47
|
+
size="icon"
|
|
48
|
+
className={cn(baseItemClass, isActive('system') && activeItemClass)}
|
|
49
|
+
onClick={() => setTheme('system')}
|
|
50
|
+
aria-label="Use system theme"
|
|
51
|
+
>
|
|
52
|
+
<Laptop className={iconSize} />
|
|
53
|
+
</Button>
|
|
54
|
+
<Button
|
|
55
|
+
type="button"
|
|
56
|
+
variant="ghost"
|
|
57
|
+
size="icon"
|
|
58
|
+
className={cn(baseItemClass, isActive('light') && activeItemClass)}
|
|
59
|
+
onClick={() => setTheme('light')}
|
|
60
|
+
aria-label="Use light theme"
|
|
61
|
+
>
|
|
62
|
+
<Sun className={iconSize} />
|
|
63
|
+
</Button>
|
|
64
|
+
<Button
|
|
65
|
+
type="button"
|
|
66
|
+
variant="ghost"
|
|
67
|
+
size="icon"
|
|
68
|
+
className={cn(baseItemClass, isActive('dark') && activeItemClass)}
|
|
69
|
+
onClick={() => setTheme('dark')}
|
|
70
|
+
aria-label="Use dark theme"
|
|
71
|
+
>
|
|
72
|
+
<Moon className={iconSize} />
|
|
73
|
+
</Button>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Navbar-sized theme + locale controls. Mirrors `DefaultFooter` controls but
|
|
80
|
+
* tuned for a navbar row. Rendered by `NavActions` when `controls` are passed
|
|
81
|
+
* to a navbar config.
|
|
82
|
+
*/
|
|
83
|
+
export function NavControls({
|
|
84
|
+
i18n,
|
|
85
|
+
showThemeSwitcher = false,
|
|
86
|
+
showLocaleSwitcher = false,
|
|
87
|
+
size = 'compact',
|
|
88
|
+
className,
|
|
89
|
+
}: NavControlsProps) {
|
|
90
|
+
const renderLocale = showLocaleSwitcher && Boolean(i18n);
|
|
91
|
+
if (!showThemeSwitcher && !renderLocale) return null;
|
|
92
|
+
|
|
93
|
+
const localeBtnClass =
|
|
94
|
+
size === 'compact'
|
|
95
|
+
? 'h-8 rounded-full border-border/60 bg-muted/30 px-2.5 text-xs hover:bg-muted/40'
|
|
96
|
+
: 'h-9 rounded-full border-border/60 bg-muted/30 text-sm hover:bg-muted/40';
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div className={cn('inline-flex items-center gap-1.5', className)}>
|
|
100
|
+
{showThemeSwitcher && <ThemeModeControl size={size} />}
|
|
101
|
+
{renderLocale && i18n && (
|
|
102
|
+
<LocaleSwitcher
|
|
103
|
+
locale={i18n.locale}
|
|
104
|
+
locales={i18n.locales}
|
|
105
|
+
onChange={i18n.onLocaleChange}
|
|
106
|
+
variant="outline"
|
|
107
|
+
size={size === 'compact' ? 'sm' : 'default'}
|
|
108
|
+
showTriggerLabel={false}
|
|
109
|
+
className={localeBtnClass}
|
|
110
|
+
/>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -10,7 +10,7 @@ import type { NavigationItem } from '../../types';
|
|
|
10
10
|
import type { UseDropdownMenuReturn } from '../hooks/useDropdownMenu';
|
|
11
11
|
import { useResponsiveOverflow } from '../hooks/useResponsiveOverflow';
|
|
12
12
|
import type { PublicDesktopDropdownRenderer } from '../navbarTypes';
|
|
13
|
-
import {
|
|
13
|
+
import { useLinkComponent } from './LinkComponentContext';
|
|
14
14
|
|
|
15
15
|
interface NavDesktopItemsProps {
|
|
16
16
|
/** Full ordered item list; overflow is computed responsively. */
|
|
@@ -57,6 +57,7 @@ export function NavDesktopItems({
|
|
|
57
57
|
dropdown,
|
|
58
58
|
renderDesktopDropdown,
|
|
59
59
|
}: NavDesktopItemsProps) {
|
|
60
|
+
const Link = useLinkComponent();
|
|
60
61
|
const { openDropdownKey, scheduleOpen, scheduleClose, closeDropdown } = dropdown;
|
|
61
62
|
|
|
62
63
|
const { visibleCount, containerRef, itemRef, measured } = useResponsiveOverflow({
|
|
@@ -91,9 +92,9 @@ export function NavDesktopItems({
|
|
|
91
92
|
<span className="min-w-0 truncate" title={sub.label}>{sub.label}</span>
|
|
92
93
|
</a>
|
|
93
94
|
) : (
|
|
94
|
-
<
|
|
95
|
+
<Link href={sub.href} className={subMenuLinkCls(subActive)}>
|
|
95
96
|
<span className="min-w-0 truncate" title={sub.label}>{sub.label}</span>
|
|
96
|
-
</
|
|
97
|
+
</Link>
|
|
97
98
|
)}
|
|
98
99
|
</div>
|
|
99
100
|
);
|
|
@@ -149,13 +150,13 @@ export function NavDesktopItems({
|
|
|
149
150
|
|
|
150
151
|
const active = isActivePath(item.href);
|
|
151
152
|
return (
|
|
152
|
-
<
|
|
153
|
+
<Link
|
|
153
154
|
key={item.href}
|
|
154
155
|
href={item.href}
|
|
155
156
|
className={cn(navItemCls, active && navItemActiveCls)}
|
|
156
157
|
>
|
|
157
158
|
<span className={labelCls} title={item.label}>{item.label}</span>
|
|
158
|
-
</
|
|
159
|
+
</Link>
|
|
159
160
|
);
|
|
160
161
|
};
|
|
161
162
|
|
|
@@ -235,9 +236,9 @@ export function NavDesktopItems({
|
|
|
235
236
|
const active = isGroupActive(item);
|
|
236
237
|
return (
|
|
237
238
|
<div key={`overflow-${item.href}`} className="rounded-full">
|
|
238
|
-
<
|
|
239
|
+
<Link href={item.href} className={subMenuLinkCls(active)}>
|
|
239
240
|
<span className="min-w-0 truncate" title={item.label}>{item.label}</span>
|
|
240
|
-
</
|
|
241
|
+
</Link>
|
|
241
242
|
</div>
|
|
242
243
|
);
|
|
243
244
|
})}
|
|
@@ -2,17 +2,17 @@ export { NavBrand } from './NavBrand';
|
|
|
2
2
|
export { NavActions } from './NavActions';
|
|
3
3
|
export { NavActionItem } from './NavActionItem';
|
|
4
4
|
export type { NavAction } from './NavActionItem';
|
|
5
|
+
export { NavControls } from './NavControls';
|
|
6
|
+
export type { NavControlsProps } from './NavControls';
|
|
5
7
|
export { NavDesktopItems } from './NavDesktopItems';
|
|
6
8
|
export { ThemeBrandMark, ThemeBrandMarkImg } from './ThemeBrandMark';
|
|
7
9
|
export type { ThemeBrandMarkProps, ThemeBrandMarkImgProps } from './ThemeBrandMark';
|
|
8
10
|
export {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
} from './ExternalPrefixesContext';
|
|
11
|
+
LinkComponentProvider,
|
|
12
|
+
useLinkComponent,
|
|
13
|
+
} from './LinkComponentContext';
|
|
13
14
|
export type {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
export type { SmartNavLinkProps } from './SmartNavLink';
|
|
15
|
+
LinkComponent,
|
|
16
|
+
LinkComponentProps,
|
|
17
|
+
LinkComponentProviderProps,
|
|
18
|
+
} from './LinkComponentContext';
|
|
@@ -19,9 +19,10 @@ import { usePathnameWithoutLocale } from '../../../hooks';
|
|
|
19
19
|
import { UserMenu } from '../../_components/UserMenu';
|
|
20
20
|
import { usePublicLayoutOptional } from '../context';
|
|
21
21
|
import { useMobileNavPanel } from '../hooks';
|
|
22
|
-
import {
|
|
22
|
+
import { useLinkComponent } from '../primitives/LinkComponentContext';
|
|
23
|
+
import { NavControls } from '../primitives/NavControls';
|
|
23
24
|
|
|
24
|
-
import type { NavigationItem, UserMenuConfig } from '../../types';
|
|
25
|
+
import type { I18nLayoutConfig, NavigationItem, UserMenuConfig } from '../../types';
|
|
25
26
|
|
|
26
27
|
export interface MobileDrawerShellProps {
|
|
27
28
|
isOpen?: boolean;
|
|
@@ -32,9 +33,17 @@ export interface MobileDrawerShellProps {
|
|
|
32
33
|
outerClassName?: string;
|
|
33
34
|
/** Panel surface (bg, border, rounding, shadow). */
|
|
34
35
|
panelClassName?: string;
|
|
36
|
+
/** Optional theme/locale controls shown as a row inside the drawer footer. */
|
|
37
|
+
controls?: {
|
|
38
|
+
showThemeSwitcher?: boolean;
|
|
39
|
+
showLocaleSwitcher?: boolean;
|
|
40
|
+
};
|
|
41
|
+
/** i18n config — required for the locale switcher row. */
|
|
42
|
+
i18n?: I18nLayoutConfig;
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
export function MobileDrawerShell(props: MobileDrawerShellProps) {
|
|
46
|
+
const Link = useLinkComponent();
|
|
38
47
|
const context = usePublicLayoutOptional();
|
|
39
48
|
const mobileMenuOpen = props.isOpen ?? context?.mobileMenuOpen ?? false;
|
|
40
49
|
const closeMobileMenu = props.onClose ?? context?.closeMobileMenu ?? (() => {});
|
|
@@ -70,6 +79,10 @@ export function MobileDrawerShell(props: MobileDrawerShellProps) {
|
|
|
70
79
|
|
|
71
80
|
const hasSessionUser = Boolean(isAuthenticated && user);
|
|
72
81
|
const showSignInFooter = !hasSessionUser;
|
|
82
|
+
const showThemeSwitcher = props.controls?.showThemeSwitcher === true;
|
|
83
|
+
const showLocaleSwitcher =
|
|
84
|
+
props.controls?.showLocaleSwitcher === true && Boolean(props.i18n);
|
|
85
|
+
const showControlsRow = showThemeSwitcher || showLocaleSwitcher;
|
|
73
86
|
|
|
74
87
|
return (
|
|
75
88
|
<>
|
|
@@ -133,7 +146,7 @@ export function MobileDrawerShell(props: MobileDrawerShellProps) {
|
|
|
133
146
|
const parentOnlySectionOpen = hasChildNav && anyChildActive;
|
|
134
147
|
return (
|
|
135
148
|
<div key={item.href}>
|
|
136
|
-
<
|
|
149
|
+
<Link
|
|
137
150
|
href={item.href}
|
|
138
151
|
onClick={closeMobileMenu}
|
|
139
152
|
title={item.label}
|
|
@@ -148,13 +161,13 @@ export function MobileDrawerShell(props: MobileDrawerShellProps) {
|
|
|
148
161
|
)}
|
|
149
162
|
>
|
|
150
163
|
{item.label}
|
|
151
|
-
</
|
|
164
|
+
</Link>
|
|
152
165
|
{hasChildNav && (
|
|
153
166
|
<div className="ml-3 mt-1.5 space-y-1 border-l border-border/40 pl-3">
|
|
154
167
|
{childItems.map((subItem) => {
|
|
155
168
|
const subActive = isActivePath(subItem.href);
|
|
156
169
|
return (
|
|
157
|
-
<
|
|
170
|
+
<Link
|
|
158
171
|
key={`${item.href}-${subItem.href}`}
|
|
159
172
|
href={subItem.href}
|
|
160
173
|
onClick={closeMobileMenu}
|
|
@@ -168,7 +181,7 @@ export function MobileDrawerShell(props: MobileDrawerShellProps) {
|
|
|
168
181
|
)}
|
|
169
182
|
>
|
|
170
183
|
{subItem.label}
|
|
171
|
-
</
|
|
184
|
+
</Link>
|
|
172
185
|
);
|
|
173
186
|
})}
|
|
174
187
|
</div>
|
|
@@ -180,9 +193,20 @@ export function MobileDrawerShell(props: MobileDrawerShellProps) {
|
|
|
180
193
|
</div>
|
|
181
194
|
</div>
|
|
182
195
|
|
|
196
|
+
{showControlsRow && (
|
|
197
|
+
<div className="shrink-0 border-t border-border/50 px-4 py-3 flex items-center justify-center gap-2">
|
|
198
|
+
<NavControls
|
|
199
|
+
i18n={props.i18n}
|
|
200
|
+
showThemeSwitcher={showThemeSwitcher}
|
|
201
|
+
showLocaleSwitcher={showLocaleSwitcher}
|
|
202
|
+
size="default"
|
|
203
|
+
/>
|
|
204
|
+
</div>
|
|
205
|
+
)}
|
|
206
|
+
|
|
183
207
|
{showSignInFooter && (
|
|
184
208
|
<div className="shrink-0 border-t border-border/50 p-4">
|
|
185
|
-
<
|
|
209
|
+
<Link
|
|
186
210
|
href={userMenu?.authPath || '/auth'}
|
|
187
211
|
onClick={closeMobileMenu}
|
|
188
212
|
className="block"
|
|
@@ -194,7 +218,7 @@ export function MobileDrawerShell(props: MobileDrawerShellProps) {
|
|
|
194
218
|
aria-hidden
|
|
195
219
|
/>
|
|
196
220
|
</Button>
|
|
197
|
-
</
|
|
221
|
+
</Link>
|
|
198
222
|
</div>
|
|
199
223
|
)}
|
|
200
224
|
</div>
|
|
@@ -46,8 +46,11 @@ import type { NavigationItem, UserMenuConfig } from '../../types';
|
|
|
46
46
|
import { NavActions } from '../primitives/NavActions';
|
|
47
47
|
import type { NavAction } from '../primitives/NavActionItem';
|
|
48
48
|
import { NavBrand } from '../primitives/NavBrand';
|
|
49
|
+
import { NavControls } from '../primitives/NavControls';
|
|
49
50
|
import { NavDesktopItems } from '../primitives/NavDesktopItems';
|
|
50
51
|
|
|
52
|
+
import type { I18nLayoutConfig } from '../../types';
|
|
53
|
+
|
|
51
54
|
const heightCls: Record<PublicNavbarHeight, string> = {
|
|
52
55
|
sm: 'py-2',
|
|
53
56
|
md: 'py-3.5',
|
|
@@ -113,6 +116,17 @@ export interface NavbarShellProps {
|
|
|
113
116
|
/** Arbitrary ReactNode after the mobile toggle. */
|
|
114
117
|
actionsTrailingSlot?: ReactNode;
|
|
115
118
|
|
|
119
|
+
// ── Theme + locale controls (rendered next to UserMenu on desktop) ────────
|
|
120
|
+
/** i18n config — enables the locale switcher. Same type as `DefaultFooter`. */
|
|
121
|
+
i18n?: I18nLayoutConfig;
|
|
122
|
+
/** Toggle individual controls. Locale switcher also requires `i18n`. */
|
|
123
|
+
controls?: {
|
|
124
|
+
/** Light / system / dark pill. @default false */
|
|
125
|
+
showThemeSwitcher?: boolean;
|
|
126
|
+
/** Locale dropdown. Requires `i18n`. @default false */
|
|
127
|
+
showLocaleSwitcher?: boolean;
|
|
128
|
+
};
|
|
129
|
+
|
|
116
130
|
// ── Props override (used by variants that proxy ctx differently) ──────────
|
|
117
131
|
mobileMenuOpen?: boolean;
|
|
118
132
|
onMobileMenuToggle?: () => void;
|
|
@@ -124,6 +138,11 @@ export interface NavbarActionsContext {
|
|
|
124
138
|
toggleMobileMenu: () => void;
|
|
125
139
|
toggleMobileLabel: string;
|
|
126
140
|
navLayout: PublicNavLayout;
|
|
141
|
+
/**
|
|
142
|
+
* Pre-built theme/locale controls node. `null` when both toggles are off.
|
|
143
|
+
* Variants with a custom `renderActions` can choose where to place it.
|
|
144
|
+
*/
|
|
145
|
+
controls: ReactNode | null;
|
|
127
146
|
}
|
|
128
147
|
|
|
129
148
|
export function NavbarShell(props: NavbarShellProps) {
|
|
@@ -224,6 +243,18 @@ export function NavbarShell(props: NavbarShellProps) {
|
|
|
224
243
|
/>
|
|
225
244
|
) : null;
|
|
226
245
|
|
|
246
|
+
const showThemeSwitcher = props.controls?.showThemeSwitcher === true;
|
|
247
|
+
const showLocaleSwitcher =
|
|
248
|
+
props.controls?.showLocaleSwitcher === true && Boolean(props.i18n);
|
|
249
|
+
const hasControls = showThemeSwitcher || showLocaleSwitcher;
|
|
250
|
+
const controlsNode = hasControls ? (
|
|
251
|
+
<NavControls
|
|
252
|
+
i18n={props.i18n}
|
|
253
|
+
showThemeSwitcher={showThemeSwitcher}
|
|
254
|
+
showLocaleSwitcher={showLocaleSwitcher}
|
|
255
|
+
/>
|
|
256
|
+
) : null;
|
|
257
|
+
|
|
227
258
|
const actionsNode = props.renderActions ? (
|
|
228
259
|
props.renderActions({
|
|
229
260
|
userMenu,
|
|
@@ -231,6 +262,7 @@ export function NavbarShell(props: NavbarShellProps) {
|
|
|
231
262
|
toggleMobileMenu,
|
|
232
263
|
toggleMobileLabel,
|
|
233
264
|
navLayout,
|
|
265
|
+
controls: controlsNode,
|
|
234
266
|
})
|
|
235
267
|
) : (
|
|
236
268
|
<NavActions
|
|
@@ -242,6 +274,7 @@ export function NavbarShell(props: NavbarShellProps) {
|
|
|
242
274
|
actions={props.actions}
|
|
243
275
|
leadingSlot={props.actionsLeadingSlot}
|
|
244
276
|
trailingSlot={props.actionsTrailingSlot}
|
|
277
|
+
controlsSlot={controlsNode}
|
|
245
278
|
/>
|
|
246
279
|
);
|
|
247
280
|
|
|
@@ -27,7 +27,7 @@ import { ThemeToggle } from '@djangocfg/ui-nextjs/theme';
|
|
|
27
27
|
import { useLogout } from '../../hooks';
|
|
28
28
|
import { LocaleSwitcher } from './LocaleSwitcher';
|
|
29
29
|
|
|
30
|
-
import type { I18nLayoutConfig } from '../
|
|
30
|
+
import type { I18nLayoutConfig } from '../types';
|
|
31
31
|
import type { HeaderConfig } from '../PrivateLayout/PrivateLayout';
|
|
32
32
|
|
|
33
33
|
/** Radix portals (dropdown, select, popover) render outside the account node — ignore those clicks for “outside”. */
|
|
@@ -52,4 +52,4 @@ export type {
|
|
|
52
52
|
// Layout Types
|
|
53
53
|
// ============================================================================
|
|
54
54
|
|
|
55
|
-
export type { BaseLayoutProps, DebugConfig } from './layout.types';
|
|
55
|
+
export type { BaseLayoutProps, DebugConfig, I18nLayoutConfig } from './layout.types';
|
|
@@ -70,3 +70,16 @@ export interface DebugConfig extends DebugButtonProps {
|
|
|
70
70
|
enabled?: boolean;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* i18n configuration consumed by layouts, navbars, footer controls, and the
|
|
75
|
+
* UserMenu locale row. Same shape as `@djangocfg/nextjs`'s `useLocaleSwitcher`.
|
|
76
|
+
*/
|
|
77
|
+
export interface I18nLayoutConfig {
|
|
78
|
+
/** Current locale (e.g. `"en"`, `"ru"`, `"pt-BR"`). */
|
|
79
|
+
locale: string;
|
|
80
|
+
/** Available locales. */
|
|
81
|
+
locales: string[];
|
|
82
|
+
/** Called when the user picks a new locale. */
|
|
83
|
+
onLocaleChange: (locale: string) => void;
|
|
84
|
+
}
|
|
85
|
+
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ExternalPrefixesContext
|
|
3
|
-
*
|
|
4
|
-
* Provides a list of path prefixes that should be treated as "outside" the
|
|
5
|
-
* Next.js App Router (e.g. a catch-all served by Nextra). Consumers inside the
|
|
6
|
-
* navbar (`SmartNavLink`) check whether the `href` starts with any configured
|
|
7
|
-
* prefix; if so, they render a native `<a>` (full page load) instead of a
|
|
8
|
-
* `next/link` that would fetch an RSC payload the route cannot produce.
|
|
9
|
-
*
|
|
10
|
-
* Default: `[]` — so behaviour is unchanged for consumers who do not opt in.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
'use client';
|
|
14
|
-
|
|
15
|
-
import React, { createContext, useContext, useMemo, type ReactNode } from 'react';
|
|
16
|
-
|
|
17
|
-
export type ExternalPrefixes = readonly string[];
|
|
18
|
-
|
|
19
|
-
const ExternalPrefixesContext = createContext<ExternalPrefixes>([]);
|
|
20
|
-
|
|
21
|
-
export interface ExternalPrefixesProviderProps {
|
|
22
|
-
/** List of path prefixes treated as external to the App Router. */
|
|
23
|
-
value?: ExternalPrefixes;
|
|
24
|
-
children: ReactNode;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function ExternalPrefixesProvider({
|
|
28
|
-
value,
|
|
29
|
-
children,
|
|
30
|
-
}: ExternalPrefixesProviderProps) {
|
|
31
|
-
const resolved = useMemo<ExternalPrefixes>(() => value ?? [], [value]);
|
|
32
|
-
return (
|
|
33
|
-
<ExternalPrefixesContext.Provider value={resolved}>
|
|
34
|
-
{children}
|
|
35
|
-
</ExternalPrefixesContext.Provider>
|
|
36
|
-
);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function useExternalPrefixes(): ExternalPrefixes {
|
|
40
|
-
return useContext(ExternalPrefixesContext);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Returns true if `href` is a string that starts with any of the provided
|
|
45
|
-
* external prefixes. Non-string hrefs (objects, `next/link` URL shape) always
|
|
46
|
-
* return false — those are App Router internal.
|
|
47
|
-
*
|
|
48
|
-
* Matches the prefix exactly or at a path-boundary (`/`, `?`, `#`) so that
|
|
49
|
-
* `/docs` in the prefix list matches `/docs`, `/docs/`, `/docs/foo`, `/docs?x=1`,
|
|
50
|
-
* `/docs#bar` — but NOT `/docsearch` (which should stay an App Router link).
|
|
51
|
-
*/
|
|
52
|
-
export function isExternalPrefixHref(
|
|
53
|
-
href: unknown,
|
|
54
|
-
prefixes: ExternalPrefixes,
|
|
55
|
-
): href is string {
|
|
56
|
-
if (typeof href !== 'string' || prefixes.length === 0) return false;
|
|
57
|
-
for (const prefix of prefixes) {
|
|
58
|
-
if (!prefix) continue;
|
|
59
|
-
if (href === prefix) return true;
|
|
60
|
-
if (
|
|
61
|
-
href.startsWith(`${prefix}/`) ||
|
|
62
|
-
href.startsWith(`${prefix}?`) ||
|
|
63
|
-
href.startsWith(`${prefix}#`)
|
|
64
|
-
) {
|
|
65
|
-
return true;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
return false;
|
|
69
|
-
}
|