@djangocfg/layouts 2.1.604 → 2.1.605
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 +12 -12
- package/src/layouts/PrivateLayout/components/PrivateContent.tsx +5 -9
- package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +5 -2
- package/src/layouts/PrivateLayout/components/PrivateSidebarTrigger.tsx +45 -0
- package/src/layouts/PrivateLayout/components/SidebarBody.tsx +7 -2
- package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +7 -23
- package/src/layouts/PrivateLayout/components/SidebarBrandSwitcher.tsx +2 -2
- package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +10 -9
- package/src/layouts/PrivateLayout/components/SidebarPeek.tsx +31 -16
- package/src/layouts/PrivateLayout/components/SidebarSlots.tsx +2 -3
- package/src/layouts/PrivateLayout/geometry.ts +35 -2
- package/src/layouts/PrivateLayout/hooks/index.ts +4 -0
- package/src/layouts/PrivateLayout/hooks/useHoverIntent.test.tsx +100 -0
- package/src/layouts/PrivateLayout/hooks/useHoverIntent.ts +112 -0
- package/src/layouts/PrivateLayout/hooks/usePresence.ts +39 -0
- package/src/layouts/PrivateLayout/hooks/useSidebarPeek.tsx +30 -85
- package/src/styles/dashboard.css +27 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/layouts",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.605",
|
|
4
4
|
"description": "Simple, straightforward layout components for Next.js - import and use with props",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"layouts",
|
|
@@ -91,11 +91,11 @@
|
|
|
91
91
|
"test": "vitest run"
|
|
92
92
|
},
|
|
93
93
|
"peerDependencies": {
|
|
94
|
-
"@djangocfg/analytics": "^2.1.
|
|
95
|
-
"@djangocfg/api": "^2.1.
|
|
96
|
-
"@djangocfg/devtools": "^2.1.
|
|
97
|
-
"@djangocfg/i18n": "^2.1.
|
|
98
|
-
"@djangocfg/ui-core": "^2.1.
|
|
94
|
+
"@djangocfg/analytics": "^2.1.605",
|
|
95
|
+
"@djangocfg/api": "^2.1.605",
|
|
96
|
+
"@djangocfg/devtools": "^2.1.605",
|
|
97
|
+
"@djangocfg/i18n": "^2.1.605",
|
|
98
|
+
"@djangocfg/ui-core": "^2.1.605",
|
|
99
99
|
"@hookform/resolvers": "^5.2.2",
|
|
100
100
|
"consola": "^3.4.2",
|
|
101
101
|
"lucide-react": "^0.545.0",
|
|
@@ -125,12 +125,12 @@
|
|
|
125
125
|
"uuid": "^11.1.1"
|
|
126
126
|
},
|
|
127
127
|
"devDependencies": {
|
|
128
|
-
"@djangocfg/analytics": "^2.1.
|
|
129
|
-
"@djangocfg/api": "^2.1.
|
|
130
|
-
"@djangocfg/devtools": "^2.1.
|
|
131
|
-
"@djangocfg/i18n": "^2.1.
|
|
132
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
133
|
-
"@djangocfg/ui-core": "^2.1.
|
|
128
|
+
"@djangocfg/analytics": "^2.1.605",
|
|
129
|
+
"@djangocfg/api": "^2.1.605",
|
|
130
|
+
"@djangocfg/devtools": "^2.1.605",
|
|
131
|
+
"@djangocfg/i18n": "^2.1.605",
|
|
132
|
+
"@djangocfg/typescript-config": "^2.1.605",
|
|
133
|
+
"@djangocfg/ui-core": "^2.1.605",
|
|
134
134
|
"@storybook/react-vite": "^10.5.0",
|
|
135
135
|
"@types/node": "^25.9.5",
|
|
136
136
|
"@types/react": "19.2.15",
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Private layout main column: the scroll surface, plus the sidebar trigger
|
|
3
3
|
* whenever the sidebar is not docked (collapsed on desktop, always on mobile).
|
|
4
|
-
* On desktop the trigger sits in a hover zone that peeks the sidebar open.
|
|
4
|
+
* On desktop (fine pointer) the trigger sits in a hover zone that peeks the sidebar open after a short intent delay.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
'use client';
|
|
8
8
|
|
|
9
9
|
import React, { ReactNode } from 'react';
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { useSidebar } from '@djangocfg/ui-core/components';
|
|
12
12
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
13
13
|
|
|
14
14
|
import type { LayoutVisualConfig } from '../../types';
|
|
15
15
|
import { useSidebarPeek } from '../hooks';
|
|
16
|
+
import { PrivateSidebarTrigger } from './PrivateSidebarTrigger';
|
|
16
17
|
|
|
17
18
|
interface PrivateContentProps {
|
|
18
19
|
children: ReactNode;
|
|
@@ -73,15 +74,10 @@ export function PrivateContent({
|
|
|
73
74
|
{showTrigger ? (
|
|
74
75
|
<div
|
|
75
76
|
data-sidebar="trigger-zone"
|
|
76
|
-
{...(isMobile ? undefined : peek
|
|
77
|
+
{...(isMobile || !peek?.hoverEnabled ? undefined : peek.zoneProps)}
|
|
77
78
|
className="absolute left-0 top-[env(safe-area-inset-top,0px)] z-20 flex size-14 items-center justify-center"
|
|
78
79
|
>
|
|
79
|
-
<
|
|
80
|
-
className={cn(
|
|
81
|
-
'text-muted-foreground hover:text-foreground',
|
|
82
|
-
isMobile ? 'size-11 [&_svg]:!size-5' : 'size-8',
|
|
83
|
-
)}
|
|
84
|
-
/>
|
|
80
|
+
<PrivateSidebarTrigger className="text-muted-foreground hover:text-foreground" />
|
|
85
81
|
</div>
|
|
86
82
|
) : null}
|
|
87
83
|
<div className={scrollAreaClass}>
|
|
@@ -30,6 +30,7 @@ import { useSidebar } from '@djangocfg/ui-core/components';
|
|
|
30
30
|
import { LucideIcon as LucideIconRender } from '../../../components';
|
|
31
31
|
import { usePeekPin } from '../hooks';
|
|
32
32
|
import { usePrivateLayoutContext } from '../context';
|
|
33
|
+
import { SIDEBAR_SPACING } from '../geometry';
|
|
33
34
|
import { useAccountMenuItems } from '../../SettingsLayout/hooks';
|
|
34
35
|
|
|
35
36
|
import type { HeaderConfig } from '../types';
|
|
@@ -170,8 +171,10 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
|
|
|
170
171
|
</Button>
|
|
171
172
|
);
|
|
172
173
|
|
|
173
|
-
const wrapperClass =
|
|
174
|
-
'flex w-full min-w-0 shrink-0 items-center justify-between gap-1 border-t border-sidebar-border/60
|
|
174
|
+
const wrapperClass = cn(
|
|
175
|
+
'flex w-full min-w-0 shrink-0 items-center justify-between gap-1 border-t border-sidebar-border/60',
|
|
176
|
+
SIDEBAR_SPACING[isMobile ? 'mobile' : 'desktop'].footer,
|
|
177
|
+
);
|
|
175
178
|
|
|
176
179
|
return (
|
|
177
180
|
<div className={wrapperClass}>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one sidebar button: same box, icon and position rules on mobile and desktop.
|
|
3
|
+
* Floating over the content it opens the sidebar; inside the sidebar header it closes or
|
|
4
|
+
* collapses it. Both are `toggleSidebar`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use client';
|
|
8
|
+
|
|
9
|
+
import React from 'react';
|
|
10
|
+
import { PanelLeft } from 'lucide-react';
|
|
11
|
+
|
|
12
|
+
import { Button, useSidebar } from '@djangocfg/ui-core/components';
|
|
13
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
14
|
+
|
|
15
|
+
import { TRIGGER_CLASS } from '../geometry';
|
|
16
|
+
|
|
17
|
+
export const PrivateSidebarTrigger = React.forwardRef<
|
|
18
|
+
React.ElementRef<typeof Button>,
|
|
19
|
+
React.ComponentProps<typeof Button>
|
|
20
|
+
>(({ className, onClick, ...props }, ref) => {
|
|
21
|
+
const { toggleSidebar, isMobile, openMobile, open } = useSidebar();
|
|
22
|
+
const isOpen = isMobile ? openMobile : open;
|
|
23
|
+
const label = isOpen ? 'Close sidebar' : 'Open sidebar';
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<Button
|
|
27
|
+
ref={ref}
|
|
28
|
+
type="button"
|
|
29
|
+
data-sidebar="trigger"
|
|
30
|
+
variant="ghost"
|
|
31
|
+
size="icon"
|
|
32
|
+
title={label}
|
|
33
|
+
aria-label={label}
|
|
34
|
+
className={cn(TRIGGER_CLASS, className)}
|
|
35
|
+
onClick={(event) => {
|
|
36
|
+
onClick?.(event);
|
|
37
|
+
toggleSidebar();
|
|
38
|
+
}}
|
|
39
|
+
{...props}
|
|
40
|
+
>
|
|
41
|
+
<PanelLeft aria-hidden />
|
|
42
|
+
</Button>
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
PrivateSidebarTrigger.displayName = 'PrivateSidebarTrigger';
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
import React, { useMemo } from 'react';
|
|
9
9
|
|
|
10
10
|
import { usePrivateLayoutContext } from '../context';
|
|
11
|
+
import { SIDEBAR_SPACING } from '../geometry';
|
|
12
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
11
13
|
import { sidebarItemKey } from '../sidebarItems';
|
|
12
14
|
import { PrivateSidebarAccount } from './PrivateSidebarAccount';
|
|
13
15
|
import { SidebarBrand } from './SidebarBrand';
|
|
@@ -15,7 +17,7 @@ import { SidebarNavGroup } from './SidebarNavGroup';
|
|
|
15
17
|
import { SidebarSlots } from './SidebarSlots';
|
|
16
18
|
|
|
17
19
|
export function SidebarBody() {
|
|
18
|
-
const { sidebar, header } = usePrivateLayoutContext();
|
|
20
|
+
const { sidebar, header, isMobile } = usePrivateLayoutContext();
|
|
19
21
|
|
|
20
22
|
const groups = useMemo(
|
|
21
23
|
() =>
|
|
@@ -36,7 +38,10 @@ export function SidebarBody() {
|
|
|
36
38
|
<SidebarBrand />
|
|
37
39
|
<div
|
|
38
40
|
data-sidebar="content"
|
|
39
|
-
className=
|
|
41
|
+
className={cn(
|
|
42
|
+
'flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto overflow-x-hidden',
|
|
43
|
+
SIDEBAR_SPACING[isMobile ? 'mobile' : 'desktop'].body,
|
|
44
|
+
)}
|
|
40
45
|
>
|
|
41
46
|
<SidebarSlots />
|
|
42
47
|
{groups}
|
|
@@ -6,13 +6,14 @@
|
|
|
6
6
|
'use client';
|
|
7
7
|
|
|
8
8
|
import React, { memo } from 'react';
|
|
9
|
-
import { X } from 'lucide-react';
|
|
10
9
|
|
|
11
|
-
import {
|
|
10
|
+
import { Link } from '@djangocfg/ui-core/components';
|
|
12
11
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
13
12
|
|
|
14
13
|
import { LucideIcon } from '../../../components';
|
|
15
14
|
import { usePrivateLayoutContext } from '../context';
|
|
15
|
+
import { SIDEBAR_SPACING } from '../geometry';
|
|
16
|
+
import { PrivateSidebarTrigger } from './PrivateSidebarTrigger';
|
|
16
17
|
import { SidebarBrandSwitcher } from './SidebarBrandSwitcher';
|
|
17
18
|
|
|
18
19
|
const BRAND_LINK =
|
|
@@ -20,7 +21,6 @@ const BRAND_LINK =
|
|
|
20
21
|
|
|
21
22
|
function SidebarBrandRaw() {
|
|
22
23
|
const { header, homeHref, brandTitle, brandMonogram, isMobile } = usePrivateLayoutContext();
|
|
23
|
-
const { setOpenMobile } = useSidebar();
|
|
24
24
|
|
|
25
25
|
const customBrand = header?.brand;
|
|
26
26
|
|
|
@@ -55,33 +55,17 @@ function SidebarBrandRaw() {
|
|
|
55
55
|
</Link>
|
|
56
56
|
);
|
|
57
57
|
|
|
58
|
-
const trailing =
|
|
59
|
-
<
|
|
60
|
-
type="button"
|
|
61
|
-
variant="ghost"
|
|
62
|
-
size="icon"
|
|
63
|
-
className="size-11 shrink-0 text-sidebar-foreground/70"
|
|
64
|
-
aria-label="Close menu"
|
|
65
|
-
onClick={() => setOpenMobile(false)}
|
|
66
|
-
>
|
|
67
|
-
<X className="size-5" />
|
|
68
|
-
</Button>
|
|
69
|
-
) : header?.switcher ? null : (
|
|
70
|
-
<SidebarTrigger className="shrink-0 text-sidebar-foreground/60 hover:text-sidebar-foreground" />
|
|
58
|
+
const trailing = header?.switcher ? null : (
|
|
59
|
+
<PrivateSidebarTrigger className="text-sidebar-foreground/60 hover:text-sidebar-foreground" />
|
|
71
60
|
);
|
|
72
61
|
|
|
73
62
|
return (
|
|
74
63
|
<div
|
|
75
64
|
data-sidebar="header"
|
|
76
|
-
className={cn(
|
|
77
|
-
'flex shrink-0 items-center gap-2',
|
|
78
|
-
isMobile
|
|
79
|
-
? 'px-4 pb-2 pt-[max(0.5rem,env(safe-area-inset-top,0px))]'
|
|
80
|
-
: 'px-3 pb-1 pt-3',
|
|
81
|
-
)}
|
|
65
|
+
className={cn('flex shrink-0 items-center gap-2', SIDEBAR_SPACING[isMobile ? 'mobile' : 'desktop'].header)}
|
|
82
66
|
>
|
|
83
67
|
<div className="min-w-0 flex-1">
|
|
84
|
-
{header?.switcher ? <SidebarBrandSwitcher config={header.switcher} showCollapseTrigger
|
|
68
|
+
{header?.switcher ? <SidebarBrandSwitcher config={header.switcher} showCollapseTrigger /> : brand}
|
|
85
69
|
</div>
|
|
86
70
|
{trailing}
|
|
87
71
|
</div>
|
|
@@ -20,12 +20,12 @@ import {
|
|
|
20
20
|
DropdownMenuItem,
|
|
21
21
|
DropdownMenuSeparator,
|
|
22
22
|
DropdownMenuTrigger,
|
|
23
|
-
SidebarTrigger,
|
|
24
23
|
} from '@djangocfg/ui-core/components';
|
|
25
24
|
import { Link } from '@djangocfg/ui-core/components';
|
|
26
25
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
27
26
|
|
|
28
27
|
import { usePeekPin } from '../hooks';
|
|
28
|
+
import { PrivateSidebarTrigger } from './PrivateSidebarTrigger';
|
|
29
29
|
import type { SidebarBrandSwitcherConfig, SidebarBrandSwitcherItem } from '../types';
|
|
30
30
|
|
|
31
31
|
interface SidebarBrandSwitcherProps {
|
|
@@ -125,7 +125,7 @@ function SidebarBrandSwitcherRaw({ config, showCollapseTrigger }: SidebarBrandSw
|
|
|
125
125
|
</DropdownMenu>
|
|
126
126
|
|
|
127
127
|
{showCollapseTrigger ? (
|
|
128
|
-
<
|
|
128
|
+
<PrivateSidebarTrigger data-no-expand />
|
|
129
129
|
) : null}
|
|
130
130
|
</div>
|
|
131
131
|
);
|
|
@@ -27,22 +27,23 @@ import { isSettingsItem, isSidebarGroupVisible, sidebarItemKey, visibleSidebarIt
|
|
|
27
27
|
import type { SidebarGroupConfig } from '../types';
|
|
28
28
|
import { SidebarNavItem } from './SidebarNavItem';
|
|
29
29
|
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
)
|
|
30
|
+
const groupClassFor = (inset: string) =>
|
|
31
|
+
cn(
|
|
32
|
+
`gap-0 ${inset} py-1`,
|
|
33
|
+
'[&:not(:first-child)]:mt-1 [&:not(:first-child)]:border-t [&:not(:first-child)]:border-sidebar-border/60 [&:not(:first-child)]:pt-2',
|
|
34
|
+
);
|
|
34
35
|
|
|
35
36
|
/** A `separated` group draws its own, stronger divider (below), so it drops the hairline and takes more air. */
|
|
36
|
-
const
|
|
37
|
+
const separatedGroupClassFor = (inset: string) => `gap-0 ${inset} pb-1 pt-0`;
|
|
37
38
|
|
|
38
39
|
/** Full-width rule with room above and below; decorative, so the rail and the drawer read the same. */
|
|
39
|
-
function GroupDivider() {
|
|
40
|
+
function GroupDivider({ inset }: { inset: string }) {
|
|
40
41
|
return (
|
|
41
42
|
<div
|
|
42
43
|
role="separator"
|
|
43
44
|
aria-orientation="horizontal"
|
|
44
45
|
data-sidebar="group-divider"
|
|
45
|
-
className=
|
|
46
|
+
className={cn(inset, 'mb-2 mt-3 h-px shrink-0 bg-sidebar-border')}
|
|
46
47
|
/>
|
|
47
48
|
);
|
|
48
49
|
}
|
|
@@ -83,8 +84,8 @@ function SidebarNavGroupRaw({ group }: { group: SidebarGroupConfig }) {
|
|
|
83
84
|
));
|
|
84
85
|
if (!visible) return null;
|
|
85
86
|
|
|
86
|
-
const groupClass = group.separated ?
|
|
87
|
-
const divider = group.separated ? <GroupDivider /> : null;
|
|
87
|
+
const groupClass = group.separated ? separatedGroupClassFor(geometry.inset) : groupClassFor(geometry.inset);
|
|
88
|
+
const divider = group.separated ? <GroupDivider inset={geometry.dividerInset} /> : null;
|
|
88
89
|
|
|
89
90
|
if (isCollapsible) {
|
|
90
91
|
return (
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Peek panel: the full sidebar floated over the page while it is collapsed.
|
|
3
|
-
* Opened
|
|
3
|
+
* Opened by hover intent on the left edge (or the trigger zone), held by `SidebarPeekProvider`,
|
|
4
|
+
* slides in and out (it stays mounted while the exit animation runs).
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
'use client';
|
|
@@ -13,22 +14,36 @@ import { SidebarResizeHandle } from './SidebarResizeHandle';
|
|
|
13
14
|
|
|
14
15
|
export function SidebarPeek() {
|
|
15
16
|
const peek = useSidebarPeek();
|
|
16
|
-
if (!peek
|
|
17
|
+
if (!peek) return null;
|
|
17
18
|
|
|
18
19
|
return (
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
20
|
+
<>
|
|
21
|
+
{peek.hoverEnabled ? (
|
|
22
|
+
// Thin hot strip on the left edge: resting the pointer here opens the panel.
|
|
23
|
+
<div
|
|
24
|
+
data-sidebar="peek-edge"
|
|
25
|
+
aria-hidden
|
|
26
|
+
className="fixed inset-y-0 left-0 z-40 w-1.5"
|
|
27
|
+
{...peek.zoneProps}
|
|
28
|
+
/>
|
|
29
|
+
) : null}
|
|
30
|
+
{peek.mounted ? (
|
|
31
|
+
<div
|
|
32
|
+
data-sidebar="peek"
|
|
33
|
+
data-state={peek.state}
|
|
34
|
+
role="complementary"
|
|
35
|
+
aria-label="Navigation"
|
|
36
|
+
inert={peek.state === 'closed'}
|
|
37
|
+
{...peek.panelProps}
|
|
38
|
+
className={[
|
|
39
|
+
'private-sidebar-peek fixed bottom-2 left-2 top-2 z-50 flex w-(--sidebar-width) flex-col overflow-hidden',
|
|
40
|
+
'rounded-xl border border-sidebar-border bg-sidebar text-sidebar-foreground shadow-2xl',
|
|
41
|
+
].join(' ')}
|
|
42
|
+
>
|
|
43
|
+
<SidebarBody />
|
|
44
|
+
<SidebarResizeHandle />
|
|
45
|
+
</div>
|
|
46
|
+
) : null}
|
|
47
|
+
</>
|
|
33
48
|
);
|
|
34
49
|
}
|
|
@@ -9,11 +9,10 @@ import React, { memo } from 'react';
|
|
|
9
9
|
import { usePrivateLayoutContext } from '../context';
|
|
10
10
|
import { SidebarFeatured } from './SidebarFeatured';
|
|
11
11
|
|
|
12
|
-
const SLOT = 'w-full min-w-0 shrink-0 px-2';
|
|
13
|
-
|
|
14
12
|
function SidebarSlotsRaw() {
|
|
15
|
-
const { sidebar } = usePrivateLayoutContext();
|
|
13
|
+
const { sidebar, geometry } = usePrivateLayoutContext();
|
|
16
14
|
if (!sidebar) return null;
|
|
15
|
+
const SLOT = `w-full min-w-0 shrink-0 ${geometry.inset}`;
|
|
17
16
|
|
|
18
17
|
return (
|
|
19
18
|
<>
|
|
@@ -1,17 +1,50 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Sidebar
|
|
3
|
-
*
|
|
2
|
+
* Sidebar geometry. Row sizes (a pointer-sized desktop row, a 44px touch row for the mobile
|
|
3
|
+
* drawer), one horizontal inset shared by every block, one trigger size, and the motion timing.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
export const NAV_GEOMETRY = {
|
|
7
7
|
desktop: {
|
|
8
8
|
item: 'h-8 gap-2.5 px-2.5 text-base [&>svg]:size-4',
|
|
9
9
|
label: 'h-7 px-2.5 text-xs',
|
|
10
|
+
/** Horizontal inset of groups, slots and the divider; equals the header and footer inset. */
|
|
11
|
+
inset: 'px-2',
|
|
12
|
+
dividerInset: 'mx-2',
|
|
10
13
|
},
|
|
11
14
|
mobile: {
|
|
12
15
|
item: 'h-11 gap-3 px-3 text-lg [&>svg]:size-5',
|
|
13
16
|
label: 'h-8 px-3 text-sm',
|
|
17
|
+
inset: 'px-3',
|
|
18
|
+
dividerInset: 'mx-3',
|
|
14
19
|
},
|
|
15
20
|
} as const;
|
|
16
21
|
|
|
17
22
|
export type NavGeometry = (typeof NAV_GEOMETRY)[keyof typeof NAV_GEOMETRY];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* One spacing scale for the three blocks of the sidebar (header, list, footer). On mobile every
|
|
26
|
+
* block uses the same 0.75rem unit on the sides and the safe-area aware edge; the list gives
|
|
27
|
+
* 0.5rem between blocks.
|
|
28
|
+
*/
|
|
29
|
+
export const SIDEBAR_SPACING = {
|
|
30
|
+
desktop: {
|
|
31
|
+
header: 'px-3 pb-1 pt-3',
|
|
32
|
+
body: 'py-2',
|
|
33
|
+
footer: 'p-2 pb-[max(0.5rem,env(safe-area-inset-bottom,0px))]',
|
|
34
|
+
},
|
|
35
|
+
mobile: {
|
|
36
|
+
header: 'px-3 pb-2 pt-[max(0.75rem,env(safe-area-inset-top,0px))]',
|
|
37
|
+
body: 'py-2',
|
|
38
|
+
footer: 'p-3 pb-[max(0.75rem,env(safe-area-inset-bottom,0px))]',
|
|
39
|
+
},
|
|
40
|
+
} as const;
|
|
41
|
+
|
|
42
|
+
/** The one trigger button: same box and icon size on mobile and desktop. */
|
|
43
|
+
export const TRIGGER_CLASS = 'size-10 shrink-0 [&_svg]:!size-5';
|
|
44
|
+
|
|
45
|
+
/** Slide duration of the peek panel; keep equal to `--private-sidebar-motion` in dashboard.css. */
|
|
46
|
+
export const SIDEBAR_MOTION_MS = 180;
|
|
47
|
+
|
|
48
|
+
/** Desktop hover-open timing: intent delay before opening, grace before closing. */
|
|
49
|
+
export const HOVER_OPEN_DELAY_MS = 400;
|
|
50
|
+
export const HOVER_CLOSE_DELAY_MS = 250;
|
|
@@ -8,3 +8,7 @@ export { useSidebarKeyboard } from './useSidebarKeyboard';
|
|
|
8
8
|
export { useSidebarDefaultOpen } from './useSidebarDefaultOpen';
|
|
9
9
|
export { SidebarPeekProvider, usePeekPin, useSidebarPeek } from './useSidebarPeek';
|
|
10
10
|
export { SidebarWidthContext, useSidebarWidth, useSidebarWidthContext } from './useSidebarWidth';
|
|
11
|
+
export { useHoverIntent } from './useHoverIntent';
|
|
12
|
+
export type { HoverIntent, HoverIntentOptions } from './useHoverIntent';
|
|
13
|
+
export { usePresence } from './usePresence';
|
|
14
|
+
export type { Presence } from './usePresence';
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React, { act } from 'react';
|
|
3
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { useHoverIntent, type HoverIntent, type HoverIntentOptions } from './useHoverIntent';
|
|
7
|
+
|
|
8
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
let host: HTMLElement;
|
|
11
|
+
let root: Root;
|
|
12
|
+
let intent: HoverIntent;
|
|
13
|
+
|
|
14
|
+
function Probe(props: HoverIntentOptions) {
|
|
15
|
+
intent = useHoverIntent(props);
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function mount(props: HoverIntentOptions = { openDelay: 400, closeDelay: 250 }) {
|
|
20
|
+
act(() => root.render(<Probe {...props} />));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const mouse = { pointerType: 'mouse' };
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
vi.useFakeTimers();
|
|
27
|
+
host = document.createElement('div');
|
|
28
|
+
root = createRoot(host);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
act(() => root.unmount());
|
|
33
|
+
vi.useRealTimers();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('useHoverIntent', () => {
|
|
37
|
+
it('opens only after the open delay', () => {
|
|
38
|
+
mount();
|
|
39
|
+
act(() => intent.zoneProps.onPointerEnter(mouse));
|
|
40
|
+
act(() => void vi.advanceTimersByTime(399));
|
|
41
|
+
expect(intent.open).toBe(false);
|
|
42
|
+
act(() => void vi.advanceTimersByTime(1));
|
|
43
|
+
expect(intent.open).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('a pass-by shorter than the open delay never opens', () => {
|
|
47
|
+
mount();
|
|
48
|
+
act(() => intent.zoneProps.onPointerEnter(mouse));
|
|
49
|
+
act(() => void vi.advanceTimersByTime(200));
|
|
50
|
+
act(() => intent.zoneProps.onPointerLeave(mouse));
|
|
51
|
+
act(() => void vi.advanceTimersByTime(1000));
|
|
52
|
+
expect(intent.open).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('closes after the grace delay, and returning in time keeps it open', () => {
|
|
56
|
+
mount();
|
|
57
|
+
act(() => intent.zoneProps.onPointerEnter(mouse));
|
|
58
|
+
act(() => void vi.advanceTimersByTime(400));
|
|
59
|
+
expect(intent.open).toBe(true);
|
|
60
|
+
|
|
61
|
+
act(() => intent.panelProps.onPointerLeave(mouse));
|
|
62
|
+
act(() => void vi.advanceTimersByTime(200));
|
|
63
|
+
act(() => intent.panelProps.onPointerEnter(mouse));
|
|
64
|
+
act(() => void vi.advanceTimersByTime(1000));
|
|
65
|
+
expect(intent.open).toBe(true);
|
|
66
|
+
|
|
67
|
+
act(() => intent.panelProps.onPointerLeave(mouse));
|
|
68
|
+
act(() => void vi.advanceTimersByTime(249));
|
|
69
|
+
expect(intent.open).toBe(true);
|
|
70
|
+
act(() => void vi.advanceTimersByTime(1));
|
|
71
|
+
expect(intent.open).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('ignores touch pointers', () => {
|
|
75
|
+
mount();
|
|
76
|
+
act(() => intent.zoneProps.onPointerEnter({ pointerType: 'touch' }));
|
|
77
|
+
act(() => void vi.advanceTimersByTime(1000));
|
|
78
|
+
expect(intent.open).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('never opens when disabled', () => {
|
|
82
|
+
mount({ enabled: false });
|
|
83
|
+
act(() => intent.zoneProps.onPointerEnter(mouse));
|
|
84
|
+
act(() => void vi.advanceTimersByTime(1000));
|
|
85
|
+
expect(intent.open).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('stays open while pinned and closes after the grace once released', () => {
|
|
89
|
+
mount();
|
|
90
|
+
act(() => intent.zoneProps.onPointerEnter(mouse));
|
|
91
|
+
act(() => void vi.advanceTimersByTime(400));
|
|
92
|
+
act(() => intent.pin(true));
|
|
93
|
+
act(() => intent.panelProps.onPointerLeave(mouse));
|
|
94
|
+
act(() => void vi.advanceTimersByTime(2000));
|
|
95
|
+
expect(intent.open).toBe(true);
|
|
96
|
+
act(() => intent.pin(false));
|
|
97
|
+
act(() => void vi.advanceTimersByTime(250));
|
|
98
|
+
expect(intent.open).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hover intent: open only after the pointer has rested for `openDelay`, close only after it has
|
|
3
|
+
* been away for `closeDelay`. A pass-by shorter than `openDelay` never opens; a quick trip out
|
|
4
|
+
* and back within `closeDelay` never closes. Touch pointers are ignored.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use client';
|
|
8
|
+
|
|
9
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
10
|
+
|
|
11
|
+
import { HOVER_CLOSE_DELAY_MS, HOVER_OPEN_DELAY_MS } from '../geometry';
|
|
12
|
+
|
|
13
|
+
export interface HoverIntentOptions {
|
|
14
|
+
openDelay?: number;
|
|
15
|
+
closeDelay?: number;
|
|
16
|
+
/** False (touch device, docked sidebar): never opens, and closes an open panel at once. */
|
|
17
|
+
enabled?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface PointerHandlers {
|
|
21
|
+
onPointerEnter: (e: { pointerType?: string }) => void;
|
|
22
|
+
onPointerLeave: (e: { pointerType?: string }) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface HoverIntent {
|
|
26
|
+
open: boolean;
|
|
27
|
+
/** For the hot zone: enter starts the open timer, leave schedules the close. */
|
|
28
|
+
zoneProps: PointerHandlers;
|
|
29
|
+
/** For the opened panel: enter opens/keeps at once, leave schedules the close. */
|
|
30
|
+
panelProps: PointerHandlers;
|
|
31
|
+
/** Close at once. */
|
|
32
|
+
close: () => void;
|
|
33
|
+
/** Hold open (a menu launched from the panel is open); releasing schedules the close. */
|
|
34
|
+
pin: (pinned: boolean) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function useHoverIntent({
|
|
38
|
+
openDelay = HOVER_OPEN_DELAY_MS,
|
|
39
|
+
closeDelay = HOVER_CLOSE_DELAY_MS,
|
|
40
|
+
enabled = true,
|
|
41
|
+
}: HoverIntentOptions = {}): HoverIntent {
|
|
42
|
+
const [open, setOpen] = useState(false);
|
|
43
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
44
|
+
const pinned = useRef(false);
|
|
45
|
+
const openRef = useRef(false);
|
|
46
|
+
|
|
47
|
+
const set = useCallback((value: boolean) => {
|
|
48
|
+
openRef.current = value;
|
|
49
|
+
setOpen(value);
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
const cancel = useCallback(() => {
|
|
53
|
+
if (timer.current) clearTimeout(timer.current);
|
|
54
|
+
timer.current = null;
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
const scheduleClose = useCallback(() => {
|
|
58
|
+
cancel();
|
|
59
|
+
if (!openRef.current || pinned.current) return;
|
|
60
|
+
timer.current = setTimeout(() => set(false), closeDelay);
|
|
61
|
+
}, [cancel, closeDelay, set]);
|
|
62
|
+
|
|
63
|
+
const close = useCallback(() => {
|
|
64
|
+
cancel();
|
|
65
|
+
set(false);
|
|
66
|
+
}, [cancel, set]);
|
|
67
|
+
|
|
68
|
+
const pin = useCallback(
|
|
69
|
+
(value: boolean) => {
|
|
70
|
+
pinned.current = value;
|
|
71
|
+
if (value) cancel();
|
|
72
|
+
else scheduleClose();
|
|
73
|
+
},
|
|
74
|
+
[cancel, scheduleClose],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const zoneProps = useMemo<PointerHandlers>(
|
|
78
|
+
() => ({
|
|
79
|
+
onPointerEnter: (e) => {
|
|
80
|
+
if (!enabled || e.pointerType === 'touch') return;
|
|
81
|
+
cancel();
|
|
82
|
+
if (openRef.current) return;
|
|
83
|
+
timer.current = setTimeout(() => set(true), openDelay);
|
|
84
|
+
},
|
|
85
|
+
onPointerLeave: (e) => {
|
|
86
|
+
if (e.pointerType === 'touch') return;
|
|
87
|
+
scheduleClose();
|
|
88
|
+
// Not open yet: leaving cancels the pending open (cancel ran in scheduleClose).
|
|
89
|
+
},
|
|
90
|
+
}),
|
|
91
|
+
[enabled, cancel, scheduleClose, set, openDelay],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const panelProps = useMemo<PointerHandlers>(
|
|
95
|
+
() => ({
|
|
96
|
+
onPointerEnter: () => {
|
|
97
|
+
cancel();
|
|
98
|
+
if (enabled) set(true);
|
|
99
|
+
},
|
|
100
|
+
onPointerLeave: () => scheduleClose(),
|
|
101
|
+
}),
|
|
102
|
+
[enabled, cancel, scheduleClose, set],
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (!enabled) close();
|
|
107
|
+
}, [enabled, close]);
|
|
108
|
+
|
|
109
|
+
useEffect(() => cancel, [cancel]);
|
|
110
|
+
|
|
111
|
+
return { open: enabled && open, zoneProps, panelProps, close, pin };
|
|
112
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presence: keeps an element mounted for `exitMs` after `open` turns false, so its exit
|
|
3
|
+
* animation can run. With `prefers-reduced-motion` the exit is skipped (unmount at once).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use client';
|
|
7
|
+
|
|
8
|
+
import { useEffect, useState } from 'react';
|
|
9
|
+
|
|
10
|
+
import { useMediaQuery } from '@djangocfg/ui-core/hooks';
|
|
11
|
+
|
|
12
|
+
import { SIDEBAR_MOTION_MS } from '../geometry';
|
|
13
|
+
|
|
14
|
+
export interface Presence {
|
|
15
|
+
/** Render the element. */
|
|
16
|
+
mounted: boolean;
|
|
17
|
+
/** Drives `data-state`: `open` while entering/shown, `closed` while exiting. */
|
|
18
|
+
state: 'open' | 'closed';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function usePresence(open: boolean, exitMs: number = SIDEBAR_MOTION_MS): Presence {
|
|
22
|
+
const reduced = useMediaQuery('(prefers-reduced-motion: reduce)');
|
|
23
|
+
const [lingering, setLingering] = useState(false);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (open) {
|
|
27
|
+
setLingering(true);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (reduced || exitMs <= 0) {
|
|
31
|
+
setLingering(false);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const t = setTimeout(() => setLingering(false), exitMs);
|
|
35
|
+
return () => clearTimeout(t);
|
|
36
|
+
}, [open, reduced, exitMs]);
|
|
37
|
+
|
|
38
|
+
return { mounted: open || lingering, state: open ? 'open' : 'closed' };
|
|
39
|
+
}
|
|
@@ -1,36 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Peek: while the sidebar is collapsed,
|
|
3
|
-
* full sidebar over the page. The panel keeps itself open while the pointer is
|
|
2
|
+
* Peek: while the sidebar is collapsed, resting the pointer on the left edge or the trigger
|
|
3
|
+
* zone (hover intent) floats the full sidebar over the page. The panel keeps itself open while the pointer is
|
|
4
4
|
* on it, or while a menu opened from it (account, workspace switcher) is open.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
'use client';
|
|
8
8
|
|
|
9
|
-
import React, {
|
|
10
|
-
createContext,
|
|
11
|
-
useCallback,
|
|
12
|
-
useContext,
|
|
13
|
-
useEffect,
|
|
14
|
-
useMemo,
|
|
15
|
-
useRef,
|
|
16
|
-
useState,
|
|
17
|
-
} from 'react';
|
|
9
|
+
import React, { createContext, useContext, useEffect, useMemo } from 'react';
|
|
18
10
|
|
|
19
11
|
import { useSidebar } from '@djangocfg/ui-core/components';
|
|
12
|
+
import { useMediaQuery } from '@djangocfg/ui-core/hooks';
|
|
20
13
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const CLOSE_DELAY_MS = 300;
|
|
14
|
+
import { useHoverIntent } from './useHoverIntent';
|
|
15
|
+
import type { Presence } from './usePresence';
|
|
16
|
+
import { usePresence } from './usePresence';
|
|
25
17
|
|
|
26
|
-
|
|
27
|
-
onPointerEnter: (e: React.PointerEvent) => void;
|
|
28
|
-
onPointerLeave: (e: React.PointerEvent) => void;
|
|
29
|
-
}
|
|
18
|
+
type PointerHandlers = ReturnType<typeof useHoverIntent>['zoneProps'];
|
|
30
19
|
|
|
31
|
-
export interface SidebarPeek {
|
|
20
|
+
export interface SidebarPeek extends Presence {
|
|
32
21
|
/** True while the sidebar is collapsed on desktop: the trigger zone shows. */
|
|
33
22
|
enabled: boolean;
|
|
23
|
+
/** Hover-open is live: collapsed, desktop, and a fine (mouse/pen) pointer. */
|
|
24
|
+
hoverEnabled: boolean;
|
|
34
25
|
open: boolean;
|
|
35
26
|
zoneProps: PointerHandlers;
|
|
36
27
|
panelProps: PointerHandlers;
|
|
@@ -64,68 +55,14 @@ interface SidebarPeekProviderProps {
|
|
|
64
55
|
export function SidebarPeekProvider({ pathname, children }: SidebarPeekProviderProps) {
|
|
65
56
|
const { state, isMobile } = useSidebar();
|
|
66
57
|
const enabled = !isMobile && state === 'collapsed';
|
|
58
|
+
// Hover is a mouse affordance: only where the primary pointer can hover precisely.
|
|
59
|
+
const finePointer = useMediaQuery('(hover: hover) and (pointer: fine)');
|
|
60
|
+
const hoverEnabled = enabled && finePointer;
|
|
67
61
|
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
const cancel = useCallback(() => {
|
|
74
|
-
if (timer.current) clearTimeout(timer.current);
|
|
75
|
-
timer.current = null;
|
|
76
|
-
}, []);
|
|
77
|
-
|
|
78
|
-
const scheduleClose = useCallback(() => {
|
|
79
|
-
cancel();
|
|
80
|
-
if (pinned.current) return;
|
|
81
|
-
const grace = openedAt.current + MIN_OPEN_MS - Date.now();
|
|
82
|
-
timer.current = setTimeout(() => setOpen(false), Math.max(CLOSE_DELAY_MS, grace));
|
|
83
|
-
}, [cancel]);
|
|
84
|
-
|
|
85
|
-
const show = useCallback(() => {
|
|
86
|
-
cancel();
|
|
87
|
-
setOpen((was) => {
|
|
88
|
-
if (!was) openedAt.current = Date.now();
|
|
89
|
-
return true;
|
|
90
|
-
});
|
|
91
|
-
}, [cancel]);
|
|
92
|
-
|
|
93
|
-
const close = useCallback(() => {
|
|
94
|
-
cancel();
|
|
95
|
-
setOpen(false);
|
|
96
|
-
}, [cancel]);
|
|
97
|
-
|
|
98
|
-
const pin = useCallback(
|
|
99
|
-
(value: boolean) => {
|
|
100
|
-
pinned.current = value;
|
|
101
|
-
if (!value) scheduleClose();
|
|
102
|
-
},
|
|
103
|
-
[scheduleClose],
|
|
104
|
-
);
|
|
105
|
-
|
|
106
|
-
// Hover is a mouse affordance; touch taps the trigger to dock instead.
|
|
107
|
-
const zoneProps = useMemo<PointerHandlers>(
|
|
108
|
-
() => ({
|
|
109
|
-
onPointerEnter: (e) => {
|
|
110
|
-
if (e.pointerType !== 'mouse') return;
|
|
111
|
-
show();
|
|
112
|
-
},
|
|
113
|
-
onPointerLeave: scheduleClose,
|
|
114
|
-
}),
|
|
115
|
-
[show, scheduleClose],
|
|
116
|
-
);
|
|
117
|
-
|
|
118
|
-
const panelProps = useMemo<PointerHandlers>(
|
|
119
|
-
() => ({
|
|
120
|
-
onPointerEnter: show,
|
|
121
|
-
onPointerLeave: scheduleClose,
|
|
122
|
-
}),
|
|
123
|
-
[show, scheduleClose],
|
|
124
|
-
);
|
|
125
|
-
|
|
126
|
-
useEffect(() => {
|
|
127
|
-
if (!enabled) close();
|
|
128
|
-
}, [enabled, close]);
|
|
62
|
+
const intent = useHoverIntent({ enabled: hoverEnabled });
|
|
63
|
+
const { close } = intent;
|
|
64
|
+
const open = enabled && intent.open;
|
|
65
|
+
const presence = usePresence(open);
|
|
129
66
|
|
|
130
67
|
useEffect(() => {
|
|
131
68
|
close();
|
|
@@ -140,11 +77,19 @@ export function SidebarPeekProvider({ pathname, children }: SidebarPeekProviderP
|
|
|
140
77
|
return () => window.removeEventListener('keydown', onKey);
|
|
141
78
|
}, [open, close]);
|
|
142
79
|
|
|
143
|
-
useEffect(() => cancel, [cancel]);
|
|
144
|
-
|
|
145
80
|
const value = useMemo<SidebarPeek>(
|
|
146
|
-
() => ({
|
|
147
|
-
|
|
81
|
+
() => ({
|
|
82
|
+
enabled,
|
|
83
|
+
hoverEnabled,
|
|
84
|
+
open,
|
|
85
|
+
mounted: enabled && presence.mounted,
|
|
86
|
+
state: presence.state,
|
|
87
|
+
zoneProps: intent.zoneProps,
|
|
88
|
+
panelProps: intent.panelProps,
|
|
89
|
+
close,
|
|
90
|
+
pin: intent.pin,
|
|
91
|
+
}),
|
|
92
|
+
[enabled, hoverEnabled, open, presence.mounted, presence.state, intent.zoneProps, intent.panelProps, close, intent.pin],
|
|
148
93
|
);
|
|
149
94
|
|
|
150
95
|
return <PeekContext.Provider value={value}>{children}</PeekContext.Provider>;
|
package/src/styles/dashboard.css
CHANGED
|
@@ -49,3 +49,30 @@
|
|
|
49
49
|
[data-sidebar-resizing] [data-variant][data-state] * {
|
|
50
50
|
transition: none !important;
|
|
51
51
|
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Private layout: peek panel slide in and out. Shipped here so consumers need no animation
|
|
56
|
+
* plugin. Duration mirrors SIDEBAR_MOTION_MS in PrivateLayout/geometry.ts.
|
|
57
|
+
*/
|
|
58
|
+
@keyframes private-sidebar-in {
|
|
59
|
+
from { opacity: 0; transform: translateX(-1rem); }
|
|
60
|
+
to { opacity: 1; transform: translateX(0); }
|
|
61
|
+
}
|
|
62
|
+
@keyframes private-sidebar-out {
|
|
63
|
+
from { opacity: 1; transform: translateX(0); }
|
|
64
|
+
to { opacity: 0; transform: translateX(-1rem); }
|
|
65
|
+
}
|
|
66
|
+
.private-sidebar-peek[data-state="open"] {
|
|
67
|
+
animation: private-sidebar-in 180ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
|
68
|
+
}
|
|
69
|
+
.private-sidebar-peek[data-state="closed"] {
|
|
70
|
+
animation: private-sidebar-out 180ms ease-in both;
|
|
71
|
+
pointer-events: none;
|
|
72
|
+
}
|
|
73
|
+
@media (prefers-reduced-motion: reduce) {
|
|
74
|
+
.private-sidebar-peek[data-state="open"],
|
|
75
|
+
.private-sidebar-peek[data-state="closed"] {
|
|
76
|
+
animation: none;
|
|
77
|
+
}
|
|
78
|
+
}
|