@djangocfg/layouts 2.1.255 → 2.1.257
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 +18 -18
- package/src/layouts/PrivateLayout/components/PrivateContent.tsx +30 -12
- package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +26 -6
- package/src/layouts/PublicLayout/PublicLayout.tsx +53 -11
- package/src/layouts/PublicLayout/components/PublicMobileDrawer.tsx +18 -13
- package/src/layouts/PublicLayout/components/PublicNavbar.tsx +2 -5
- package/src/layouts/PublicLayout/components/PublicNavigation.tsx +22 -4
- package/src/layouts/PublicLayout/context.tsx +5 -0
- package/src/layouts/PublicLayout/hooks/index.ts +1 -1
- package/src/layouts/PublicLayout/hooks/useMobileNavPanel.ts +55 -0
- package/src/layouts/PublicLayout/index.ts +3 -0
- package/src/layouts/PublicLayout/navbarTypes.ts +12 -0
- package/src/layouts/_components/PrivateSidebarAccount.tsx +16 -3
- package/src/layouts/_components/UserMenu.tsx +131 -28
- package/src/layouts/types/index.ts +1 -0
- package/src/layouts/types/ui.types.ts +9 -0
- package/src/layouts/PublicLayout/hooks/useFloatingPanel.ts +0 -61
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/layouts",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.257",
|
|
4
4
|
"description": "Simple, straightforward layout components for Next.js - import and use with props",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"layouts",
|
|
@@ -74,14 +74,14 @@
|
|
|
74
74
|
"check": "tsc --noEmit"
|
|
75
75
|
},
|
|
76
76
|
"peerDependencies": {
|
|
77
|
-
"@djangocfg/api": "^2.1.
|
|
78
|
-
"@djangocfg/centrifugo": "^2.1.
|
|
79
|
-
"@djangocfg/i18n": "^2.1.
|
|
80
|
-
"@djangocfg/monitor": "^2.1.
|
|
81
|
-
"@djangocfg/debuger": "^2.1.
|
|
82
|
-
"@djangocfg/ui-core": "^2.1.
|
|
83
|
-
"@djangocfg/ui-nextjs": "^2.1.
|
|
84
|
-
"@djangocfg/ui-tools": "^2.1.
|
|
77
|
+
"@djangocfg/api": "^2.1.257",
|
|
78
|
+
"@djangocfg/centrifugo": "^2.1.257",
|
|
79
|
+
"@djangocfg/i18n": "^2.1.257",
|
|
80
|
+
"@djangocfg/monitor": "^2.1.257",
|
|
81
|
+
"@djangocfg/debuger": "^2.1.257",
|
|
82
|
+
"@djangocfg/ui-core": "^2.1.257",
|
|
83
|
+
"@djangocfg/ui-nextjs": "^2.1.257",
|
|
84
|
+
"@djangocfg/ui-tools": "^2.1.257",
|
|
85
85
|
"@hookform/resolvers": "^5.2.2",
|
|
86
86
|
"consola": "^3.4.2",
|
|
87
87
|
"lucide-react": "^0.545.0",
|
|
@@ -109,15 +109,15 @@
|
|
|
109
109
|
"uuid": "^11.1.0"
|
|
110
110
|
},
|
|
111
111
|
"devDependencies": {
|
|
112
|
-
"@djangocfg/api": "^2.1.
|
|
113
|
-
"@djangocfg/i18n": "^2.1.
|
|
114
|
-
"@djangocfg/centrifugo": "^2.1.
|
|
115
|
-
"@djangocfg/monitor": "^2.1.
|
|
116
|
-
"@djangocfg/debuger": "^2.1.
|
|
117
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
118
|
-
"@djangocfg/ui-core": "^2.1.
|
|
119
|
-
"@djangocfg/ui-nextjs": "^2.1.
|
|
120
|
-
"@djangocfg/ui-tools": "^2.1.
|
|
112
|
+
"@djangocfg/api": "^2.1.257",
|
|
113
|
+
"@djangocfg/i18n": "^2.1.257",
|
|
114
|
+
"@djangocfg/centrifugo": "^2.1.257",
|
|
115
|
+
"@djangocfg/monitor": "^2.1.257",
|
|
116
|
+
"@djangocfg/debuger": "^2.1.257",
|
|
117
|
+
"@djangocfg/typescript-config": "^2.1.257",
|
|
118
|
+
"@djangocfg/ui-core": "^2.1.257",
|
|
119
|
+
"@djangocfg/ui-nextjs": "^2.1.257",
|
|
120
|
+
"@djangocfg/ui-tools": "^2.1.257",
|
|
121
121
|
"@types/node": "^24.7.2",
|
|
122
122
|
"@types/react": "^19.1.0",
|
|
123
123
|
"@types/react-dom": "^19.1.0",
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Private layout main column —
|
|
3
|
-
* On viewports below `md`, the desktop sidebar is off-canvas; the trigger opens the `
|
|
2
|
+
* Private layout main column — on narrow viewports a fixed menu FAB (`SidebarTrigger`) + scrollable area.
|
|
3
|
+
* On viewports below `md`, the desktop sidebar is off-canvas; the trigger opens the `Drawer` from ui-nextjs sidebar.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
'use client';
|
|
7
7
|
|
|
8
8
|
import React, { ReactNode } from 'react';
|
|
9
9
|
|
|
10
|
-
import { SidebarTrigger } from '@djangocfg/ui-nextjs/components';
|
|
10
|
+
import { SidebarTrigger, useSidebar } from '@djangocfg/ui-nextjs/components';
|
|
11
11
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
12
12
|
|
|
13
13
|
interface PrivateContentProps {
|
|
@@ -22,24 +22,42 @@ export function PrivateContent({
|
|
|
22
22
|
padding = 'default',
|
|
23
23
|
hasSidebar = true,
|
|
24
24
|
}: PrivateContentProps) {
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
const { isMobile, openMobile } = useSidebar();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Space for fixed FAB + safe area so content does not sit under the button.
|
|
29
|
+
* Tighter when the mobile drawer is closed (FAB only). When the drawer is open, a bit more
|
|
30
|
+
* room avoids the first line sitting under the overlay edge.
|
|
31
|
+
*/
|
|
32
|
+
const mobileFabClearance =
|
|
33
|
+
hasSidebar &&
|
|
34
|
+
(isMobile && !openMobile
|
|
35
|
+
? 'max-md:pt-[max(3.75rem,calc(3rem+env(safe-area-inset-top,0px)))]'
|
|
36
|
+
: 'max-md:pt-[max(4rem,calc(3.25rem+env(safe-area-inset-top,0px)))]');
|
|
37
|
+
|
|
29
38
|
const scrollAreaClass = cn(
|
|
30
39
|
'min-h-0 flex-1 overflow-y-auto',
|
|
31
40
|
padding === 'default' && 'p-4 sm:p-6 lg:p-8',
|
|
41
|
+
mobileFabClearance,
|
|
32
42
|
);
|
|
33
43
|
|
|
34
|
-
const
|
|
35
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
44
|
+
const mobileMenuFab = hasSidebar ? (
|
|
45
|
+
<SidebarTrigger
|
|
46
|
+
variant="secondary"
|
|
47
|
+
className={cn(
|
|
48
|
+
'fixed z-40 md:hidden',
|
|
49
|
+
'left-3 top-[max(0.75rem,env(safe-area-inset-top,0px))]',
|
|
50
|
+
'h-12 w-12 rounded-xl',
|
|
51
|
+
'border border-border shadow-md',
|
|
52
|
+
'[&_svg]:!h-6 [&_svg]:!w-6',
|
|
53
|
+
'touch-manipulation',
|
|
54
|
+
)}
|
|
55
|
+
/>
|
|
38
56
|
) : null;
|
|
39
57
|
|
|
40
58
|
return (
|
|
41
59
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
|
42
|
-
{
|
|
60
|
+
{mobileMenuFab}
|
|
43
61
|
<div className={scrollAreaClass}>{children}</div>
|
|
44
62
|
</div>
|
|
45
63
|
);
|
|
@@ -48,9 +48,9 @@ function navDensityFromCount(n: number): NavDensity {
|
|
|
48
48
|
* Nav rows use semantic sidebar tokens so light/dark follows ui-core theme vars.
|
|
49
49
|
*/
|
|
50
50
|
const navItemClass = cn(
|
|
51
|
-
'border-0 font-
|
|
52
|
-
'text-sidebar-foreground/
|
|
53
|
-
'data-[active=true]:font-
|
|
51
|
+
'border-0 font-medium shadow-none transition-colors',
|
|
52
|
+
'text-sidebar-foreground/80',
|
|
53
|
+
'data-[active=true]:font-semibold data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
|
|
54
54
|
'hover:bg-sidebar-accent/70 hover:text-sidebar-accent-foreground',
|
|
55
55
|
'data-[active=true]:hover:bg-sidebar-accent',
|
|
56
56
|
'[&>svg]:shrink-0 [&>svg]:text-sidebar-foreground/70 [&>svg]:opacity-85',
|
|
@@ -210,7 +210,7 @@ export function PrivateSidebar({ sidebar, header, i18n, pathname: pathnameProp }
|
|
|
210
210
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-sidebar-primary">{brandMark}</div>
|
|
211
211
|
<span className="truncate text-sm font-semibold tracking-tight text-sidebar-foreground">{brandTitle}</span>
|
|
212
212
|
</Link>
|
|
213
|
-
<SidebarTrigger className="shrink-0" aria-label="Collapse sidebar" />
|
|
213
|
+
{!isMobile && <SidebarTrigger className="shrink-0" aria-label="Collapse sidebar" />}
|
|
214
214
|
</div>
|
|
215
215
|
);
|
|
216
216
|
|
|
@@ -220,12 +220,32 @@ export function PrivateSidebar({ sidebar, header, i18n, pathname: pathnameProp }
|
|
|
220
220
|
</div>
|
|
221
221
|
);
|
|
222
222
|
|
|
223
|
-
|
|
223
|
+
/** Mobile drawer: menu open/close only from the main column trigger — no duplicate toggle in the sheet. */
|
|
224
|
+
const mobileHeader = (
|
|
225
|
+
<div className="flex items-center gap-3">
|
|
226
|
+
<Link
|
|
227
|
+
href={homeHref}
|
|
228
|
+
className="flex min-w-0 flex-1 items-center gap-3 rounded-md py-1 outline-none ring-sidebar-ring focus-visible:ring-2"
|
|
229
|
+
>
|
|
230
|
+
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-sidebar-primary">{brandMark}</div>
|
|
231
|
+
<span className="truncate text-sm font-semibold tracking-tight text-sidebar-foreground">{brandTitle}</span>
|
|
232
|
+
</Link>
|
|
233
|
+
</div>
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const sidebarHeaderContent = isMobile ? mobileHeader : expanded ? expandedHeader : collapsedHeader;
|
|
224
237
|
const footerExtra = sidebar.footer ? <div className="mb-2">{sidebar.footer}</div> : null;
|
|
225
238
|
|
|
239
|
+
const sidebarHeaderClass = cn(
|
|
240
|
+
'pb-2',
|
|
241
|
+
isMobile
|
|
242
|
+
? 'px-4 pb-3 pt-[max(1.25rem,env(safe-area-inset-top,0px))]'
|
|
243
|
+
: 'px-2 pt-3.5',
|
|
244
|
+
);
|
|
245
|
+
|
|
226
246
|
return (
|
|
227
247
|
<Sidebar collapsible="icon">
|
|
228
|
-
<SidebarHeader className=
|
|
248
|
+
<SidebarHeader className={sidebarHeaderClass}>{sidebarHeaderContent}</SidebarHeader>
|
|
229
249
|
|
|
230
250
|
<SidebarContent className={sidebarContentClass}>
|
|
231
251
|
{menuStartSlot}
|
|
@@ -33,9 +33,12 @@
|
|
|
33
33
|
'use client';
|
|
34
34
|
|
|
35
35
|
import { usePathname } from 'next/navigation';
|
|
36
|
-
import { ReactNode, useEffect, useMemo, useState } from 'react';
|
|
36
|
+
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
|
|
37
37
|
|
|
38
|
-
import {
|
|
38
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
39
|
+
|
|
40
|
+
import { PublicLayoutProvider, usePublicLayoutOptional } from './context';
|
|
41
|
+
import type { PublicNavbarSurface } from './navbarTypes';
|
|
39
42
|
|
|
40
43
|
export interface PublicLayoutProps {
|
|
41
44
|
children: ReactNode;
|
|
@@ -48,36 +51,75 @@ export interface PublicLayoutProps {
|
|
|
48
51
|
*/
|
|
49
52
|
navbar?: ReactNode;
|
|
50
53
|
footer?: ReactNode;
|
|
54
|
+
/**
|
|
55
|
+
* When `auto` (default), `<main>` gets a small top offset from `PublicNavigation` surface
|
|
56
|
+
* (`floating` vs `flush`). Set `none` if the page controls spacing itself.
|
|
57
|
+
*/
|
|
58
|
+
contentTopSpacing?: 'auto' | 'none';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function PublicMain({
|
|
62
|
+
children,
|
|
63
|
+
contentTopSpacing,
|
|
64
|
+
}: {
|
|
65
|
+
children: ReactNode;
|
|
66
|
+
contentTopSpacing: 'auto' | 'none';
|
|
67
|
+
}) {
|
|
68
|
+
const ctx = usePublicLayoutOptional();
|
|
69
|
+
const variant = ctx?.navbarSurface?.variant;
|
|
70
|
+
|
|
71
|
+
const topClass =
|
|
72
|
+
contentTopSpacing === 'none'
|
|
73
|
+
? undefined
|
|
74
|
+
: !variant
|
|
75
|
+
? 'pt-4 sm:pt-5'
|
|
76
|
+
: variant === 'floating'
|
|
77
|
+
? 'pt-2 sm:pt-3 lg:pt-4'
|
|
78
|
+
: 'pt-1 sm:pt-2 lg:pt-3';
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<main className={cn('flex-1', topClass)}>
|
|
82
|
+
{children}
|
|
83
|
+
</main>
|
|
84
|
+
);
|
|
51
85
|
}
|
|
52
86
|
|
|
53
87
|
export function PublicLayout({
|
|
54
88
|
children,
|
|
55
89
|
navbar,
|
|
56
90
|
footer,
|
|
91
|
+
contentTopSpacing = 'auto',
|
|
57
92
|
}: PublicLayoutProps) {
|
|
58
93
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
|
94
|
+
const [navbarSurface, setNavbarSurfaceState] = useState<PublicNavbarSurface | null>(null);
|
|
59
95
|
const pathname = usePathname();
|
|
60
96
|
|
|
97
|
+
const setNavbarSurface = useCallback((surface: PublicNavbarSurface | null) => {
|
|
98
|
+
setNavbarSurfaceState(surface);
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
61
101
|
// Close mobile menu on route change
|
|
62
102
|
useEffect(() => {
|
|
63
103
|
setMobileMenuOpen(false);
|
|
64
104
|
}, [pathname]);
|
|
65
105
|
|
|
66
|
-
const contextValue = useMemo(
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
106
|
+
const contextValue = useMemo(
|
|
107
|
+
() => ({
|
|
108
|
+
mobileMenuOpen,
|
|
109
|
+
toggleMobileMenu: () => setMobileMenuOpen((prev) => !prev),
|
|
110
|
+
closeMobileMenu: () => setMobileMenuOpen(false),
|
|
111
|
+
navbarSurface,
|
|
112
|
+
setNavbarSurface,
|
|
113
|
+
}),
|
|
114
|
+
[mobileMenuOpen, navbarSurface, setNavbarSurface],
|
|
115
|
+
);
|
|
73
116
|
|
|
74
117
|
return (
|
|
75
118
|
<PublicLayoutProvider value={contextValue}>
|
|
76
119
|
<div className="min-h-screen flex flex-col">
|
|
77
120
|
{navbar ?? null}
|
|
78
121
|
|
|
79
|
-
{
|
|
80
|
-
<main className="flex-1">{children}</main>
|
|
122
|
+
<PublicMain contentTopSpacing={contentTopSpacing}>{children}</PublicMain>
|
|
81
123
|
|
|
82
124
|
{footer ?? null}
|
|
83
125
|
</div>
|
|
@@ -17,7 +17,7 @@ import { usePathnameWithoutLocale } from '../../../hooks';
|
|
|
17
17
|
|
|
18
18
|
import { UserMenu } from '../../_components/UserMenu';
|
|
19
19
|
import { usePublicLayoutOptional } from '../context';
|
|
20
|
-
import {
|
|
20
|
+
import { useMobileNavPanel } from '../hooks';
|
|
21
21
|
|
|
22
22
|
import type { NavigationItem, UserMenuConfig } from '../../types';
|
|
23
23
|
|
|
@@ -39,7 +39,7 @@ export function PublicMobileDrawer(props: PublicMobileDrawerProps = {}) {
|
|
|
39
39
|
const { isAuthenticated } = useAuth();
|
|
40
40
|
const pathname = usePathnameWithoutLocale();
|
|
41
41
|
const t = useAppT();
|
|
42
|
-
const {
|
|
42
|
+
const { mounted, visible } = useMobileNavPanel({
|
|
43
43
|
isOpen: mobileMenuOpen,
|
|
44
44
|
onClose: closeMobileMenu,
|
|
45
45
|
});
|
|
@@ -61,7 +61,7 @@ export function PublicMobileDrawer(props: PublicMobileDrawerProps = {}) {
|
|
|
61
61
|
return pathname === href || pathname.startsWith(`${href}/`);
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
-
if (!
|
|
64
|
+
if (!mounted) return null;
|
|
65
65
|
|
|
66
66
|
return (
|
|
67
67
|
<>
|
|
@@ -73,16 +73,17 @@ export function PublicMobileDrawer(props: PublicMobileDrawerProps = {}) {
|
|
|
73
73
|
onClick={closeMobileMenu}
|
|
74
74
|
/>
|
|
75
75
|
)}
|
|
76
|
+
{/* Outer shell must not capture taps when the panel is closed: with pointer-events-none on the
|
|
77
|
+
inner panel, events would otherwise hit this transparent fixed layer (z-1000) and block the page. */}
|
|
76
78
|
<div
|
|
77
|
-
className="fixed inset-x-0 z-1000 lg:hidden px-4 sm:px-6 lg:px-8"
|
|
79
|
+
className="pointer-events-none fixed inset-x-0 z-1000 lg:hidden px-4 sm:px-6 lg:px-8"
|
|
78
80
|
style={{ top: 'var(--public-navbar-mobile-drawer-top, 5rem)' }}
|
|
79
81
|
>
|
|
80
82
|
<div
|
|
81
|
-
onTransitionEnd={onTransitionEnd}
|
|
82
83
|
className={`mx-auto w-full rounded-2xl border border-border/60 bg-background/95 backdrop-blur-xl shadow-2xl overflow-hidden flex flex-col transform-gpu will-change-transform transition-[transform,opacity] duration-[220ms] ease-out ${containerClassName || ''} ${
|
|
83
|
-
|
|
84
|
-
? 'opacity-100 translate-y-0 scale-100'
|
|
85
|
-
: 'opacity-0 -translate-y-2 scale-[0.985]
|
|
84
|
+
visible
|
|
85
|
+
? 'pointer-events-auto opacity-100 translate-y-0 scale-100'
|
|
86
|
+
: 'pointer-events-none opacity-0 -translate-y-2 scale-[0.985]'
|
|
86
87
|
}`}
|
|
87
88
|
style={{
|
|
88
89
|
maxHeight: 'min(var(--public-navbar-mobile-drawer-max-height, calc(100dvh - 5rem - 12px)), calc(100dvh - 12px))',
|
|
@@ -101,7 +102,7 @@ export function PublicMobileDrawer(props: PublicMobileDrawerProps = {}) {
|
|
|
101
102
|
)}
|
|
102
103
|
|
|
103
104
|
{isAuthenticated && (
|
|
104
|
-
<UserMenu variant="mobile" groups={userMenu?.groups} authPath={userMenu?.authPath} />
|
|
105
|
+
<UserMenu variant="mobile" groups={userMenu?.groups} authPath={userMenu?.authPath} i18n={userMenu?.i18n} />
|
|
105
106
|
)}
|
|
106
107
|
|
|
107
108
|
{/* Navigation Items */}
|
|
@@ -112,7 +113,10 @@ export function PublicMobileDrawer(props: PublicMobileDrawerProps = {}) {
|
|
|
112
113
|
</h3>
|
|
113
114
|
</div>
|
|
114
115
|
<div className="space-y-1">
|
|
115
|
-
{mobileNavigation.map((item) =>
|
|
116
|
+
{mobileNavigation.map((item) => {
|
|
117
|
+
const childItems = item.items ?? [];
|
|
118
|
+
const hasChildNav = childItems.length > 0;
|
|
119
|
+
return (
|
|
116
120
|
<div key={item.href}>
|
|
117
121
|
<Link
|
|
118
122
|
href={item.href}
|
|
@@ -125,9 +129,9 @@ export function PublicMobileDrawer(props: PublicMobileDrawerProps = {}) {
|
|
|
125
129
|
>
|
|
126
130
|
{item.label}
|
|
127
131
|
</Link>
|
|
128
|
-
{
|
|
132
|
+
{hasChildNav && (
|
|
129
133
|
<div className="ml-3 mt-1 space-y-1 border-l border-border/40 pl-3">
|
|
130
|
-
{
|
|
134
|
+
{childItems.map((subItem) => (
|
|
131
135
|
<Link
|
|
132
136
|
key={`${item.href}-${subItem.href}`}
|
|
133
137
|
href={subItem.href}
|
|
@@ -140,7 +144,8 @@ export function PublicMobileDrawer(props: PublicMobileDrawerProps = {}) {
|
|
|
140
144
|
</div>
|
|
141
145
|
)}
|
|
142
146
|
</div>
|
|
143
|
-
)
|
|
147
|
+
);
|
|
148
|
+
})}
|
|
144
149
|
</div>
|
|
145
150
|
</div>
|
|
146
151
|
</div>
|
|
@@ -6,11 +6,8 @@ import { PublicMobileDrawer } from './PublicMobileDrawer';
|
|
|
6
6
|
import { PublicNavigation } from './PublicNavigation';
|
|
7
7
|
|
|
8
8
|
import type { NavigationItem, UserMenuConfig } from '../../types';
|
|
9
|
-
import type {
|
|
10
|
-
|
|
11
|
-
PublicNavbarPosition,
|
|
12
|
-
PublicNavbarVariant,
|
|
13
|
-
} from './PublicNavigation';
|
|
9
|
+
import type { PublicDesktopDropdownRenderer } from './PublicNavigation';
|
|
10
|
+
import type { PublicNavbarPosition, PublicNavbarVariant } from '../navbarTypes';
|
|
14
11
|
|
|
15
12
|
export interface PublicNavbarConfig {
|
|
16
13
|
brand?: React.ReactNode;
|
|
@@ -8,7 +8,14 @@
|
|
|
8
8
|
|
|
9
9
|
import { ChevronDown, Menu, X } from 'lucide-react';
|
|
10
10
|
import Link from 'next/link';
|
|
11
|
-
import React, {
|
|
11
|
+
import React, {
|
|
12
|
+
type ReactNode,
|
|
13
|
+
useEffect,
|
|
14
|
+
useLayoutEffect,
|
|
15
|
+
useMemo,
|
|
16
|
+
useRef,
|
|
17
|
+
useState,
|
|
18
|
+
} from 'react';
|
|
12
19
|
|
|
13
20
|
import { useAuth } from '@djangocfg/api/auth';
|
|
14
21
|
import { useAppT } from '@djangocfg/i18n';
|
|
@@ -22,11 +29,11 @@ import { usePathnameWithoutLocale } from '../../../hooks';
|
|
|
22
29
|
|
|
23
30
|
import { UserMenu } from '../../_components/UserMenu';
|
|
24
31
|
import { usePublicLayoutOptional } from '../context';
|
|
32
|
+
import type { PublicNavbarPosition, PublicNavbarVariant } from '../navbarTypes';
|
|
25
33
|
|
|
26
34
|
import type { NavigationItem, UserMenuConfig } from '../../types';
|
|
27
35
|
|
|
28
|
-
export type PublicNavbarVariant
|
|
29
|
-
export type PublicNavbarPosition = 'sticky' | 'fixed' | 'static';
|
|
36
|
+
export type { PublicNavbarPosition, PublicNavbarVariant } from '../navbarTypes';
|
|
30
37
|
|
|
31
38
|
export interface PublicDesktopDropdownRenderProps {
|
|
32
39
|
item: NavigationItem;
|
|
@@ -131,6 +138,16 @@ export function PublicNavigation(props: PublicNavigationProps = {}) {
|
|
|
131
138
|
}
|
|
132
139
|
}, [isTabletOrBelow]);
|
|
133
140
|
|
|
141
|
+
const setNavbarSurface = context?.setNavbarSurface;
|
|
142
|
+
|
|
143
|
+
useLayoutEffect(() => {
|
|
144
|
+
if (!setNavbarSurface) return;
|
|
145
|
+
setNavbarSurface({ variant: navbarVariant, position: navbarPosition });
|
|
146
|
+
return () => {
|
|
147
|
+
setNavbarSurface(null);
|
|
148
|
+
};
|
|
149
|
+
}, [setNavbarSurface, navbarVariant, navbarPosition]);
|
|
150
|
+
|
|
134
151
|
useEffect(() => {
|
|
135
152
|
const updateDrawerViewportVars = () => {
|
|
136
153
|
const root = document.documentElement;
|
|
@@ -297,7 +314,7 @@ export function PublicNavigation(props: PublicNavigationProps = {}) {
|
|
|
297
314
|
className={navInnerClassName}
|
|
298
315
|
style={{ backgroundColor: 'hsl(var(--background) / 0.72)', backdropFilter: 'blur(10px)' }}
|
|
299
316
|
>
|
|
300
|
-
<div className="w-full
|
|
317
|
+
<div className="w-full pl-6 pr-3 sm:px-4 lg:px-6">
|
|
301
318
|
<div className="flex items-center justify-between py-3.5">
|
|
302
319
|
{/* Logo */}
|
|
303
320
|
{brand ? (
|
|
@@ -373,6 +390,7 @@ export function PublicNavigation(props: PublicNavigationProps = {}) {
|
|
|
373
390
|
variant="desktop"
|
|
374
391
|
groups={userMenu?.groups}
|
|
375
392
|
authPath={userMenu?.authPath}
|
|
393
|
+
i18n={userMenu?.i18n}
|
|
376
394
|
/>
|
|
377
395
|
</>
|
|
378
396
|
</div>
|
|
@@ -2,10 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { createContext, useContext } from 'react';
|
|
4
4
|
|
|
5
|
+
import type { PublicNavbarSurface } from './navbarTypes';
|
|
6
|
+
|
|
5
7
|
export interface PublicLayoutContextValue {
|
|
6
8
|
mobileMenuOpen: boolean;
|
|
7
9
|
toggleMobileMenu: () => void;
|
|
8
10
|
closeMobileMenu: () => void;
|
|
11
|
+
/** Filled by `<PublicNavigation />` — drives default `main` top spacing. */
|
|
12
|
+
navbarSurface: PublicNavbarSurface | null;
|
|
13
|
+
setNavbarSurface: (surface: PublicNavbarSurface | null) => void;
|
|
9
14
|
}
|
|
10
15
|
|
|
11
16
|
const PublicLayoutContext = createContext<PublicLayoutContextValue | null>(null);
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { useMobileNavPanel } from './useMobileNavPanel';
|
|
2
2
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
/** Slightly above `transition-[transform,opacity] duration-[220ms]` in PublicMobileDrawer */
|
|
6
|
+
const CLOSE_UNMOUNT_MS = 230;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Keeps the mobile nav panel mounted briefly after `isOpen` becomes false so CSS can run the close transition,
|
|
10
|
+
* then unmounts — no reliance on `transitionend` (fragile with SPA navigation / Safari).
|
|
11
|
+
*/
|
|
12
|
+
export function useMobileNavPanel(options: { isOpen: boolean; onClose: () => void }) {
|
|
13
|
+
const { isOpen, onClose } = options;
|
|
14
|
+
const [mounted, setMounted] = useState(isOpen);
|
|
15
|
+
const [visible, setVisible] = useState(isOpen);
|
|
16
|
+
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (isOpen) {
|
|
20
|
+
if (closeTimerRef.current) {
|
|
21
|
+
clearTimeout(closeTimerRef.current);
|
|
22
|
+
closeTimerRef.current = null;
|
|
23
|
+
}
|
|
24
|
+
setMounted(true);
|
|
25
|
+
const id = requestAnimationFrame(() => {
|
|
26
|
+
requestAnimationFrame(() => setVisible(true));
|
|
27
|
+
});
|
|
28
|
+
return () => cancelAnimationFrame(id);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
setVisible(false);
|
|
32
|
+
closeTimerRef.current = setTimeout(() => {
|
|
33
|
+
setMounted((m) => (m ? false : m));
|
|
34
|
+
closeTimerRef.current = null;
|
|
35
|
+
}, CLOSE_UNMOUNT_MS);
|
|
36
|
+
|
|
37
|
+
return () => {
|
|
38
|
+
if (closeTimerRef.current) {
|
|
39
|
+
clearTimeout(closeTimerRef.current);
|
|
40
|
+
closeTimerRef.current = null;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}, [isOpen]);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!isOpen) return;
|
|
47
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
48
|
+
if (event.key === 'Escape') onClose();
|
|
49
|
+
};
|
|
50
|
+
window.addEventListener('keydown', onKeyDown);
|
|
51
|
+
return () => window.removeEventListener('keydown', onKeyDown);
|
|
52
|
+
}, [isOpen, onClose]);
|
|
53
|
+
|
|
54
|
+
return { mounted, visible };
|
|
55
|
+
}
|
|
@@ -6,8 +6,11 @@ export { PublicLayout } from './PublicLayout';
|
|
|
6
6
|
export type { PublicLayoutProps } from './PublicLayout';
|
|
7
7
|
export { PublicNavigation, PublicMobileDrawer, PublicNavbar } from './components';
|
|
8
8
|
export type {
|
|
9
|
+
PublicNavbarSurface,
|
|
9
10
|
PublicNavbarVariant,
|
|
10
11
|
PublicNavbarPosition,
|
|
12
|
+
} from './navbarTypes';
|
|
13
|
+
export type {
|
|
11
14
|
PublicDesktopDropdownRenderer,
|
|
12
15
|
PublicDesktopDropdownRenderProps,
|
|
13
16
|
} from './components/PublicNavigation';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared navbar surface types — used by PublicNavigation (registration) and PublicLayout (main offset).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type PublicNavbarVariant = 'floating' | 'flush';
|
|
6
|
+
|
|
7
|
+
export type PublicNavbarPosition = 'sticky' | 'fixed' | 'static';
|
|
8
|
+
|
|
9
|
+
export interface PublicNavbarSurface {
|
|
10
|
+
variant: PublicNavbarVariant;
|
|
11
|
+
position: PublicNavbarPosition;
|
|
12
|
+
}
|
|
@@ -30,6 +30,17 @@ import { LocaleSwitcher } from './LocaleSwitcher';
|
|
|
30
30
|
import type { I18nLayoutConfig } from '../AppLayout/AppLayout';
|
|
31
31
|
import type { HeaderConfig } from '../PrivateLayout/PrivateLayout';
|
|
32
32
|
|
|
33
|
+
/** Radix portals (dropdown, select, popover) render outside the account node — ignore those clicks for “outside”. */
|
|
34
|
+
function isPointerFromRadixOverlay(target: EventTarget | null): boolean {
|
|
35
|
+
if (!target || !(target instanceof Element)) return false;
|
|
36
|
+
return Boolean(
|
|
37
|
+
target.closest('[data-radix-popper-content-wrapper]') ||
|
|
38
|
+
target.closest('[data-radix-dropdown-menu-content]') ||
|
|
39
|
+
target.closest('[data-radix-select-content]') ||
|
|
40
|
+
target.closest('[data-radix-popover-content]'),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
33
44
|
interface PrivateSidebarAccountProps {
|
|
34
45
|
header?: HeaderConfig;
|
|
35
46
|
i18n?: I18nLayoutConfig;
|
|
@@ -53,9 +64,11 @@ export function PrivateSidebarAccount({ header, i18n }: PrivateSidebarAccountPro
|
|
|
53
64
|
|
|
54
65
|
const handlePointerDown = (event: PointerEvent) => {
|
|
55
66
|
const root = accountRootRef.current;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
67
|
+
const target = event.target;
|
|
68
|
+
if (!(target instanceof Node)) return;
|
|
69
|
+
if (root?.contains(target)) return;
|
|
70
|
+
if (isPointerFromRadixOverlay(target)) return;
|
|
71
|
+
setAccountOpen(false);
|
|
59
72
|
};
|
|
60
73
|
|
|
61
74
|
document.addEventListener('pointerdown', handlePointerDown);
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
|
|
31
31
|
'use client';
|
|
32
32
|
|
|
33
|
-
import { ArrowRight, LogOut } from 'lucide-react';
|
|
33
|
+
import { ArrowRight, Globe, LogOut } from 'lucide-react';
|
|
34
34
|
import Link from 'next/link';
|
|
35
35
|
import React, { useMemo } from 'react';
|
|
36
36
|
|
|
@@ -39,12 +39,26 @@ import { useAppT } from '@djangocfg/i18n';
|
|
|
39
39
|
|
|
40
40
|
import { useLogout } from '../../hooks';
|
|
41
41
|
import {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
Avatar,
|
|
43
|
+
AvatarFallback,
|
|
44
|
+
AvatarImage,
|
|
45
|
+
Button,
|
|
46
|
+
DropdownMenu,
|
|
47
|
+
DropdownMenuContent,
|
|
48
|
+
DropdownMenuGroup,
|
|
49
|
+
DropdownMenuItem,
|
|
50
|
+
DropdownMenuLabel,
|
|
51
|
+
DropdownMenuSeparator,
|
|
52
|
+
DropdownMenuSub,
|
|
53
|
+
DropdownMenuSubContent,
|
|
54
|
+
DropdownMenuSubTrigger,
|
|
55
|
+
DropdownMenuTrigger,
|
|
56
|
+
getLanguageFlag,
|
|
45
57
|
} from '@djangocfg/ui-core/components';
|
|
46
58
|
|
|
47
|
-
import
|
|
59
|
+
import { LOCALE_LABELS } from './LocaleSwitcher';
|
|
60
|
+
|
|
61
|
+
import type { UserMenuGroup, UserMenuLocaleConfig } from '../types';
|
|
48
62
|
|
|
49
63
|
export interface UserMenuProps {
|
|
50
64
|
/** Display variant */
|
|
@@ -53,12 +67,18 @@ export interface UserMenuProps {
|
|
|
53
67
|
groups?: UserMenuGroup[];
|
|
54
68
|
/** Auth page path (for sign in button) */
|
|
55
69
|
authPath?: string;
|
|
70
|
+
/**
|
|
71
|
+
* Language switching inside this menu (desktop: submenu; mobile: button row).
|
|
72
|
+
* Prefer this over nesting `<LocaleSwitcher />` in the same dropdown — nested menus close each other.
|
|
73
|
+
*/
|
|
74
|
+
i18n?: UserMenuLocaleConfig;
|
|
56
75
|
}
|
|
57
76
|
|
|
58
77
|
export function UserMenu({
|
|
59
78
|
variant = 'desktop',
|
|
60
79
|
groups,
|
|
61
80
|
authPath = '/auth',
|
|
81
|
+
i18n,
|
|
62
82
|
}: UserMenuProps) {
|
|
63
83
|
const { user, isAuthenticated } = useAuth();
|
|
64
84
|
const handleLogout = useLogout();
|
|
@@ -69,36 +89,43 @@ export function UserMenu({
|
|
|
69
89
|
signIn: t('layouts.profile.login'),
|
|
70
90
|
signOut: t('layouts.profile.signOut'),
|
|
71
91
|
userMenu: t('layouts.profile.userMenu'),
|
|
92
|
+
language: t('layouts.profile.language'),
|
|
72
93
|
}), [t]);
|
|
73
94
|
|
|
74
95
|
React.useEffect(() => {
|
|
75
96
|
setMounted(true);
|
|
76
97
|
}, []);
|
|
77
98
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
99
|
+
/** Profile links only; sign out rendered separately so we can insert locale UI between. */
|
|
100
|
+
const profileGroups: UserMenuGroup[] = React.useMemo(() => {
|
|
101
|
+
if (groups && groups.length > 0) return [...groups];
|
|
102
|
+
return [];
|
|
103
|
+
}, [groups]);
|
|
82
104
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
105
|
+
const signOutItem = React.useMemo(
|
|
106
|
+
() => ({
|
|
107
|
+
label: labels.signOut,
|
|
108
|
+
onClick: handleLogout,
|
|
109
|
+
icon: LogOut,
|
|
110
|
+
variant: 'destructive' as const,
|
|
111
|
+
}),
|
|
112
|
+
[handleLogout, labels.signOut],
|
|
113
|
+
);
|
|
87
114
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
});
|
|
115
|
+
/** Prepared locale UI data — avoid `i18n && i18n.locales.length` chains in JSX. */
|
|
116
|
+
const localeMenu = React.useMemo(() => {
|
|
117
|
+
const codes = i18n?.locales;
|
|
118
|
+
if (!i18n || !codes?.length) return null;
|
|
119
|
+
return {
|
|
120
|
+
codes,
|
|
121
|
+
current: i18n.locale,
|
|
122
|
+
onChange: i18n.onLocaleChange,
|
|
123
|
+
};
|
|
124
|
+
}, [i18n]);
|
|
99
125
|
|
|
100
|
-
|
|
101
|
-
|
|
126
|
+
const hasProfileGroups = profileGroups.length > 0;
|
|
127
|
+
|
|
128
|
+
const localeLabel = (code: string) => LOCALE_LABELS[code] || code.toUpperCase();
|
|
102
129
|
|
|
103
130
|
if (!mounted) {
|
|
104
131
|
return null;
|
|
@@ -151,7 +178,7 @@ export function UserMenu({
|
|
|
151
178
|
</div>
|
|
152
179
|
</div>
|
|
153
180
|
<div className="space-y-1">
|
|
154
|
-
{
|
|
181
|
+
{profileGroups.map((group, groupIndex) => (
|
|
155
182
|
<div key={groupIndex}>
|
|
156
183
|
{group.title && (
|
|
157
184
|
<div className="px-4 py-2">
|
|
@@ -195,6 +222,44 @@ export function UserMenu({
|
|
|
195
222
|
})}
|
|
196
223
|
</div>
|
|
197
224
|
))}
|
|
225
|
+
{localeMenu && (
|
|
226
|
+
<div className="border-t border-border/50 px-4 pt-3">
|
|
227
|
+
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
228
|
+
{labels.language}
|
|
229
|
+
</p>
|
|
230
|
+
<div className="mt-2 flex flex-wrap gap-2">
|
|
231
|
+
{localeMenu.codes.map((code) => {
|
|
232
|
+
const flag = getLanguageFlag(code);
|
|
233
|
+
const active = code === localeMenu.current;
|
|
234
|
+
return (
|
|
235
|
+
<button
|
|
236
|
+
key={code}
|
|
237
|
+
type="button"
|
|
238
|
+
onClick={() => localeMenu.onChange(code)}
|
|
239
|
+
className={`rounded-md border px-2.5 py-1.5 text-sm font-medium transition-colors ${
|
|
240
|
+
active
|
|
241
|
+
? 'border-primary bg-accent text-foreground'
|
|
242
|
+
: 'border-border/60 text-muted-foreground hover:bg-accent hover:text-foreground'
|
|
243
|
+
}`}
|
|
244
|
+
>
|
|
245
|
+
{flag ? <span className="mr-1.5">{flag}</span> : null}
|
|
246
|
+
{localeLabel(code)}
|
|
247
|
+
</button>
|
|
248
|
+
);
|
|
249
|
+
})}
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
)}
|
|
253
|
+
<div>
|
|
254
|
+
<button
|
|
255
|
+
type="button"
|
|
256
|
+
onClick={signOutItem.onClick}
|
|
257
|
+
className="flex w-full items-center gap-3 rounded-sm px-4 py-3 text-left text-sm font-medium text-destructive transition-colors hover:bg-destructive/10"
|
|
258
|
+
>
|
|
259
|
+
<LogOut className="h-4 w-4" />
|
|
260
|
+
{signOutItem.label}
|
|
261
|
+
</button>
|
|
262
|
+
</div>
|
|
198
263
|
</div>
|
|
199
264
|
</div>
|
|
200
265
|
);
|
|
@@ -222,7 +287,7 @@ export function UserMenu({
|
|
|
222
287
|
</div>
|
|
223
288
|
</DropdownMenuLabel>
|
|
224
289
|
<DropdownMenuSeparator />
|
|
225
|
-
{
|
|
290
|
+
{profileGroups.map((group, groupIndex) => (
|
|
226
291
|
<React.Fragment key={groupIndex}>
|
|
227
292
|
{groupIndex > 0 && <DropdownMenuSeparator />}
|
|
228
293
|
<DropdownMenuGroup>
|
|
@@ -267,6 +332,44 @@ export function UserMenu({
|
|
|
267
332
|
</DropdownMenuGroup>
|
|
268
333
|
</React.Fragment>
|
|
269
334
|
))}
|
|
335
|
+
{localeMenu && (
|
|
336
|
+
<>
|
|
337
|
+
{hasProfileGroups && <DropdownMenuSeparator />}
|
|
338
|
+
<DropdownMenuSub>
|
|
339
|
+
<DropdownMenuSubTrigger className="cursor-default">
|
|
340
|
+
<Globe className="mr-2 h-4 w-4" />
|
|
341
|
+
<span>{labels.language}</span>
|
|
342
|
+
</DropdownMenuSubTrigger>
|
|
343
|
+
<DropdownMenuSubContent>
|
|
344
|
+
{localeMenu.codes.map((code) => {
|
|
345
|
+
const flag = getLanguageFlag(code);
|
|
346
|
+
return (
|
|
347
|
+
<DropdownMenuItem
|
|
348
|
+
key={code}
|
|
349
|
+
onSelect={() => {
|
|
350
|
+
localeMenu.onChange(code);
|
|
351
|
+
}}
|
|
352
|
+
className={code === localeMenu.current ? 'bg-accent' : ''}
|
|
353
|
+
>
|
|
354
|
+
{flag ? <span className="mr-2">{flag}</span> : null}
|
|
355
|
+
{localeLabel(code)}
|
|
356
|
+
</DropdownMenuItem>
|
|
357
|
+
);
|
|
358
|
+
})}
|
|
359
|
+
</DropdownMenuSubContent>
|
|
360
|
+
</DropdownMenuSub>
|
|
361
|
+
</>
|
|
362
|
+
)}
|
|
363
|
+
<DropdownMenuSeparator />
|
|
364
|
+
<DropdownMenuGroup>
|
|
365
|
+
<DropdownMenuItem
|
|
366
|
+
onClick={signOutItem.onClick}
|
|
367
|
+
className="text-destructive focus:text-destructive"
|
|
368
|
+
>
|
|
369
|
+
<LogOut className="mr-2 h-4 w-4" />
|
|
370
|
+
<span>{signOutItem.label}</span>
|
|
371
|
+
</DropdownMenuItem>
|
|
372
|
+
</DropdownMenuGroup>
|
|
270
373
|
</DropdownMenuContent>
|
|
271
374
|
</DropdownMenu>
|
|
272
375
|
);
|
|
@@ -97,9 +97,18 @@ export interface UserMenuGroup {
|
|
|
97
97
|
items: UserMenuItem[];
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/** Optional locale switching inside the user menu (submenu on desktop; avoids nested `DropdownMenu` + portal issues). */
|
|
101
|
+
export interface UserMenuLocaleConfig {
|
|
102
|
+
locale: string;
|
|
103
|
+
locales: string[];
|
|
104
|
+
onLocaleChange: (locale: string) => void;
|
|
105
|
+
}
|
|
106
|
+
|
|
100
107
|
export interface UserMenuConfig {
|
|
101
108
|
/** Menu groups for authenticated users */
|
|
102
109
|
groups?: UserMenuGroup[];
|
|
103
110
|
/** Auth page path (for sign in button) */
|
|
104
111
|
authPath?: string;
|
|
112
|
+
/** When set, language is rendered inside the user menu (not as a separate nested dropdown). */
|
|
113
|
+
i18n?: UserMenuLocaleConfig;
|
|
105
114
|
}
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useEffect, useRef, useState } from 'react';
|
|
4
|
-
|
|
5
|
-
interface UseFloatingPanelOptions {
|
|
6
|
-
isOpen: boolean;
|
|
7
|
-
onClose: () => void;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export function useFloatingPanel({
|
|
11
|
-
isOpen,
|
|
12
|
-
onClose,
|
|
13
|
-
}: UseFloatingPanelOptions) {
|
|
14
|
-
const [isRendered, setIsRendered] = useState(isOpen);
|
|
15
|
-
const [isActive, setIsActive] = useState(isOpen);
|
|
16
|
-
const rafRef = useRef<number | null>(null);
|
|
17
|
-
|
|
18
|
-
useEffect(() => {
|
|
19
|
-
if (isOpen) {
|
|
20
|
-
setIsRendered(true);
|
|
21
|
-
if (rafRef.current) window.cancelAnimationFrame(rafRef.current);
|
|
22
|
-
rafRef.current = window.requestAnimationFrame(() => {
|
|
23
|
-
setIsActive(true);
|
|
24
|
-
});
|
|
25
|
-
return;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
if (!isRendered) return;
|
|
29
|
-
setIsActive(false);
|
|
30
|
-
}, [isOpen, isRendered]);
|
|
31
|
-
|
|
32
|
-
useEffect(() => {
|
|
33
|
-
return () => {
|
|
34
|
-
if (rafRef.current) window.cancelAnimationFrame(rafRef.current);
|
|
35
|
-
};
|
|
36
|
-
}, []);
|
|
37
|
-
|
|
38
|
-
useEffect(() => {
|
|
39
|
-
if (!isOpen) return;
|
|
40
|
-
const onKeyDown = (event: KeyboardEvent) => {
|
|
41
|
-
if (event.key === 'Escape') onClose();
|
|
42
|
-
};
|
|
43
|
-
window.addEventListener('keydown', onKeyDown);
|
|
44
|
-
return () => window.removeEventListener('keydown', onKeyDown);
|
|
45
|
-
}, [isOpen, onClose]);
|
|
46
|
-
|
|
47
|
-
const onTransitionEnd = (event: React.TransitionEvent<HTMLElement>) => {
|
|
48
|
-
if (event.target !== event.currentTarget) return;
|
|
49
|
-
if (event.propertyName !== 'transform') return;
|
|
50
|
-
if (!isOpen && !isActive) {
|
|
51
|
-
setIsRendered(false);
|
|
52
|
-
}
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
return {
|
|
56
|
-
isRendered,
|
|
57
|
-
isActive,
|
|
58
|
-
onTransitionEnd,
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
|