@djangocfg/layouts 2.1.604 → 2.1.606
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/AuthLayout/AuthLayout.tsx +1 -0
- package/src/layouts/AuthLayout/README.md +20 -3
- package/src/layouts/AuthLayout/components/oauth/OAuthSection.tsx +54 -0
- package/src/layouts/AuthLayout/components/oauth/buttons/OAuthButtonList.test.tsx +52 -0
- package/src/layouts/AuthLayout/components/oauth/buttons/OAuthButtonList.tsx +49 -0
- package/src/layouts/AuthLayout/components/oauth/buttons/OAuthButtons.tsx +44 -0
- package/src/layouts/AuthLayout/components/oauth/buttons/OAuthProviderButton.test.tsx +56 -0
- package/src/layouts/AuthLayout/components/oauth/buttons/OAuthProviderButton.tsx +51 -0
- package/src/layouts/AuthLayout/components/oauth/{OAuthCallback.tsx → callback/OAuthCallback.tsx} +7 -33
- package/src/layouts/AuthLayout/components/oauth/callback/OAuthCallbackOverlay.tsx +41 -0
- package/src/layouts/AuthLayout/components/oauth/{oauthCallbackParams.test.ts → callback/callbackParams.test.ts} +1 -1
- package/src/layouts/AuthLayout/components/oauth/errors/errorCopy.test.ts +63 -0
- package/src/layouts/AuthLayout/components/oauth/errors/errorCopy.ts +95 -0
- package/src/layouts/AuthLayout/components/oauth/errors/useOAuthErrorText.ts +25 -0
- package/src/layouts/AuthLayout/components/oauth/index.ts +29 -4
- package/src/layouts/AuthLayout/components/oauth/notice/OAuthNotice.tsx +41 -0
- package/src/layouts/AuthLayout/components/oauth/notice/OAuthNoticeView.test.tsx +64 -0
- package/src/layouts/AuthLayout/components/oauth/{OAuthNotice.tsx → notice/OAuthNoticeView.tsx} +26 -32
- package/src/layouts/AuthLayout/components/oauth/{GithubIcon.tsx → providers/icons/GithubIcon.tsx} +0 -1
- package/src/layouts/AuthLayout/components/oauth/{GoogleIcon.tsx → providers/icons/GoogleIcon.tsx} +1 -1
- package/src/layouts/AuthLayout/components/oauth/providers/index.ts +2 -0
- package/src/layouts/AuthLayout/components/oauth/providers/registry.test.ts +23 -0
- package/src/layouts/AuthLayout/components/oauth/providers/registry.ts +32 -0
- package/src/layouts/AuthLayout/components/oauth/{oauthProviders.test.ts → providers/resolveProviders.test.ts} +1 -1
- package/src/layouts/AuthLayout/components/shared/AuthButton.tsx +1 -2
- package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +4 -26
- package/src/layouts/AuthLayout/context.tsx +1 -1
- package/src/layouts/AuthLayout/styles/auth.css +3 -70
- package/src/layouts/AuthLayout/styles/oauth.css +33 -0
- 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/src/testing/MockAuthFormProvider.tsx +1 -1
- package/src/layouts/AuthLayout/components/oauth/OAuthButtons.tsx +0 -82
- package/src/layouts/AuthLayout/components/oauth/oauthErrors.ts +0 -23
- package/src/layouts/AuthLayout/components/oauth/oauthNoticeCopy.test.ts +0 -36
- package/src/layouts/AuthLayout/components/oauth/oauthNoticeCopy.ts +0 -86
- /package/src/layouts/AuthLayout/components/oauth/{oauthCallbackParams.ts → callback/callbackParams.ts} +0 -0
- /package/src/layouts/AuthLayout/components/oauth/{oauthProviders.ts → providers/resolveProviders.ts} +0 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider sign-in (components/oauth). The buttons themselves are theme-token
|
|
3
|
+
* utilities in `OAuthProviderButton`; this file holds only what a utility
|
|
4
|
+
* cannot: the column, the room held while the provider list loads, and the
|
|
5
|
+
* developer hint. Sizes are tokens so the reserve and the buttons agree.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
:root {
|
|
9
|
+
/* A provider button is at least this tall (above the 44px touch minimum). */
|
|
10
|
+
--auth-oauth-height: 3rem;
|
|
11
|
+
--auth-oauth-gap: 0.75rem;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.auth-oauth {
|
|
15
|
+
display: flex;
|
|
16
|
+
flex-direction: column;
|
|
17
|
+
gap: var(--auth-oauth-gap);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/* Holds the room of two buttons, the gap and the divider while the server's
|
|
21
|
+
provider list loads, so the email field does not jump when it arrives. */
|
|
22
|
+
.auth-oauth-reserve {
|
|
23
|
+
height: calc(2 * var(--auth-oauth-height) + var(--auth-oauth-gap) + var(--auth-gap) + 1rem);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* The line for whoever deploys the site, under a notice about configuration. */
|
|
27
|
+
.auth-oauth-hint {
|
|
28
|
+
margin-top: 0.5rem;
|
|
29
|
+
color: var(--auth-muted);
|
|
30
|
+
font-size: 0.75rem;
|
|
31
|
+
line-height: 1.4;
|
|
32
|
+
overflow-wrap: anywhere;
|
|
33
|
+
}
|
|
@@ -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
|
+
}
|