@djangocfg/layouts 2.1.487 → 2.1.488
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/package.json +15 -15
- package/src/layouts/PublicLayout/components/UserMenu.tsx +2 -2
- package/src/layouts/PublicLayout/primitives/NavActions.tsx +56 -10
- package/src/layouts/PublicLayout/primitives/NavControls.tsx +1 -1
- package/src/layouts/PublicLayout/primitives/NavDesktopItems.tsx +15 -6
- package/src/layouts/PublicLayout/shared/MobileDrawerShell.tsx +67 -44
- package/src/layouts/PublicLayout/shared/NavbarShell.tsx +106 -53
- package/src/layouts/PublicLayout/shared/navbarRows.tsx +93 -0
- package/src/layouts/_components/locale-switcher/LocaleSwitcherDialog.tsx +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/layouts",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.488",
|
|
4
4
|
"description": "Simple, straightforward layout components for Next.js - import and use with props",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"layouts",
|
|
@@ -89,12 +89,12 @@
|
|
|
89
89
|
"check": "tsc --noEmit"
|
|
90
90
|
},
|
|
91
91
|
"peerDependencies": {
|
|
92
|
-
"@djangocfg/analytics": "^2.1.
|
|
93
|
-
"@djangocfg/api": "^2.1.
|
|
94
|
-
"@djangocfg/centrifugo": "^2.1.
|
|
95
|
-
"@djangocfg/devtools": "^2.1.
|
|
96
|
-
"@djangocfg/i18n": "^2.1.
|
|
97
|
-
"@djangocfg/ui-core": "^2.1.
|
|
92
|
+
"@djangocfg/analytics": "^2.1.488",
|
|
93
|
+
"@djangocfg/api": "^2.1.488",
|
|
94
|
+
"@djangocfg/centrifugo": "^2.1.488",
|
|
95
|
+
"@djangocfg/devtools": "^2.1.488",
|
|
96
|
+
"@djangocfg/i18n": "^2.1.488",
|
|
97
|
+
"@djangocfg/ui-core": "^2.1.488",
|
|
98
98
|
"@hookform/resolvers": "^5.2.2",
|
|
99
99
|
"consola": "^3.4.2",
|
|
100
100
|
"lucide-react": "^0.545.0",
|
|
@@ -124,14 +124,14 @@
|
|
|
124
124
|
"uuid": "^11.1.1"
|
|
125
125
|
},
|
|
126
126
|
"devDependencies": {
|
|
127
|
-
"@djangocfg/analytics": "^2.1.
|
|
128
|
-
"@djangocfg/api": "^2.1.
|
|
129
|
-
"@djangocfg/centrifugo": "^2.1.
|
|
130
|
-
"@djangocfg/devtools": "^2.1.
|
|
131
|
-
"@djangocfg/i18n": "^2.1.
|
|
132
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
133
|
-
"@djangocfg/ui-core": "^2.1.
|
|
134
|
-
"@djangocfg/ui-tools": "^2.1.
|
|
127
|
+
"@djangocfg/analytics": "^2.1.488",
|
|
128
|
+
"@djangocfg/api": "^2.1.488",
|
|
129
|
+
"@djangocfg/centrifugo": "^2.1.488",
|
|
130
|
+
"@djangocfg/devtools": "^2.1.488",
|
|
131
|
+
"@djangocfg/i18n": "^2.1.488",
|
|
132
|
+
"@djangocfg/typescript-config": "^2.1.488",
|
|
133
|
+
"@djangocfg/ui-core": "^2.1.488",
|
|
134
|
+
"@djangocfg/ui-tools": "^2.1.488",
|
|
135
135
|
"@types/node": "^25.9.5",
|
|
136
136
|
"@types/react": "19.2.15",
|
|
137
137
|
"@types/react-dom": "19.2.3",
|
|
@@ -130,7 +130,7 @@ export function UserMenu({
|
|
|
130
130
|
<div className="pt-4 border-t border-border/50">
|
|
131
131
|
<Link
|
|
132
132
|
href={resolvedAuthPath}
|
|
133
|
-
className="group flex items-center justify-between rounded-
|
|
133
|
+
className="group flex items-center justify-between rounded-control px-4 py-2.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground hover:bg-accent/40"
|
|
134
134
|
>
|
|
135
135
|
<span>{labels.signIn}</span>
|
|
136
136
|
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
|
@@ -162,7 +162,7 @@ export function UserMenu({
|
|
|
162
162
|
if (variant === 'mobile') {
|
|
163
163
|
return (
|
|
164
164
|
<div className="space-y-3">
|
|
165
|
-
<div className="flex items-center gap-3 px-4 py-3 rounded-
|
|
165
|
+
<div className="flex items-center gap-3 px-4 py-3 rounded-[var(--radius-popover)] bg-accent/30">
|
|
166
166
|
<UserAvatar src={userAvatar} name={displayName} size="md" />
|
|
167
167
|
<div className="flex-1 min-w-0">
|
|
168
168
|
<p className="text-sm font-semibold text-foreground truncate">
|
|
@@ -4,6 +4,7 @@ import { Menu, X } from 'lucide-react';
|
|
|
4
4
|
import React, { memo, type ReactNode } from 'react';
|
|
5
5
|
|
|
6
6
|
import { Button } from '@djangocfg/ui-core/components';
|
|
7
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
7
8
|
|
|
8
9
|
import { UserMenu } from '../components/UserMenu';
|
|
9
10
|
import type { UserMenuConfig } from '../../types';
|
|
@@ -28,6 +29,12 @@ interface NavActionsProps {
|
|
|
28
29
|
* Built by `NavbarShell` from `config.controls` + `config.i18n`.
|
|
29
30
|
*/
|
|
30
31
|
controlsSlot?: ReactNode;
|
|
32
|
+
/**
|
|
33
|
+
* Rendered immediately before the burger, on mobile only. Holds the locale
|
|
34
|
+
* flag: language is a top-level choice, so it should not require opening the
|
|
35
|
+
* menu to reach.
|
|
36
|
+
*/
|
|
37
|
+
mobileLeadingSlot?: ReactNode;
|
|
31
38
|
}
|
|
32
39
|
|
|
33
40
|
function NavActionsRaw({
|
|
@@ -40,6 +47,7 @@ function NavActionsRaw({
|
|
|
40
47
|
leadingSlot,
|
|
41
48
|
trailingSlot,
|
|
42
49
|
controlsSlot,
|
|
50
|
+
mobileLeadingSlot,
|
|
43
51
|
}: NavActionsProps) {
|
|
44
52
|
const hasActions = actions && actions.length > 0;
|
|
45
53
|
|
|
@@ -66,16 +74,53 @@ function NavActionsRaw({
|
|
|
66
74
|
/>
|
|
67
75
|
</div>
|
|
68
76
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
{/* Flag and burger are NOT peers: the burger opens navigation, the flag is
|
|
78
|
+
a rarely-touched setting. Sitting flush at equal size they read as one
|
|
79
|
+
pair of identical pills, so a divider separates them and the burger is
|
|
80
|
+
the larger, 44px touch target the platform guidelines ask for. */}
|
|
81
|
+
<div className="flex items-center gap-1">
|
|
82
|
+
{mobileLeadingSlot && (
|
|
83
|
+
<>
|
|
84
|
+
<div className={forceShowMobileTrigger ? 'shrink-0 flex items-center' : 'shrink-0 flex items-center lg:hidden'}>
|
|
85
|
+
{mobileLeadingSlot}
|
|
86
|
+
</div>
|
|
87
|
+
<span
|
|
88
|
+
aria-hidden
|
|
89
|
+
className={cn(
|
|
90
|
+
// `mx-1` on the rule itself (rather than a wider gap on the
|
|
91
|
+
// row) keeps the spacing symmetrical: a plain `gap` measures
|
|
92
|
+
// from the buttons' 44px boxes, which are wider than their
|
|
93
|
+
// glyphs, and the divider ended up hugging the burger.
|
|
94
|
+
// Full-strength border — at `/70` it vanished in light mode.
|
|
95
|
+
'mx-1 h-6 w-px shrink-0 bg-border',
|
|
96
|
+
forceShowMobileTrigger ? undefined : 'lg:hidden',
|
|
97
|
+
)}
|
|
98
|
+
/>
|
|
99
|
+
</>
|
|
100
|
+
)}
|
|
101
|
+
|
|
102
|
+
<Button
|
|
103
|
+
variant="ghost"
|
|
104
|
+
size="icon"
|
|
105
|
+
aria-label={toggleMobileLabel}
|
|
106
|
+
data-mobile-menu-trigger="true"
|
|
107
|
+
className={cn(
|
|
108
|
+
// Pill, matching every other BUTTON in the shell (CTAs, Login, the
|
|
109
|
+
// page's own actions). Corner style here is per-element-kind, not
|
|
110
|
+
// one global radius: buttons are pills, while nav rows and menu
|
|
111
|
+
// items use `rounded-control` (`--radius-control`).
|
|
112
|
+
//
|
|
113
|
+
// The 44px box stays (that is the touch target); only the glyph is
|
|
114
|
+
// back to the standard 20px — at 24px it read as an oversized icon
|
|
115
|
+
// rather than a normal control.
|
|
116
|
+
'rounded-full h-11 w-11 [&_svg]:size-5',
|
|
117
|
+
!forceShowMobileTrigger && 'lg:hidden',
|
|
118
|
+
)}
|
|
119
|
+
onClick={onMobileMenuToggle}
|
|
120
|
+
>
|
|
121
|
+
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
|
122
|
+
</Button>
|
|
123
|
+
</div>
|
|
79
124
|
|
|
80
125
|
{trailingSlot && <div className="shrink-0 flex items-center">{trailingSlot}</div>}
|
|
81
126
|
</div>
|
|
@@ -119,6 +164,7 @@ export const NavActions = memo(NavActionsRaw, (prev, next) => {
|
|
|
119
164
|
prev.leadingSlot === next.leadingSlot &&
|
|
120
165
|
prev.trailingSlot === next.trailingSlot &&
|
|
121
166
|
prev.controlsSlot === next.controlsSlot &&
|
|
167
|
+
prev.mobileLeadingSlot === next.mobileLeadingSlot &&
|
|
122
168
|
actionsEqual(prev.actions, next.actions)
|
|
123
169
|
);
|
|
124
170
|
});
|
|
@@ -38,7 +38,7 @@ function ThemeModeControl({ size }: { size: 'compact' | 'default' }) {
|
|
|
38
38
|
const activeItemClass = 'bg-background/80 text-foreground shadow-sm';
|
|
39
39
|
|
|
40
40
|
return (
|
|
41
|
-
<div className="inline-flex items-center gap-0.5 rounded-
|
|
41
|
+
<div className="inline-flex items-center gap-0.5 rounded-[var(--radius-popover)] border border-border/60 bg-muted/30 p-0.5">
|
|
42
42
|
<Button
|
|
43
43
|
type="button"
|
|
44
44
|
variant="ghost"
|
|
@@ -23,8 +23,17 @@ interface NavDesktopItemsProps {
|
|
|
23
23
|
renderDesktopDropdown?: PublicDesktopDropdownRenderer;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// Corners come from ui-core's radius tokens, never raw Tailwind steps —
|
|
27
|
+
// presets (`dense`, `windows`) retune those variables, and a hardcoded
|
|
28
|
+
// `rounded-lg` would opt this navbar out of the active theme.
|
|
29
|
+
//
|
|
30
|
+
// Radius is chosen per element KIND, not unified across the bar: nav rows and
|
|
31
|
+
// menu items take `rounded-control` (--radius-control, the token documented for
|
|
32
|
+
// "nav items, inputs, chips"), their popover takes --radius-popover, while
|
|
33
|
+
// BUTTONS stay pills like every other button in the product. Making the rows
|
|
34
|
+
// pills too is what produced capsules floating inside a soft-cornered popover.
|
|
26
35
|
const navItemCls = cn(
|
|
27
|
-
'inline-flex min-h-9 items-center justify-center gap-1 rounded-
|
|
36
|
+
'inline-flex min-h-9 items-center justify-center gap-1 rounded-control border border-transparent px-4 py-1.5 text-sm !font-medium',
|
|
28
37
|
'ring-0 focus-visible:ring-0',
|
|
29
38
|
'text-foreground/90 transition-colors',
|
|
30
39
|
'surface-hover hover:text-foreground',
|
|
@@ -38,7 +47,7 @@ const labelCls = 'min-w-0 max-w-[11rem] truncate sm:max-w-[13rem]';
|
|
|
38
47
|
|
|
39
48
|
function subMenuLinkCls(active: boolean) {
|
|
40
49
|
return cn(
|
|
41
|
-
'flex min-h-9 min-w-0 max-w-[min(17rem,calc(100vw-5rem))] items-center rounded-
|
|
50
|
+
'flex min-h-9 min-w-0 max-w-[min(17rem,calc(100vw-5rem))] items-center rounded-control border-0 px-3 py-2 text-sm font-medium transition-colors',
|
|
42
51
|
'surface-hover',
|
|
43
52
|
active
|
|
44
53
|
? 'border-0 bg-accent font-semibold text-foreground shadow-sm dark:bg-muted/90 dark:shadow-none'
|
|
@@ -50,10 +59,10 @@ function subMenuLinkCls(active: boolean) {
|
|
|
50
59
|
// it. The package-level nav-dropdown-glass utility keeps the frosted effect
|
|
51
60
|
// present even when the consuming app does not scan layout sources.
|
|
52
61
|
const popoverCls =
|
|
53
|
-
'absolute left-0 top-full mt-1 z-[1200] min-w-[14.5rem] rounded-
|
|
62
|
+
'absolute left-0 top-full mt-1 z-[1200] min-w-[14.5rem] rounded-[var(--radius-popover)] border border-border/70 nav-dropdown-glass p-1.5 shadow-[0_1px_2px_rgba(0,0,0,0.05),0_8px_28px_rgba(0,0,0,0.18)] dark:shadow-[0_8px_30px_rgba(0,0,0,0.45)]';
|
|
54
63
|
|
|
55
64
|
const portalPopoverCls =
|
|
56
|
-
'fixed z-[1200] min-w-[14.5rem] rounded-
|
|
65
|
+
'fixed z-[1200] min-w-[14.5rem] rounded-[var(--radius-popover)] border border-border/70 nav-dropdown-glass backdrop-blur-[5px] backdrop-saturate-150 p-1.5 shadow-[0_1px_2px_rgba(0,0,0,0.05),0_8px_28px_rgba(0,0,0,0.18)] dark:shadow-[0_8px_30px_rgba(0,0,0,0.45)]';
|
|
57
66
|
|
|
58
67
|
function DesktopDropdownPortal({
|
|
59
68
|
anchor,
|
|
@@ -130,7 +139,7 @@ function NavDesktopItemsRaw({
|
|
|
130
139
|
{item.items.map((sub) => {
|
|
131
140
|
const subActive = isActivePath(sub.href);
|
|
132
141
|
return (
|
|
133
|
-
<div key={`${item.label}-${sub.href}`} className="rounded-
|
|
142
|
+
<div key={`${item.label}-${sub.href}`} className="rounded-control">
|
|
134
143
|
{sub.external ? (
|
|
135
144
|
<a
|
|
136
145
|
href={sub.href}
|
|
@@ -292,7 +301,7 @@ function NavDesktopItemsRaw({
|
|
|
292
301
|
{overflowItems.map((item) => {
|
|
293
302
|
const active = isGroupActive(item);
|
|
294
303
|
return (
|
|
295
|
-
<div key={`overflow-${item.href}`} className="rounded-
|
|
304
|
+
<div key={`overflow-${item.href}`} className="rounded-control">
|
|
296
305
|
<Link href={item.href} className={subMenuLinkCls(active)}>
|
|
297
306
|
<span className="min-w-0 truncate" title={item.label}>{item.label}</span>
|
|
298
307
|
</Link>
|
|
@@ -58,7 +58,6 @@ export function MobileDrawerShell(props: MobileDrawerShellProps) {
|
|
|
58
58
|
useBodyScrollLock(mobileMenuOpen && isTabletOrBelow);
|
|
59
59
|
|
|
60
60
|
const labels = useMemo(() => ({
|
|
61
|
-
menu: t('layouts.navigation.menu'),
|
|
62
61
|
quickActions: 'Actions',
|
|
63
62
|
signIn: t('layouts.profile.login'),
|
|
64
63
|
}), [t]);
|
|
@@ -79,8 +78,11 @@ export function MobileDrawerShell(props: MobileDrawerShellProps) {
|
|
|
79
78
|
const hasSessionUser = Boolean(isAuthenticated && user);
|
|
80
79
|
const showSignInFooter = !hasSessionUser;
|
|
81
80
|
const showThemeSwitcher = props.controls?.showThemeSwitcher === true;
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
// Locale is deliberately NOT rendered here even when enabled: on mobile it
|
|
82
|
+
// lives in the navbar beside the burger (see NavbarShell). The drawer footer
|
|
83
|
+
// is the worst possible anchor for a locale list — it sits at the bottom
|
|
84
|
+
// edge, so the list opened downward and spilled past the panel.
|
|
85
|
+
const showControlsRow = showThemeSwitcher;
|
|
84
86
|
|
|
85
87
|
return (
|
|
86
88
|
<>
|
|
@@ -121,7 +123,11 @@ export function MobileDrawerShell(props: MobileDrawerShellProps) {
|
|
|
121
123
|
maxHeight: 'min(var(--public-navbar-mobile-drawer-max-height, calc(100dvh - 5rem - 12px)), calc(100dvh - 12px))',
|
|
122
124
|
}}
|
|
123
125
|
>
|
|
124
|
-
|
|
126
|
+
{/* `p-2` is the panel's inner gutter. Rows carry their own `px-3`, so
|
|
127
|
+
a link's text sits at 20px from the panel edge while its hover /
|
|
128
|
+
active pill still has room to breathe inside the rounded corners —
|
|
129
|
+
at `px-4` the pill ran into them. */}
|
|
130
|
+
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-3">
|
|
125
131
|
{hasSessionUser && (
|
|
126
132
|
<div className="px-2">
|
|
127
133
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
@@ -134,14 +140,13 @@ export function MobileDrawerShell(props: MobileDrawerShellProps) {
|
|
|
134
140
|
<UserMenu variant="mobile" groups={userMenu?.groups} authPath={userMenu?.authPath} i18n={userMenu?.i18n} />
|
|
135
141
|
)}
|
|
136
142
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
{mobileNavigation.map((item) => {
|
|
143
|
+
{/* No "MENU" heading: an open drawer IS the menu, so the label only
|
|
144
|
+
cost a row of vertical space on the smallest screens.
|
|
145
|
+
No `space-y` either — each row already carries its own height
|
|
146
|
+
(`min-h-11`), so an extra gap on top of it read as drift rather
|
|
147
|
+
than rhythm. Rows touch; their padding does the separating. */}
|
|
148
|
+
<div>
|
|
149
|
+
{mobileNavigation.map((item) => {
|
|
145
150
|
const childItems = item.items ?? [];
|
|
146
151
|
const hasChildNav = childItems.length > 0;
|
|
147
152
|
const anyChildActive = hasChildNav && childItems.some((sub) => isActivePath(sub.href));
|
|
@@ -156,7 +161,7 @@ export function MobileDrawerShell(props: MobileDrawerShellProps) {
|
|
|
156
161
|
onClick={closeMobileMenu}
|
|
157
162
|
title={item.label}
|
|
158
163
|
className={cn(
|
|
159
|
-
'
|
|
164
|
+
'flex min-h-11 min-w-0 max-w-full items-center rounded-control border-0 px-3 text-[15px] font-medium transition-colors ring-0 truncate',
|
|
160
165
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/35',
|
|
161
166
|
parentPageActive
|
|
162
167
|
? 'border-0 bg-accent font-semibold text-foreground shadow-sm dark:border dark:border-border dark:bg-muted dark:shadow-none'
|
|
@@ -168,7 +173,16 @@ export function MobileDrawerShell(props: MobileDrawerShellProps) {
|
|
|
168
173
|
{item.label}
|
|
169
174
|
</Link>
|
|
170
175
|
{hasChildNav && (
|
|
171
|
-
|
|
176
|
+
// Children stay expanded (a short scroll beats an extra
|
|
177
|
+
// tap) but run denser than the parents: 36px rows, not
|
|
178
|
+
// 44px, so a section like "Products" no longer pushes
|
|
179
|
+
// every following section below the fold.
|
|
180
|
+
//
|
|
181
|
+
// The rule sits at `ml-[1.125rem]` — the centre of the
|
|
182
|
+
// parent's text inset (px-3 + half a character) — so the
|
|
183
|
+
// children hang off their parent instead of floating at
|
|
184
|
+
// an unrelated offset.
|
|
185
|
+
<div className="my-0.5 ml-[1.125rem] space-y-0 border-l border-border/50 pl-3">
|
|
172
186
|
{childItems.map((subItem) => {
|
|
173
187
|
const subActive = isActivePath(subItem.href);
|
|
174
188
|
return (
|
|
@@ -178,7 +192,7 @@ export function MobileDrawerShell(props: MobileDrawerShellProps) {
|
|
|
178
192
|
onClick={closeMobileMenu}
|
|
179
193
|
title={subItem.label}
|
|
180
194
|
className={cn(
|
|
181
|
-
'flex min-h-
|
|
195
|
+
'flex min-h-9 min-w-0 max-w-full items-center rounded-control border-0 px-3 text-sm font-medium transition-colors ring-0 truncate',
|
|
182
196
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/35',
|
|
183
197
|
subActive
|
|
184
198
|
? 'border-0 bg-accent font-semibold text-foreground shadow-sm dark:border dark:border-border dark:bg-muted/90 dark:shadow-none'
|
|
@@ -191,38 +205,47 @@ export function MobileDrawerShell(props: MobileDrawerShellProps) {
|
|
|
191
205
|
})}
|
|
192
206
|
</div>
|
|
193
207
|
)}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
</div>
|
|
208
|
+
</div>
|
|
209
|
+
);
|
|
210
|
+
})}
|
|
198
211
|
</div>
|
|
199
212
|
</div>
|
|
200
213
|
|
|
201
|
-
{
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
214
|
+
{/* One footer row, not three stacked bands. Controls are utilities and
|
|
215
|
+
sit left at their natural width; Sign in is the single primary
|
|
216
|
+
action and sits right, sized to its label — a full-bleed button
|
|
217
|
+
here read as the loudest thing in the drawer while the language
|
|
218
|
+
picker above it burned a whole row of its own. */}
|
|
219
|
+
{/* `mx-2` insets the rule to the panel gutter so it reads as a divider
|
|
220
|
+
between two zones of one panel, not a seam across it. */}
|
|
221
|
+
{(showControlsRow || showSignInFooter) && (
|
|
222
|
+
<div className="mx-2 shrink-0 border-t border-border/60 px-1 py-2.5 flex items-center justify-between gap-3">
|
|
223
|
+
{showControlsRow ? (
|
|
224
|
+
// Squared off to match the navbar controls and the Login button
|
|
225
|
+
// beside it — `NavControls` itself stays pill-shaped for the
|
|
226
|
+
// site footer, which has no such neighbours.
|
|
227
|
+
<NavControls
|
|
228
|
+
className="[&>div]:rounded-[var(--radius-popover)] [&_button]:rounded-full"
|
|
229
|
+
showThemeSwitcher={showThemeSwitcher}
|
|
230
|
+
showLocaleSwitcher={false}
|
|
231
|
+
size="compact"
|
|
232
|
+
/>
|
|
233
|
+
) : (
|
|
234
|
+
<span />
|
|
235
|
+
)}
|
|
236
|
+
|
|
237
|
+
{showSignInFooter && (
|
|
238
|
+
<Link
|
|
239
|
+
href={userMenu?.authPath || routes.auth}
|
|
240
|
+
onClick={closeMobileMenu}
|
|
241
|
+
className="shrink-0"
|
|
242
|
+
>
|
|
243
|
+
<Button className="rounded-full h-10 gap-1.5 px-5">
|
|
244
|
+
{labels.signIn}
|
|
245
|
+
<ArrowRight className="h-4 w-4 shrink-0" aria-hidden />
|
|
246
|
+
</Button>
|
|
247
|
+
</Link>
|
|
248
|
+
)}
|
|
226
249
|
</div>
|
|
227
250
|
)}
|
|
228
251
|
</div>
|
|
@@ -29,6 +29,8 @@ import { useIsTabletOrBelow } from '@djangocfg/ui-core/hooks';
|
|
|
29
29
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
30
30
|
|
|
31
31
|
import { usePathnameWithoutLocale } from '../../../hooks';
|
|
32
|
+
import { LocaleSwitcher } from '../../_components/LocaleSwitcher';
|
|
33
|
+
import { useLayoutI18nOptional } from '../../AppLayout/LayoutI18nProvider';
|
|
32
34
|
import { usePublicLayoutOptional } from '../context';
|
|
33
35
|
import {
|
|
34
36
|
useDropdownMenu,
|
|
@@ -51,24 +53,69 @@ import { NavBrand } from '../primitives/NavBrand';
|
|
|
51
53
|
import { NavControls } from '../primitives/NavControls';
|
|
52
54
|
import { NavDesktopItems } from '../primitives/NavDesktopItems';
|
|
53
55
|
|
|
54
|
-
|
|
56
|
+
import { NavbarRowDesktop, NavbarRowMobile } from './navbarRows';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Vertical padding, per breakpoint. Two independent scales rather than one with
|
|
60
|
+
* `sm:` steps inside it: the mobile row's tallest element is the 44px burger,
|
|
61
|
+
* so padding there is pure added height, while the desktop row needs the extra
|
|
62
|
+
* air to keep the link list off the panel edge.
|
|
63
|
+
*/
|
|
64
|
+
const heightClsDesktop: Record<PublicNavbarHeight, string> = {
|
|
55
65
|
sm: 'py-2',
|
|
56
66
|
md: 'py-3.5',
|
|
57
67
|
lg: 'py-5',
|
|
58
68
|
};
|
|
59
69
|
|
|
60
|
-
|
|
61
|
-
|
|
70
|
+
const heightClsMobile: Record<PublicNavbarHeight, string> = {
|
|
71
|
+
sm: 'py-1.5',
|
|
72
|
+
md: 'py-2',
|
|
73
|
+
lg: 'py-3',
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Token → Tailwind class for the inner-row horizontal padding.
|
|
78
|
+
*
|
|
79
|
+
* Deliberately ASYMMETRIC at every breakpoint (`pl` > `pr`) to correct an
|
|
80
|
+
* optical imbalance, not a measured one. On the left the gutter meets the
|
|
81
|
+
* brand's glyphs directly; on the right it meets a button — an icon square on
|
|
82
|
+
* phones, the Login pill on desktop — whose box carries its own internal
|
|
83
|
+
* padding around the glyph. Equal `px` therefore *looks* lopsided, with a
|
|
84
|
+
* conspicuous moat to the right of the last control.
|
|
85
|
+
*
|
|
86
|
+
* It also stacks with the navbar shell's own outer gutter (`px-3 sm:px-4
|
|
87
|
+
* lg:px-6`), which is why the right side stays small in absolute terms: at
|
|
88
|
+
* `lg` the symmetric version put ~48px between the Login button and the
|
|
89
|
+
* viewport edge.
|
|
90
|
+
*/
|
|
91
|
+
/** Desktop (`lg`+) horizontal inset. Right stays trimmed — see the note above. */
|
|
92
|
+
const insetXClsDesktop: Record<'none' | 'xs' | 'sm' | 'md' | 'lg', string> = {
|
|
93
|
+
none: '',
|
|
94
|
+
xs: 'lg:pl-4 lg:pr-1.5',
|
|
95
|
+
sm: 'lg:pl-6 lg:pr-2',
|
|
96
|
+
md: 'lg:pl-8 lg:pr-3',
|
|
97
|
+
lg: 'lg:pl-12 lg:pr-5',
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** Mobile horizontal inset — tighter, and asymmetric for the same reason. */
|
|
101
|
+
const insetXClsMobile: Record<'none' | 'xs' | 'sm' | 'md' | 'lg', string> = {
|
|
62
102
|
none: '',
|
|
63
|
-
xs: '
|
|
64
|
-
sm: '
|
|
65
|
-
md: '
|
|
66
|
-
lg: '
|
|
103
|
+
xs: 'pl-3 pr-1',
|
|
104
|
+
sm: 'pl-4 pr-1.5',
|
|
105
|
+
md: 'pl-5 pr-2',
|
|
106
|
+
lg: 'pl-6 pr-3',
|
|
67
107
|
};
|
|
68
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Resolves the inset token into one class string covering both breakpoints.
|
|
111
|
+
*
|
|
112
|
+
* A caller may also pass raw Tailwind instead of a token; that escape hatch is
|
|
113
|
+
* applied verbatim to both rows, since we cannot know how to split it.
|
|
114
|
+
*/
|
|
69
115
|
function resolveInsetX(value: PublicNavbarInsetX | undefined): string {
|
|
70
|
-
|
|
71
|
-
|
|
116
|
+
const token = (value ?? 'sm') as keyof typeof insetXClsMobile;
|
|
117
|
+
if (!(token in insetXClsMobile)) return value as string;
|
|
118
|
+
return cn(insetXClsMobile[token], insetXClsDesktop[token]);
|
|
72
119
|
}
|
|
73
120
|
|
|
74
121
|
interface ShapeInputs {
|
|
@@ -190,6 +237,7 @@ function NavbarShellRaw(props: NavbarShellProps) {
|
|
|
190
237
|
: undefined;
|
|
191
238
|
const mobileMenuOpen = props.mobileMenuOpen ?? context?.mobileMenuOpen ?? false;
|
|
192
239
|
const toggleMobileMenu = props.onMobileMenuToggle ?? context?.toggleMobileMenu ?? (() => {});
|
|
240
|
+
const closeMobileMenu = context?.closeMobileMenu ?? (() => {});
|
|
193
241
|
|
|
194
242
|
const navOuterRef = useRef<HTMLDivElement | null>(null);
|
|
195
243
|
|
|
@@ -200,6 +248,9 @@ function NavbarShellRaw(props: NavbarShellProps) {
|
|
|
200
248
|
const isTabletOrBelow = useIsTabletOrBelow();
|
|
201
249
|
const t = useAppT();
|
|
202
250
|
const pathname = usePathnameWithoutLocale();
|
|
251
|
+
// `LocaleSwitcher` throws without a provider, so the mobile flag is gated on
|
|
252
|
+
// this being present — same contract `NavControls` applies internally.
|
|
253
|
+
const i18n = useLayoutI18nOptional();
|
|
203
254
|
|
|
204
255
|
// Close dropdowns when switching to mobile
|
|
205
256
|
useEffect(() => {
|
|
@@ -273,6 +324,34 @@ function NavbarShellRaw(props: NavbarShellProps) {
|
|
|
273
324
|
/>
|
|
274
325
|
) : null;
|
|
275
326
|
|
|
327
|
+
// Locale sits in the navbar beside the burger, reachable without opening the
|
|
328
|
+
// menu. It stays the compact dropdown, NOT the fullscreen dialog: anchored at
|
|
329
|
+
// the TOP of the screen it has all the room it needs to open downward, so the
|
|
330
|
+
// overflow that plagued the drawer footer cannot happen here — and a modal is
|
|
331
|
+
// far too heavy a response to a mistap on a small target.
|
|
332
|
+
//
|
|
333
|
+
// Rendered directly rather than through `NavControls`: that wrapper hardcodes
|
|
334
|
+
// an outlined, tinted pill for the site footer, and overriding it from the
|
|
335
|
+
// outside produced exactly the mismatch this pairing must avoid — an outlined
|
|
336
|
+
// flag next to a ghost burger. Here both are ghost squircles of equal size.
|
|
337
|
+
const mobileLocaleNode = showLocaleSwitcher && i18n ? (
|
|
338
|
+
// Capture-phase close: the navbar (z-1001) stays clickable above an open
|
|
339
|
+
// drawer, so without this the drawer would sit open behind the menu.
|
|
340
|
+
<div
|
|
341
|
+
onClickCapture={() => {
|
|
342
|
+
if (mobileMenuOpen) closeMobileMenu();
|
|
343
|
+
}}
|
|
344
|
+
>
|
|
345
|
+
<LocaleSwitcher
|
|
346
|
+
variant="dropdown"
|
|
347
|
+
buttonVariant="ghost"
|
|
348
|
+
size="icon"
|
|
349
|
+
showTriggerLabel={false}
|
|
350
|
+
className="h-11 w-11 rounded-full"
|
|
351
|
+
/>
|
|
352
|
+
</div>
|
|
353
|
+
) : null;
|
|
354
|
+
|
|
276
355
|
const actionsNode = props.renderActions ? (
|
|
277
356
|
props.renderActions({
|
|
278
357
|
userMenu,
|
|
@@ -293,58 +372,32 @@ function NavbarShellRaw(props: NavbarShellProps) {
|
|
|
293
372
|
leadingSlot={props.actionsLeadingSlot}
|
|
294
373
|
trailingSlot={props.actionsTrailingSlot}
|
|
295
374
|
controlsSlot={controlsNode}
|
|
375
|
+
mobileLeadingSlot={mobileLocaleNode}
|
|
296
376
|
/>
|
|
297
377
|
);
|
|
298
378
|
|
|
299
|
-
const h = heightCls[navbarHeight];
|
|
300
|
-
|
|
301
|
-
const row = (() => {
|
|
302
|
-
switch (navLayout) {
|
|
303
|
-
case 'brand-left':
|
|
304
|
-
return (
|
|
305
|
-
<div className={cn('flex items-center gap-4', h)}>
|
|
306
|
-
<div className="min-w-0 shrink-0 flex items-center">{brandNode}</div>
|
|
307
|
-
<div className="hidden isolate lg:flex min-w-0 flex-1 items-center gap-1">{desktopNavNode}</div>
|
|
308
|
-
<div className="flex-1 lg:hidden" />
|
|
309
|
-
<div className="flex shrink-0 items-center gap-4">{actionsNode}</div>
|
|
310
|
-
</div>
|
|
311
|
-
);
|
|
312
|
-
case 'centered':
|
|
313
|
-
return (
|
|
314
|
-
<div className={cn('flex items-center gap-4', h)}>
|
|
315
|
-
<div className="shrink-0">{brandNode}</div>
|
|
316
|
-
<div className="hidden isolate lg:flex min-w-0 flex-1 items-center justify-center gap-1">{desktopNavNode}</div>
|
|
317
|
-
<div className="flex-1 lg:hidden" />
|
|
318
|
-
<div className="flex shrink-0 items-center">{actionsNode}</div>
|
|
319
|
-
</div>
|
|
320
|
-
);
|
|
321
|
-
case 'split':
|
|
322
|
-
return (
|
|
323
|
-
<div className={cn('flex items-center justify-between', h)}>
|
|
324
|
-
{brandNode}
|
|
325
|
-
{actionsNode}
|
|
326
|
-
</div>
|
|
327
|
-
);
|
|
328
|
-
default:
|
|
329
|
-
return (
|
|
330
|
-
<div className={cn('flex items-center gap-4', h)}>
|
|
331
|
-
<div className="shrink-0">{brandNode}</div>
|
|
332
|
-
<div className="hidden isolate lg:flex min-w-0 flex-1 items-center justify-center gap-1">
|
|
333
|
-
{desktopNavNode}
|
|
334
|
-
</div>
|
|
335
|
-
<div className="flex-1 lg:hidden" />
|
|
336
|
-
<div className="flex shrink-0 items-center">{actionsNode}</div>
|
|
337
|
-
</div>
|
|
338
|
-
);
|
|
339
|
-
}
|
|
340
|
-
})();
|
|
341
|
-
|
|
342
379
|
const innerPaddingCls = innerPadding ?? cn('w-full', resolveInsetX(insetX));
|
|
343
380
|
|
|
344
381
|
return (
|
|
345
382
|
<div ref={navOuterRef} className={outerCls}>
|
|
346
383
|
<nav className={cn(shapeClassName, surfaceCls)}>
|
|
347
|
-
<div className={innerPaddingCls}>
|
|
384
|
+
<div className={innerPaddingCls}>
|
|
385
|
+
{/* Two rows, one config. Exactly one is visible at any width — see
|
|
386
|
+
the note in `navbarRows.tsx` for why this is CSS and not a
|
|
387
|
+
media-query hook. */}
|
|
388
|
+
<NavbarRowDesktop
|
|
389
|
+
brand={brandNode}
|
|
390
|
+
actions={actionsNode}
|
|
391
|
+
desktopNav={desktopNavNode}
|
|
392
|
+
navLayout={navLayout}
|
|
393
|
+
heightClassName={heightClsDesktop[navbarHeight]}
|
|
394
|
+
/>
|
|
395
|
+
<NavbarRowMobile
|
|
396
|
+
brand={brandNode}
|
|
397
|
+
actions={actionsNode}
|
|
398
|
+
heightClassName={heightClsMobile[navbarHeight]}
|
|
399
|
+
/>
|
|
400
|
+
</div>
|
|
348
401
|
</nav>
|
|
349
402
|
</div>
|
|
350
403
|
);
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The navbar has TWO layouts, not one responsive layout.
|
|
3
|
+
*
|
|
4
|
+
* They are separate components on purpose. Previously a single row carried both
|
|
5
|
+
* behaviours and toggled parts with `lg:hidden` / `hidden lg:flex`, which meant
|
|
6
|
+
* every shared token — horizontal inset, row height, control size, corner
|
|
7
|
+
* radius — had to encode both breakpoints at once. Tuning the phone spacing
|
|
8
|
+
* risked the desktop bar and vice versa; the two designs were entangled in
|
|
9
|
+
* values that looked unrelated to either.
|
|
10
|
+
*
|
|
11
|
+
* Splitting them costs one thing worth stating plainly: both rows are rendered
|
|
12
|
+
* and one is hidden with CSS. That keeps rendering purely CSS-driven — no
|
|
13
|
+
* `useMediaQuery`, no layout shift on hydration, correct SSR output — at the
|
|
14
|
+
* price of the hidden row's markup existing in the DOM. For a navbar (a handful
|
|
15
|
+
* of links) that trade is right; it would not be for a heavy subtree.
|
|
16
|
+
*
|
|
17
|
+
* Config stays SINGLE — callers pass one `navigation`, one `brand`, one
|
|
18
|
+
* `userMenu`. Only the arrangement differs, so the two cannot drift apart.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
'use client';
|
|
22
|
+
|
|
23
|
+
import React, { type ReactNode } from 'react';
|
|
24
|
+
|
|
25
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
26
|
+
|
|
27
|
+
import type { PublicNavLayout } from '../navbarTypes';
|
|
28
|
+
|
|
29
|
+
export interface NavbarRowProps {
|
|
30
|
+
brand: ReactNode;
|
|
31
|
+
actions: ReactNode;
|
|
32
|
+
/** Desktop-only: the horizontal link list. */
|
|
33
|
+
desktopNav?: ReactNode;
|
|
34
|
+
navLayout: PublicNavLayout;
|
|
35
|
+
/** Vertical padding classes for this breakpoint. */
|
|
36
|
+
heightClassName: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Desktop row (`lg` and up). Brand, the link list, then actions.
|
|
41
|
+
*
|
|
42
|
+
* `navLayout` only affects where the links sit, so it is read here and nowhere
|
|
43
|
+
* else — the mobile row has no link list to arrange.
|
|
44
|
+
*/
|
|
45
|
+
export function NavbarRowDesktop({
|
|
46
|
+
brand,
|
|
47
|
+
actions,
|
|
48
|
+
desktopNav,
|
|
49
|
+
navLayout,
|
|
50
|
+
heightClassName,
|
|
51
|
+
}: NavbarRowProps) {
|
|
52
|
+
// `split` deliberately renders no link list — the caller wants brand and
|
|
53
|
+
// actions only, with navigation living in the drawer at every width.
|
|
54
|
+
const navAlignment =
|
|
55
|
+
navLayout === 'brand-left'
|
|
56
|
+
? 'justify-start'
|
|
57
|
+
: 'justify-center';
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div className={cn('hidden lg:flex items-center gap-6', heightClassName)}>
|
|
61
|
+
<div className="min-w-0 shrink-0 flex items-center">{brand}</div>
|
|
62
|
+
|
|
63
|
+
{navLayout !== 'split' && (
|
|
64
|
+
// `isolate` keeps the dropdown popovers' stacking contained to the nav
|
|
65
|
+
// list instead of competing with the actions column.
|
|
66
|
+
<div className={cn('isolate flex min-w-0 flex-1 items-center gap-1', navAlignment)}>
|
|
67
|
+
{desktopNav}
|
|
68
|
+
</div>
|
|
69
|
+
)}
|
|
70
|
+
|
|
71
|
+
{navLayout === 'split' && <div className="flex-1" />}
|
|
72
|
+
|
|
73
|
+
<div className="flex shrink-0 items-center gap-3">{actions}</div>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Mobile row (below `lg`). Brand on the left, controls hard right — there is no
|
|
80
|
+
* link list, so it never needs the alignment logic above.
|
|
81
|
+
*/
|
|
82
|
+
export function NavbarRowMobile({
|
|
83
|
+
brand,
|
|
84
|
+
actions,
|
|
85
|
+
heightClassName,
|
|
86
|
+
}: Omit<NavbarRowProps, 'desktopNav' | 'navLayout'>) {
|
|
87
|
+
return (
|
|
88
|
+
<div className={cn('flex lg:hidden items-center justify-between gap-3', heightClassName)}>
|
|
89
|
+
<div className="min-w-0 flex items-center">{brand}</div>
|
|
90
|
+
<div className="flex shrink-0 items-center">{actions}</div>
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -104,7 +104,7 @@ export function LocaleSwitcherDialog({
|
|
|
104
104
|
The shell scrolls; the grid below uses overflow-hidden so the focus
|
|
105
105
|
ring on the search input isn't clipped by an inner scroll edge.
|
|
106
106
|
*/}
|
|
107
|
-
<div className="mx-auto flex h-full w-full max-w-5xl flex-col px-4 sm:px-6 lg:px-8">
|
|
107
|
+
<div className="mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col px-4 sm:px-6 lg:px-8">
|
|
108
108
|
<LocaleSwitcherDialogHeader brand={brand} labels={merged} />
|
|
109
109
|
|
|
110
110
|
<div className="flex-1 min-h-0 pb-6 lg:pb-10">
|