@djangocfg/layouts 2.1.603 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/layouts",
3
- "version": "2.1.603",
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.603",
95
- "@djangocfg/api": "^2.1.603",
96
- "@djangocfg/devtools": "^2.1.603",
97
- "@djangocfg/i18n": "^2.1.603",
98
- "@djangocfg/ui-core": "^2.1.603",
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.603",
129
- "@djangocfg/api": "^2.1.603",
130
- "@djangocfg/devtools": "^2.1.603",
131
- "@djangocfg/i18n": "^2.1.603",
132
- "@djangocfg/typescript-config": "^2.1.603",
133
- "@djangocfg/ui-core": "^2.1.603",
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 { SidebarTrigger, useSidebar } from '@djangocfg/ui-core/components';
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?.zoneProps)}
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
- <SidebarTrigger
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 p-2 pb-[max(0.5rem,env(safe-area-inset-bottom,0px))]';
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="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto overflow-x-hidden py-2"
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 { Button, Link, SidebarTrigger, useSidebar } from '@djangocfg/ui-core/components';
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 = isMobile ? (
59
- <Button
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={!isMobile} /> : brand}
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
- <SidebarTrigger className="shrink-0" aria-label="Collapse sidebar" data-no-expand />
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 GROUP_CLASS = cn(
31
- 'gap-0 px-2 py-1',
32
- '[&:not(:first-child)]:mt-1 [&:not(:first-child)]:border-t [&:not(:first-child)]:border-sidebar-border/60 [&:not(:first-child)]:pt-2',
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 SEPARATED_GROUP_CLASS = 'gap-0 px-2 pb-1 pt-0';
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="mx-2 mb-2 mt-3 h-px shrink-0 bg-sidebar-border"
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 ? SEPARATED_GROUP_CLASS : GROUP_CLASS;
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 and held by `SidebarPeekProvider`.
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?.open) return null;
17
+ if (!peek) return null;
17
18
 
18
19
  return (
19
- <div
20
- data-sidebar="peek"
21
- role="complementary"
22
- aria-label="Navigation"
23
- {...peek.panelProps}
24
- className={[
25
- 'fixed bottom-2 left-2 top-2 z-50 flex w-(--sidebar-width) flex-col overflow-hidden',
26
- 'rounded-xl border border-sidebar-border bg-sidebar text-sidebar-foreground shadow-2xl',
27
- 'animate-in fade-in-0 slide-in-from-left-4 duration-150',
28
- ].join(' ')}
29
- >
30
- <SidebarBody />
31
- <SidebarResizeHandle />
32
- </div>
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 row geometry. Two sizes only: a pointer-sized desktop row and a
3
- * 44px touch row for the mobile drawer.
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, hovering its trigger zone floats the
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
- // Opens on entry with no delay, then stays at least MIN_OPEN_MS even if the
22
- // pointer only grazed the zone, so a user who didn't notice it still sees it.
23
- const MIN_OPEN_MS = 900;
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
- interface PointerHandlers {
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 [open, setOpen] = useState(false);
69
- const pinned = useRef(false);
70
- const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
71
- const openedAt = useRef(0);
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
- () => ({ enabled, open: enabled && open, zoneProps, panelProps, close, pin }),
147
- [enabled, open, zoneProps, panelProps, close, pin],
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>;
@@ -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
+ }