@djangocfg/layouts 2.1.590 → 2.1.592

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.
Files changed (71) hide show
  1. package/package.json +12 -12
  2. package/src/layouts/PrivateLayout/PrivateLayout.tsx +37 -26
  3. package/src/layouts/PrivateLayout/README.md +20 -12
  4. package/src/layouts/PrivateLayout/components/PrivateContent.tsx +29 -42
  5. package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +23 -92
  6. package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +32 -62
  7. package/src/layouts/PrivateLayout/components/SidebarBody.tsx +45 -0
  8. package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +64 -152
  9. package/src/layouts/PrivateLayout/components/SidebarBrandSwitcher.tsx +2 -30
  10. package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +55 -115
  11. package/src/layouts/PrivateLayout/components/SidebarNavItem.tsx +37 -100
  12. package/src/layouts/PrivateLayout/components/SidebarPeek.tsx +34 -0
  13. package/src/layouts/PrivateLayout/components/SidebarResizeHandle.tsx +76 -0
  14. package/src/layouts/PrivateLayout/components/SidebarSlots.tsx +14 -53
  15. package/src/layouts/PrivateLayout/components/index.ts +0 -4
  16. package/src/layouts/PrivateLayout/context.tsx +10 -115
  17. package/src/layouts/PrivateLayout/geometry.ts +17 -0
  18. package/src/layouts/PrivateLayout/hooks/index.ts +2 -1
  19. package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +4 -4
  20. package/src/layouts/PrivateLayout/hooks/useSidebarKeyboard.ts +33 -93
  21. package/src/layouts/PrivateLayout/hooks/useSidebarPeek.tsx +151 -0
  22. package/src/layouts/PrivateLayout/hooks/useSidebarWidth.ts +83 -0
  23. package/src/layouts/PrivateLayout/types.ts +13 -20
  24. package/src/layouts/SettingsLayout/README.md +23 -10
  25. package/src/layouts/SettingsLayout/SettingsDialog.tsx +14 -8
  26. package/src/layouts/SettingsLayout/SettingsForm.tsx +13 -9
  27. package/src/layouts/SettingsLayout/components/ApiKeySection/ApiKeySection.tsx +9 -7
  28. package/src/layouts/SettingsLayout/components/ApiKeySection/context.tsx +6 -41
  29. package/src/layouts/SettingsLayout/components/AvatarSection.tsx +11 -3
  30. package/src/layouts/SettingsLayout/components/SettingsNav.tsx +5 -9
  31. package/src/layouts/SettingsLayout/components/SettingsNavItem.tsx +5 -7
  32. package/src/layouts/SettingsLayout/components/SettingsPanel.tsx +5 -10
  33. package/src/layouts/SettingsLayout/components/SettingsSearch.tsx +5 -3
  34. package/src/layouts/SettingsLayout/components/SettingsShell.tsx +14 -21
  35. package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSection.tsx +52 -105
  36. package/src/layouts/SettingsLayout/components/index.ts +0 -1
  37. package/src/layouts/SettingsLayout/context/SettingsContext.tsx +5 -2
  38. package/src/layouts/SettingsLayout/i18n/index.ts +39 -0
  39. package/src/layouts/SettingsLayout/i18n/locales/ar.ts +66 -0
  40. package/src/layouts/SettingsLayout/i18n/locales/da.ts +66 -0
  41. package/src/layouts/SettingsLayout/i18n/locales/de.ts +66 -0
  42. package/src/layouts/SettingsLayout/i18n/locales/en.ts +66 -0
  43. package/src/layouts/SettingsLayout/i18n/locales/es.ts +66 -0
  44. package/src/layouts/SettingsLayout/i18n/locales/fr.ts +66 -0
  45. package/src/layouts/SettingsLayout/i18n/locales/it.ts +66 -0
  46. package/src/layouts/SettingsLayout/i18n/locales/ja.ts +66 -0
  47. package/src/layouts/SettingsLayout/i18n/locales/ko.ts +66 -0
  48. package/src/layouts/SettingsLayout/i18n/locales/nl.ts +66 -0
  49. package/src/layouts/SettingsLayout/i18n/locales/no.ts +66 -0
  50. package/src/layouts/SettingsLayout/i18n/locales/pl.ts +66 -0
  51. package/src/layouts/SettingsLayout/i18n/locales/ptBR.ts +66 -0
  52. package/src/layouts/SettingsLayout/i18n/locales/ru.ts +66 -0
  53. package/src/layouts/SettingsLayout/i18n/locales/sv.ts +66 -0
  54. package/src/layouts/SettingsLayout/i18n/locales/tr.ts +66 -0
  55. package/src/layouts/SettingsLayout/i18n/locales/zh.ts +66 -0
  56. package/src/layouts/SettingsLayout/i18n/types.ts +65 -0
  57. package/src/layouts/SettingsLayout/index.ts +0 -1
  58. package/src/layouts/SettingsLayout/sections/AccountSection.tsx +9 -4
  59. package/src/layouts/SettingsLayout/sections/DeleteAccountRow.tsx +4 -1
  60. package/src/layouts/SettingsLayout/sections/GeneralSection.tsx +25 -0
  61. package/src/layouts/SettingsLayout/sections/PreferencesRows.tsx +12 -4
  62. package/src/layouts/SettingsLayout/sections/builtins.tsx +34 -29
  63. package/src/layouts/SettingsLayout/sections/index.ts +2 -2
  64. package/src/layouts/SettingsLayout/types.ts +12 -4
  65. package/src/layouts/types/layout.types.ts +5 -6
  66. package/src/styles/dashboard.css +10 -0
  67. package/src/layouts/PrivateLayout/density.ts +0 -48
  68. package/src/layouts/PrivateLayout/hooks/useHoverExpand.ts +0 -110
  69. package/src/layouts/PrivateLayout/hooks/useShellVisualState.ts +0 -130
  70. package/src/layouts/SettingsLayout/components/SettingsRailFooter.tsx +0 -74
  71. package/src/layouts/SettingsLayout/sections/SecuritySection.tsx +0 -15
@@ -1,13 +1,11 @@
1
1
  /**
2
- * Sidebar Nav Item Component
3
- *
4
- * Individual navigation item with icon, label, badge, and active state.
5
- * Memoized with shallow item comparison so badge number changes still re-render.
2
+ * Sidebar Nav Item — icon, label, optional badge. Active state is a surface
3
+ * plus `aria-current`; weight never changes, so labels do not jump.
6
4
  */
7
5
 
8
6
  'use client';
9
7
 
10
- import React, { memo, useMemo } from 'react';
8
+ import React, { memo } from 'react';
11
9
 
12
10
  import {
13
11
  Link,
@@ -19,7 +17,6 @@ import { cn } from '@djangocfg/ui-core/lib';
19
17
 
20
18
  import { LucideIcon } from '../../../components';
21
19
  import { usePrivateLayoutContext } from '../context';
22
- import { useShellVisualState } from '../hooks';
23
20
  import type { SidebarItem } from '../types';
24
21
 
25
22
  interface SidebarNavItemProps {
@@ -27,111 +24,51 @@ interface SidebarNavItemProps {
27
24
  hideItemIcons?: boolean;
28
25
  }
29
26
 
30
- function SidebarNavItemRaw({ item, hideItemIcons }: SidebarNavItemProps) {
31
- const { isActive, menuNav } = usePrivateLayoutContext();
32
- const { content } = useShellVisualState();
33
-
34
- const tooltipText = item.tooltip ?? item.label;
35
-
36
- const itemIcon = useMemo(
37
- () =>
38
- !hideItemIcons && item.icon ? (
39
- <LucideIcon icon={item.icon} className={menuNav.iconClass} />
40
- ) : null,
41
- [hideItemIcons, item.icon, menuNav.iconClass],
42
- );
43
-
44
- const hasBadge = Boolean(item.badge);
27
+ const ITEM_CLASS = cn(
28
+ 'rounded-lg border-0 font-normal shadow-none transition-colors duration-150',
29
+ 'text-sidebar-foreground/90 hover:bg-sidebar-accent/70 hover:text-sidebar-foreground',
30
+ 'data-[active=true]:bg-sidebar-accent data-[active=true]:font-normal data-[active=true]:text-sidebar-foreground',
31
+ '[&>svg]:text-sidebar-foreground/80 [&>svg]:opacity-100 hover:[&>svg]:text-sidebar-foreground',
32
+ 'data-[active=true]:[&>svg]:text-sidebar-foreground',
33
+ );
45
34
 
46
- const collapsedBadgeDot = useMemo(
47
- () =>
48
- !content.showBadgeText && hasBadge ? (
49
- <span className="absolute right-1 top-1 flex h-2 w-2">
50
- <span className="inline-flex h-full w-full rounded-full bg-primary" />
51
- </span>
52
- ) : null,
53
- [content.showBadgeText, hasBadge],
54
- );
55
-
56
- const expandedBadgeNode = useMemo(
57
- () =>
58
- content.showBadgeText && hasBadge ? (
59
- <SidebarMenuBadge
60
- className={cn(
61
- item.badgeVariant === 'pill'
62
- ? 'bg-primary/15 text-primary px-1.5 rounded-md font-medium'
63
- : undefined,
64
- )}
65
- >
66
- {item.badge}
67
- </SidebarMenuBadge>
68
- ) : null,
69
- [content.showBadgeText, hasBadge, item.badgeVariant, item.badge],
70
- );
71
-
72
- const isItemActive = isActive(item.href);
73
-
74
- const buttonClass = useMemo(
75
- () =>
76
- cn(
77
- 'group/nav relative border-0 font-medium shadow-none transition-colors',
78
- 'text-sidebar-foreground/80',
79
- 'data-[active=true]:font-semibold data-[active=true]:text-sidebar-accent-foreground',
80
- 'hover:bg-sidebar-accent/70 hover:text-sidebar-accent-foreground',
81
- '[&>svg]:shrink-0 [&>svg]:text-sidebar-foreground/70 [&>svg]:opacity-85',
82
- '[&>svg]:transition-transform [&>svg]:duration-200 group-hover/nav:[&>svg]:scale-110 group-active/nav:[&>svg]:scale-95',
83
- 'data-[active=true]:[&>svg]:text-sidebar-accent-foreground data-[active=true]:[&>svg]:opacity-100',
84
- hideItemIcons && 'pl-8',
85
- content.showLabels && '[&>span]:!inline !p-2 !justify-start',
86
- ),
87
- [hideItemIcons, content.showLabels],
88
- );
35
+ function SidebarNavItemRaw({ item, hideItemIcons }: SidebarNavItemProps) {
36
+ const { isActive, geometry } = usePrivateLayoutContext();
37
+ const active = isActive(item.href);
89
38
 
90
39
  return (
91
40
  <SidebarMenuItem>
92
41
  <SidebarMenuButton
93
42
  asChild
94
- isActive={isItemActive}
95
- size={menuNav.buttonSize}
96
- tooltip={content.showTooltips ? tooltipText : undefined}
97
- className={buttonClass}
43
+ isActive={active}
44
+ className={cn(ITEM_CLASS, geometry.item, hideItemIcons && 'pl-8')}
98
45
  >
99
- <Link href={item.href}>
100
- {itemIcon}
46
+ <Link href={item.href} aria-current={active ? 'page' : undefined}>
47
+ {!hideItemIcons && item.icon ? <LucideIcon icon={item.icon} /> : null}
101
48
  <span>{item.label}</span>
102
- {expandedBadgeNode}
49
+ {item.badge ? (
50
+ <SidebarMenuBadge
51
+ className={cn(
52
+ item.badgeVariant === 'pill' && 'rounded-md bg-primary/15 px-1.5 font-medium text-primary',
53
+ )}
54
+ >
55
+ {item.badge}
56
+ </SidebarMenuBadge>
57
+ ) : null}
103
58
  </Link>
104
59
  </SidebarMenuButton>
105
- {collapsedBadgeDot}
106
60
  </SidebarMenuItem>
107
61
  );
108
62
  }
109
63
 
110
- /**
111
- * Shallow equality for SidebarItem — compares primitive fields so that
112
- * badge number changes (e.g. 3 → 5) still trigger a re-render, but
113
- * object-reference churn from parent re-renders is ignored.
114
- */
115
- function itemShallowEqual(a: SidebarItem, b: SidebarItem): boolean {
116
- return (
117
- a.href === b.href &&
118
- a.label === b.label &&
119
- a.icon === b.icon &&
120
- a.badge === b.badge &&
121
- a.badgeVariant === b.badgeVariant &&
122
- a.tooltip === b.tooltip
123
- );
124
- }
125
-
126
- /**
127
- * Memoised leaf component. Re-renders only when:
128
- * - `hideItemIcons` changes, or
129
- * - any field inside `item` changes (badge count, label, href, etc.).
130
- * Parent re-renders with the same item data are skipped.
131
- */
132
- export const SidebarNavItem = memo(SidebarNavItemRaw, (prev, next) => {
133
- return (
134
- prev.hideItemIcons === next.hideItemIcons &&
135
- itemShallowEqual(prev.item, next.item)
136
- );
137
- });
64
+ const itemEqual = (a: SidebarItem, b: SidebarItem) =>
65
+ a.href === b.href &&
66
+ a.label === b.label &&
67
+ a.icon === b.icon &&
68
+ a.badge === b.badge &&
69
+ a.badgeVariant === b.badgeVariant;
70
+
71
+ export const SidebarNavItem = memo(
72
+ SidebarNavItemRaw,
73
+ (prev, next) => prev.hideItemIcons === next.hideItemIcons && itemEqual(prev.item, next.item),
74
+ );
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Peek panel: the full sidebar floated over the page while it is collapsed.
3
+ * Opened and held by `SidebarPeekProvider`.
4
+ */
5
+
6
+ 'use client';
7
+
8
+ import React from 'react';
9
+
10
+ import { useSidebarPeek } from '../hooks';
11
+ import { SidebarBody } from './SidebarBody';
12
+ import { SidebarResizeHandle } from './SidebarResizeHandle';
13
+
14
+ export function SidebarPeek() {
15
+ const peek = useSidebarPeek();
16
+ if (!peek?.open) return null;
17
+
18
+ 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>
33
+ );
34
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Drag handle on the sidebar's right edge. Pointer drag, arrow keys and
3
+ * double-click-to-reset; the width itself lives in `useSidebarWidth`.
4
+ */
5
+
6
+ 'use client';
7
+
8
+ import React, { useRef } from 'react';
9
+
10
+ import { useSidebarWidthContext } from '../hooks';
11
+
12
+ const KEY_STEP = 16;
13
+
14
+ export function SidebarResizeHandle() {
15
+ const sidebarWidth = useSidebarWidthContext();
16
+ const origin = useRef<{ x: number; width: number } | null>(null);
17
+
18
+ if (!sidebarWidth?.enabled) return null;
19
+ const { width, min, max, dragging, setWidth, setDragging, reset } = sidebarWidth;
20
+
21
+ const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
22
+ if (e.button !== 0) return;
23
+ e.currentTarget.setPointerCapture(e.pointerId);
24
+ origin.current = { x: e.clientX, width };
25
+ setDragging(true);
26
+ };
27
+
28
+ const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
29
+ if (!origin.current) return;
30
+ setWidth(origin.current.width + e.clientX - origin.current.x);
31
+ };
32
+
33
+ const endDrag = () => {
34
+ origin.current = null;
35
+ setDragging(false);
36
+ };
37
+
38
+ const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
39
+ const next =
40
+ e.key === 'ArrowLeft' ? width - KEY_STEP
41
+ : e.key === 'ArrowRight' ? width + KEY_STEP
42
+ : e.key === 'Home' ? min
43
+ : e.key === 'End' ? max
44
+ : null;
45
+ if (next === null) return;
46
+ e.preventDefault();
47
+ e.stopPropagation();
48
+ setWidth(next);
49
+ };
50
+
51
+ return (
52
+ <div
53
+ role="separator"
54
+ aria-orientation="vertical"
55
+ aria-label="Resize sidebar"
56
+ aria-valuemin={min}
57
+ aria-valuemax={max}
58
+ aria-valuenow={width}
59
+ tabIndex={0}
60
+ data-dragging={dragging || undefined}
61
+ onPointerDown={onPointerDown}
62
+ onPointerMove={onPointerMove}
63
+ onPointerUp={endDrag}
64
+ onPointerCancel={endDrag}
65
+ onDoubleClick={reset}
66
+ onKeyDown={onKeyDown}
67
+ className={[
68
+ 'absolute inset-y-0 right-0 z-20 hidden w-2 cursor-col-resize touch-none outline-none md:block',
69
+ // 2px primary line on the border; fades in after a short hover delay.
70
+ 'after:absolute after:inset-y-0 after:right-0 after:w-0.5 after:bg-transparent',
71
+ 'after:transition-colors after:duration-150',
72
+ 'hover:after:bg-primary/70 hover:after:delay-100 focus-visible:after:bg-primary data-[dragging]:after:bg-primary',
73
+ ].join(' ')}
74
+ />
75
+ );
76
+ }
@@ -1,71 +1,32 @@
1
1
  /**
2
- * Sidebar Slots Component
3
- *
4
- * Renders optional content slots: menuStart, menuEnd, featured CTA, footer.
2
+ * Optional slots around the nav groups: menuStart, featured, menuEnd, footer.
5
3
  */
6
4
 
7
5
  'use client';
8
6
 
9
- import React, { memo, useMemo } from 'react';
7
+ import React, { memo } from 'react';
10
8
 
11
- import { SidebarFeatured } from './SidebarFeatured';
12
9
  import { usePrivateLayoutContext } from '../context';
13
- import { useShellVisualState } from '../hooks';
10
+ import { SidebarFeatured } from './SidebarFeatured';
11
+
12
+ const SLOT = 'w-full min-w-0 shrink-0 px-2';
14
13
 
15
14
  function SidebarSlotsRaw() {
16
15
  const { sidebar } = usePrivateLayoutContext();
17
- const { content } = useShellVisualState();
18
-
19
- const menuStartSlot = useMemo(
20
- () =>
21
- sidebar?.menuStart ? (
22
- <div className="w-full min-w-0 shrink-0 px-2">{sidebar.menuStart}</div>
23
- ) : null,
24
- [sidebar?.menuStart],
25
- );
26
-
27
- const menuEndSlot = useMemo(
28
- () =>
29
- sidebar?.menuEnd ? (
30
- <div className="w-full min-w-0 shrink-0 px-2">{sidebar.menuEnd}</div>
31
- ) : null,
32
- [sidebar?.menuEnd],
33
- );
34
-
35
- const featuredSlot = useMemo(
36
- () =>
37
- sidebar?.featured ? (
38
- <div className="w-full min-w-0 shrink-0 px-2">
39
- <SidebarFeatured config={sidebar.featured} />
40
- </div>
41
- ) : null,
42
- [sidebar?.featured],
43
- );
44
-
45
- const footerExtra = useMemo(
46
- () =>
47
- sidebar?.footer ? (
48
- <div className="mb-2">{sidebar.footer}</div>
49
- ) : null,
50
- [sidebar?.footer],
51
- );
52
-
53
- if (content.hideSlots) return null;
16
+ if (!sidebar) return null;
54
17
 
55
18
  return (
56
19
  <>
57
- {menuStartSlot}
58
- {featuredSlot}
59
- {menuEndSlot}
60
- {footerExtra}
20
+ {sidebar.menuStart ? <div className={SLOT}>{sidebar.menuStart}</div> : null}
21
+ {sidebar.featured ? (
22
+ <div className={SLOT}>
23
+ <SidebarFeatured config={sidebar.featured} />
24
+ </div>
25
+ ) : null}
26
+ {sidebar.menuEnd ? <div className={SLOT}>{sidebar.menuEnd}</div> : null}
27
+ {sidebar.footer ? <div className={SLOT}>{sidebar.footer}</div> : null}
61
28
  </>
62
29
  );
63
30
  }
64
31
 
65
- /**
66
- * Memoised slots wrapper. Skips re-renders when the sidebar config object
67
- * reference stays the same. The slot ReactNodes themselves (menuStart,
68
- * menuEnd, footer) are *not* deeply compared — they remain reactive because
69
- * the consumer is expected to pass a new ReactNode when slot content changes.
70
- */
71
32
  export const SidebarSlots = memo(SidebarSlotsRaw);
@@ -6,8 +6,4 @@ export { PrivateSidebar } from './PrivateSidebar';
6
6
  export { PrivateContent } from './PrivateContent';
7
7
  export { ForbiddenState } from './ForbiddenState';
8
8
  export type { ForbiddenStateProps } from './ForbiddenState';
9
- export { SidebarBrand } from './SidebarBrand';
10
9
  export { SidebarBrandSwitcher } from './SidebarBrandSwitcher';
11
- export { SidebarNavGroup } from './SidebarNavGroup';
12
- export { SidebarNavItem } from './SidebarNavItem';
13
- export { SidebarSlots } from './SidebarSlots';
@@ -1,76 +1,31 @@
1
1
  /**
2
2
  * PrivateLayout Context
3
3
  *
4
- * Provides UI state for the private layout shell:
5
- * - sidebar state (collapsed/expanded, mobile)
6
- * - nav density based on item count
7
- * - active indicator style
8
- * - group label style
9
- * - current pathname
4
+ * What the sidebar parts share: config, the active-route check, brand
5
+ * identity and the row geometry for the current form factor.
10
6
  */
11
7
 
12
8
  'use client';
13
9
 
14
10
  import React, { createContext, useContext, useMemo } from 'react';
15
11
 
16
- import type {
17
- HeaderConfig,
18
- SidebarActiveIndicator,
19
- SidebarConfig,
20
- SidebarGroupLabelStyle,
21
- SidebarItem,
22
- } from './types';
23
-
24
- import { DENSITY, RAIL_NAV } from './density';
25
- import type { NavDensity } from './density';
26
-
27
- // ============================================================================
28
- // Context Type
29
- // ============================================================================
12
+ import { NAV_GEOMETRY } from './geometry';
13
+ import type { NavGeometry } from './geometry';
14
+ import type { HeaderConfig, SidebarConfig, SidebarItem } from './types';
30
15
 
31
16
  export interface PrivateLayoutContextValue {
32
- /** Sidebar configuration */
33
17
  sidebar: SidebarConfig | undefined;
34
- /** Header configuration */
35
18
  header: HeaderConfig | undefined;
36
- /** Current pathname for active highlighting */
37
19
  pathname: string;
38
- /** Whether the sidebar is in mobile mode */
39
20
  isMobile: boolean;
40
- /** Whether the sidebar is expanded (vs collapsed icon rail) */
41
- isExpanded: boolean;
42
- /** Whether the sidebar is collapsed to icon rail */
43
- isCollapsed: boolean;
44
- /** Nav density based on total item count */
45
- density: NavDensity;
46
- /** Density tokens for the current state */
47
- menuNav: (typeof DENSITY)[NavDensity];
48
- /** Active indicator style */
49
- activeIndicator: SidebarActiveIndicator;
50
- /** Group label style */
51
- groupLabelStyle: SidebarGroupLabelStyle;
52
- /** All nav items flattened */
21
+ geometry: NavGeometry;
53
22
  allItems: SidebarItem[];
54
- /** Check if a href is active */
55
23
  isActive: (href: string) => boolean;
56
- /** Whether menuStart slot should be shown */
57
- showMenuStart: boolean;
58
- /** Whether menuEnd slot should be shown */
59
- showMenuEnd: boolean;
60
- /** Whether featured CTA should be shown */
61
- showFeatured: boolean;
62
- /** Home href */
63
24
  homeHref: string;
64
- /** Brand title derived from header */
65
25
  brandTitle: string;
66
- /** Brand monogram derived from header/title */
67
26
  brandMonogram: string;
68
27
  }
69
28
 
70
- // ============================================================================
71
- // Context
72
- // ============================================================================
73
-
74
29
  const PrivateLayoutContext = createContext<PrivateLayoutContextValue | null>(null);
75
30
 
76
31
  export function usePrivateLayoutContext(): PrivateLayoutContextValue {
@@ -81,30 +36,20 @@ export function usePrivateLayoutContext(): PrivateLayoutContextValue {
81
36
  return ctx;
82
37
  }
83
38
 
84
- // ============================================================================
85
- // Provider Props
86
- // ============================================================================
87
-
88
39
  interface PrivateLayoutProviderProps {
89
40
  children: React.ReactNode;
90
41
  sidebar: SidebarConfig | undefined;
91
42
  header: HeaderConfig | undefined;
92
43
  pathname: string;
93
44
  isMobile: boolean;
94
- state: 'expanded' | 'collapsed';
95
45
  }
96
46
 
97
- // ============================================================================
98
- // Provider
99
- // ============================================================================
100
-
101
47
  export function PrivateLayoutProvider({
102
48
  children,
103
49
  sidebar,
104
50
  header,
105
51
  pathname,
106
52
  isMobile,
107
- state,
108
53
  }: PrivateLayoutProviderProps) {
109
54
  const homeHref = sidebar?.homeHref || '/';
110
55
  const brandTitle = header?.title?.trim() || 'Dashboard';
@@ -119,12 +64,7 @@ export function PrivateLayoutProvider({
119
64
  [sidebar?.groups],
120
65
  );
121
66
 
122
- const density: NavDensity = sidebar?.density ?? 'default';
123
- const isExpanded = state === 'expanded';
124
- const isCollapsed = state === 'collapsed';
125
-
126
- const menuNav = isCollapsed ? RAIL_NAV : DENSITY[density];
127
-
67
+ // The most specific item wins: `/a` is not active on `/a/b` when `/a/b` is its own item.
128
68
  const isActive = React.useCallback(
129
69
  (href: string) => {
130
70
  const matches = pathname === href || pathname.startsWith(`${href}/`);
@@ -139,66 +79,21 @@ export function PrivateLayoutProvider({
139
79
  [pathname, allItems],
140
80
  );
141
81
 
142
- const collapsedRail = !isMobile && isCollapsed;
143
-
144
- const hasMenuStart = sidebar?.menuStart != null && sidebar.menuStart !== false;
145
- const hasMenuEnd = sidebar?.menuEnd != null && sidebar.menuEnd !== false;
146
-
147
- const showMenuStart =
148
- hasMenuStart && (!collapsedRail || sidebar?.menuStartShowOnCollapsed === true);
149
- const showMenuEnd =
150
- hasMenuEnd && (!collapsedRail || sidebar?.menuEndShowOnCollapsed === true);
151
- const showFeatured = Boolean(sidebar?.featured) && !collapsedRail;
152
-
153
- const activeIndicator = sidebar?.activeIndicator ?? 'background';
154
- const groupLabelStyle = sidebar?.groupLabelStyle ?? 'uppercase';
155
-
156
82
  const value = useMemo(
157
83
  () => ({
158
84
  sidebar,
159
85
  header,
160
86
  pathname,
161
87
  isMobile,
162
- isExpanded,
163
- isCollapsed,
164
- density,
165
- menuNav,
166
- activeIndicator,
167
- groupLabelStyle,
88
+ geometry: isMobile ? NAV_GEOMETRY.mobile : NAV_GEOMETRY.desktop,
168
89
  allItems,
169
90
  isActive,
170
- showMenuStart,
171
- showMenuEnd,
172
- showFeatured,
173
91
  homeHref,
174
92
  brandTitle,
175
93
  brandMonogram,
176
94
  }),
177
- [
178
- sidebar,
179
- header,
180
- pathname,
181
- isMobile,
182
- isExpanded,
183
- isCollapsed,
184
- density,
185
- menuNav,
186
- activeIndicator,
187
- groupLabelStyle,
188
- allItems,
189
- isActive,
190
- showMenuStart,
191
- showMenuEnd,
192
- showFeatured,
193
- homeHref,
194
- brandTitle,
195
- brandMonogram,
196
- ],
95
+ [sidebar, header, pathname, isMobile, allItems, isActive, homeHref, brandTitle, brandMonogram],
197
96
  );
198
97
 
199
- return (
200
- <PrivateLayoutContext.Provider value={value}>
201
- {children}
202
- </PrivateLayoutContext.Provider>
203
- );
98
+ return <PrivateLayoutContext.Provider value={value}>{children}</PrivateLayoutContext.Provider>;
204
99
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Sidebar row geometry. Two sizes only: a pointer-sized desktop row and a
3
+ * 44px touch row for the mobile drawer.
4
+ */
5
+
6
+ export const NAV_GEOMETRY = {
7
+ desktop: {
8
+ item: 'h-8 gap-2.5 px-2.5 text-base [&>svg]:size-4',
9
+ label: 'h-7 px-2.5 text-xs',
10
+ },
11
+ mobile: {
12
+ item: 'h-11 gap-3 px-3 text-lg [&>svg]:size-5',
13
+ label: 'h-8 px-3 text-sm',
14
+ },
15
+ } as const;
16
+
17
+ export type NavGeometry = (typeof NAV_GEOMETRY)[keyof typeof NAV_GEOMETRY];
@@ -4,6 +4,7 @@
4
4
 
5
5
  export { useAuthGuard } from './useAuthGuard';
6
6
  export { useLayoutVisual } from './useLayoutVisual';
7
- export { useShellVisualState } from './useShellVisualState';
8
7
  export { useSidebarKeyboard } from './useSidebarKeyboard';
9
8
  export { useSidebarDefaultOpen } from './useSidebarDefaultOpen';
9
+ export { SidebarPeekProvider, usePeekPin, useSidebarPeek } from './useSidebarPeek';
10
+ export { SidebarWidthContext, useSidebarWidth, useSidebarWidthContext } from './useSidebarWidth';
@@ -12,7 +12,7 @@ import type { LayoutVisualConfig } from '../../types';
12
12
  export function resolveProviderStyle(
13
13
  visual: LayoutVisualConfig | undefined,
14
14
  ): React.CSSProperties | undefined {
15
- if ((visual?.variant ?? 'boxed') !== 'boxed') return undefined;
15
+ if ((visual?.variant ?? 'full-bleed') !== 'boxed') return undefined;
16
16
  const inset = normaliseInset(visual?.inset);
17
17
  return {
18
18
  ['--app-shell-inset-x' as string]: `${inset.x}px`,
@@ -47,7 +47,7 @@ const BOXED_RADIUS_REM: Record<NonNullable<LayoutVisualConfig['radius']>, string
47
47
  };
48
48
 
49
49
  export function resolveInsetClassName(visual: LayoutVisualConfig | undefined): string {
50
- if ((visual?.variant ?? 'boxed') !== 'boxed') return 'flex flex-col';
50
+ if ((visual?.variant ?? 'full-bleed') !== 'boxed') return 'flex flex-col';
51
51
  const border = visual?.border ?? true;
52
52
  return border ? `${BOXED_INSET_CLASS} ${BOXED_INSET_BORDER_CLASS}` : BOXED_INSET_CLASS;
53
53
  }
@@ -75,7 +75,7 @@ export function resolveProviderClassName(
75
75
  // the inner scroll-area (PrivateContent) is the only scroll surface. Without
76
76
  // this, SidebarProvider grows via min-h-svh and the whole page scrolls.
77
77
  const base = 'h-svh overflow-hidden';
78
- if ((visual?.variant ?? 'boxed') !== 'boxed') return base;
78
+ if ((visual?.variant ?? 'full-bleed') !== 'boxed') return base;
79
79
  // `max-md:!bg-background` neutralises shadcn-sidebar's built-in
80
80
  // `has-[[data-variant=inset]]:bg-sidebar` below md so the mobile Drawer shell
81
81
  // doesn't paint the whole viewport with the canvas tint.
@@ -100,7 +100,7 @@ export interface LayoutVisualResult {
100
100
 
101
101
  export function useLayoutVisual(visual: LayoutVisualConfig | undefined): LayoutVisualResult {
102
102
  return useMemo(() => {
103
- const variant = visual?.variant ?? 'boxed';
103
+ const variant = visual?.variant ?? 'full-bleed';
104
104
  const sidebarVariant = variant === 'boxed' ? 'inset' : 'sidebar';
105
105
 
106
106
  return {