@djangocfg/layouts 2.1.591 → 2.1.593

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 (100) hide show
  1. package/package.json +12 -12
  2. package/src/components/errors/ErrorLayout.tsx +1 -164
  3. package/src/components/errors/ErrorsTracker/providers/ErrorTrackingProvider.tsx +1 -100
  4. package/src/components/errors/ErrorsTracker/providers/errorDedupe.ts +101 -0
  5. package/src/components/errors/errorGlyphs.tsx +167 -0
  6. package/src/layouts/AppLayout/BaseApp.tsx +1 -1
  7. package/src/layouts/AppLayout/index.ts +2 -2
  8. package/src/layouts/AuthLayout/AuthLayout.tsx +3 -23
  9. package/src/layouts/AuthLayout/components/shared/AuthTopBar.tsx +1 -1
  10. package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +1 -1
  11. package/src/layouts/AuthLayout/layoutContext.ts +25 -0
  12. package/src/layouts/AuthLayout/styles/split-shell.css +0 -1
  13. package/src/layouts/PrivateLayout/PrivateLayout.tsx +37 -26
  14. package/src/layouts/PrivateLayout/README.md +20 -12
  15. package/src/layouts/PrivateLayout/components/PrivateContent.tsx +29 -42
  16. package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +23 -92
  17. package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +41 -112
  18. package/src/layouts/PrivateLayout/components/SidebarBody.tsx +45 -0
  19. package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +64 -152
  20. package/src/layouts/PrivateLayout/components/SidebarBrandSwitcher.tsx +2 -30
  21. package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +55 -115
  22. package/src/layouts/PrivateLayout/components/SidebarNavItem.tsx +37 -100
  23. package/src/layouts/PrivateLayout/components/SidebarPeek.tsx +34 -0
  24. package/src/layouts/PrivateLayout/components/SidebarResizeHandle.tsx +76 -0
  25. package/src/layouts/PrivateLayout/components/SidebarSlots.tsx +14 -53
  26. package/src/layouts/PrivateLayout/components/index.ts +0 -4
  27. package/src/layouts/PrivateLayout/context.tsx +10 -115
  28. package/src/layouts/PrivateLayout/geometry.ts +17 -0
  29. package/src/layouts/PrivateLayout/hooks/index.ts +2 -1
  30. package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +4 -4
  31. package/src/layouts/PrivateLayout/hooks/useSidebarKeyboard.ts +33 -93
  32. package/src/layouts/PrivateLayout/hooks/useSidebarPeek.tsx +151 -0
  33. package/src/layouts/PrivateLayout/hooks/useSidebarWidth.ts +83 -0
  34. package/src/layouts/PrivateLayout/types.ts +13 -20
  35. package/src/layouts/PublicLayout/footers/DefaultFooter/DefaultFooter.tsx +3 -89
  36. package/src/layouts/PublicLayout/footers/DefaultFooter/ThemeModeControl.tsx +90 -0
  37. package/src/layouts/PublicLayout/primitives/DesktopDropdownPortal.tsx +47 -0
  38. package/src/layouts/PublicLayout/primitives/NavControls.tsx +1 -1
  39. package/src/layouts/PublicLayout/primitives/NavDesktopItems.tsx +2 -83
  40. package/src/layouts/PublicLayout/primitives/navDesktopStyles.ts +42 -0
  41. package/src/layouts/PublicLayout/shared/NavbarShell.tsx +4 -171
  42. package/src/layouts/PublicLayout/shared/index.ts +1 -1
  43. package/src/layouts/PublicLayout/shared/navbarShell.types.ts +109 -0
  44. package/src/layouts/PublicLayout/shared/navbarShellStyles.ts +69 -0
  45. package/src/layouts/SettingsLayout/README.md +23 -10
  46. package/src/layouts/SettingsLayout/SettingsDialog.tsx +27 -11
  47. package/src/layouts/SettingsLayout/SettingsForm.tsx +13 -9
  48. package/src/layouts/SettingsLayout/components/AccountMenuButton.tsx +83 -0
  49. package/src/layouts/SettingsLayout/components/ApiKeySection/ApiKeySection.tsx +9 -7
  50. package/src/layouts/SettingsLayout/components/ApiKeySection/context.tsx +6 -41
  51. package/src/layouts/SettingsLayout/components/AvatarSection.tsx +11 -3
  52. package/src/layouts/SettingsLayout/components/SettingsNav.tsx +5 -9
  53. package/src/layouts/SettingsLayout/components/SettingsNavItem.tsx +5 -7
  54. package/src/layouts/SettingsLayout/components/SettingsPanel.tsx +5 -10
  55. package/src/layouts/SettingsLayout/components/SettingsSearch.tsx +5 -3
  56. package/src/layouts/SettingsLayout/components/SettingsShell.tsx +14 -21
  57. package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSection.tsx +63 -131
  58. package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSetupDialog.tsx +35 -0
  59. package/src/layouts/SettingsLayout/components/index.ts +0 -1
  60. package/src/layouts/SettingsLayout/context/SettingsContext.tsx +5 -2
  61. package/src/layouts/SettingsLayout/hooks/index.ts +3 -0
  62. package/src/layouts/SettingsLayout/hooks/useAccountMenuItems.ts +69 -0
  63. package/src/layouts/SettingsLayout/hooks/useSettingsDialog.ts +6 -3
  64. package/src/layouts/SettingsLayout/i18n/index.ts +39 -0
  65. package/src/layouts/SettingsLayout/i18n/locales/ar.ts +66 -0
  66. package/src/layouts/SettingsLayout/i18n/locales/da.ts +66 -0
  67. package/src/layouts/SettingsLayout/i18n/locales/de.ts +66 -0
  68. package/src/layouts/SettingsLayout/i18n/locales/en.ts +66 -0
  69. package/src/layouts/SettingsLayout/i18n/locales/es.ts +66 -0
  70. package/src/layouts/SettingsLayout/i18n/locales/fr.ts +66 -0
  71. package/src/layouts/SettingsLayout/i18n/locales/it.ts +66 -0
  72. package/src/layouts/SettingsLayout/i18n/locales/ja.ts +66 -0
  73. package/src/layouts/SettingsLayout/i18n/locales/ko.ts +66 -0
  74. package/src/layouts/SettingsLayout/i18n/locales/nl.ts +66 -0
  75. package/src/layouts/SettingsLayout/i18n/locales/no.ts +66 -0
  76. package/src/layouts/SettingsLayout/i18n/locales/pl.ts +66 -0
  77. package/src/layouts/SettingsLayout/i18n/locales/ptBR.ts +66 -0
  78. package/src/layouts/SettingsLayout/i18n/locales/ru.ts +66 -0
  79. package/src/layouts/SettingsLayout/i18n/locales/sv.ts +66 -0
  80. package/src/layouts/SettingsLayout/i18n/locales/tr.ts +66 -0
  81. package/src/layouts/SettingsLayout/i18n/locales/zh.ts +66 -0
  82. package/src/layouts/SettingsLayout/i18n/types.ts +65 -0
  83. package/src/layouts/SettingsLayout/index.ts +3 -1
  84. package/src/layouts/SettingsLayout/sections/AccountSection.tsx +9 -4
  85. package/src/layouts/SettingsLayout/sections/DeleteAccountRow.tsx +4 -1
  86. package/src/layouts/SettingsLayout/sections/GeneralSection.tsx +25 -0
  87. package/src/layouts/SettingsLayout/sections/PreferencesRows.tsx +13 -5
  88. package/src/layouts/SettingsLayout/sections/builtins.tsx +34 -29
  89. package/src/layouts/SettingsLayout/sections/index.ts +2 -2
  90. package/src/layouts/SettingsLayout/store.ts +12 -0
  91. package/src/layouts/SettingsLayout/types.ts +12 -4
  92. package/src/layouts/_components/locale-switcher/LocaleSwitcher.tsx +1 -1
  93. package/src/layouts/types/layout.types.ts +5 -6
  94. package/src/{layouts/AppLayout → shared}/LayoutI18nProvider.tsx +1 -1
  95. package/src/styles/dashboard.css +10 -0
  96. package/src/layouts/PrivateLayout/density.ts +0 -48
  97. package/src/layouts/PrivateLayout/hooks/useHoverExpand.ts +0 -110
  98. package/src/layouts/PrivateLayout/hooks/useShellVisualState.ts +0 -130
  99. package/src/layouts/SettingsLayout/components/SettingsRailFooter.tsx +0 -74
  100. package/src/layouts/SettingsLayout/sections/SecuritySection.tsx +0 -15
@@ -1,12 +1,12 @@
1
1
  /**
2
- * Sidebar Nav Group Component
3
- *
4
- * Renders a group of nav items as either a flat list or collapsible accordion.
2
+ * Sidebar Nav Group — a labelled list, or an accordion when `collapsible`.
3
+ * Groups are separated by a hairline, not by louder labels.
5
4
  */
6
5
 
7
6
  'use client';
8
7
 
9
- import React, { useMemo, useState, useEffect, useCallback, memo } from 'react';
8
+ import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
9
+ import { ChevronDown } from 'lucide-react';
10
10
 
11
11
  import {
12
12
  Collapsible,
@@ -17,26 +17,24 @@ import {
17
17
  SidebarGroupLabel,
18
18
  SidebarMenu,
19
19
  } from '@djangocfg/ui-core/components';
20
- import { ChevronDown } from 'lucide-react';
21
20
  import { cn } from '@djangocfg/ui-core/lib';
22
21
 
23
22
  import { LucideIcon } from '../../../components';
23
+ import { shallowEqualBy } from '../../../utils/shallowEqualBy';
24
24
  import { usePrivateLayoutContext } from '../context';
25
- import { useShellVisualState } from '../hooks';
26
25
  import type { SidebarGroupConfig } from '../types';
27
26
  import { SidebarNavItem } from './SidebarNavItem';
28
- import { shallowEqualBy } from '../../../utils/shallowEqualBy';
29
27
 
30
- interface SidebarNavGroupProps {
31
- group: SidebarGroupConfig;
32
- }
28
+ const GROUP_CLASS = cn(
29
+ 'gap-0 px-2 py-1',
30
+ '[&:not(:first-child)]:mt-1 [&:not(:first-child)]:border-t [&:not(:first-child)]:border-sidebar-border/60 [&:not(:first-child)]:pt-2',
31
+ );
33
32
 
34
- function SidebarNavGroupRaw({ group }: SidebarNavGroupProps) {
35
- const { isActive, menuNav, groupLabelStyle } = usePrivateLayoutContext();
36
- const { content } = useShellVisualState();
33
+ function SidebarNavGroupRaw({ group }: { group: SidebarGroupConfig }) {
34
+ const { isActive, geometry } = usePrivateLayoutContext();
37
35
 
38
- const hasLabel = Boolean(group.label && group.label.trim().length > 0);
39
- const isCollapsible = Boolean(group.collapsible) && hasLabel && content.showLabels;
36
+ const hasLabel = group.label.trim().length > 0;
37
+ const isCollapsible = Boolean(group.collapsible) && hasLabel;
40
38
  const hideItemIcons = group.hideItemIcons ?? isCollapsible;
41
39
 
42
40
  const hasActiveChild = useMemo(
@@ -44,94 +42,47 @@ function SidebarNavGroupRaw({ group }: SidebarNavGroupProps) {
44
42
  [group.items, isActive],
45
43
  );
46
44
 
47
- const [open, setOpen] = useState<boolean>(
48
- isCollapsible ? Boolean(group.defaultOpen) || hasActiveChild : true,
49
- );
45
+ const [open, setOpen] = useState(isCollapsible ? Boolean(group.defaultOpen) || hasActiveChild : true);
50
46
 
51
47
  useEffect(() => {
52
48
  if (isCollapsible && hasActiveChild) setOpen(true);
53
49
  }, [isCollapsible, hasActiveChild]);
54
50
 
55
- const items = useMemo(
56
- () =>
57
- group.items.map((item) => (
58
- <SidebarNavItem key={item.href} item={item} hideItemIcons={hideItemIcons} />
59
- )),
60
- [group.items, hideItemIcons],
61
- );
51
+ const handleOpenChange = useCallback((value: boolean) => setOpen(value), []);
62
52
 
63
- const groupLabelUppercaseClass = useMemo(
64
- () => cn('px-2', menuNav.label),
65
- [menuNav.label],
66
- );
67
- const groupLabelPlainClass = useMemo(
68
- () =>
69
- cn(
70
- 'px-2 text-sm font-semibold text-sidebar-foreground',
71
- 'h-7 leading-none',
72
- ),
73
- [],
74
- );
75
-
76
- const labelClass = useMemo(
77
- () =>
78
- cn(
79
- isCollapsible || groupLabelStyle === 'plain'
80
- ? groupLabelPlainClass
81
- : groupLabelUppercaseClass,
82
- content.showGroupLabels && '!mt-0 !opacity-100 !pointer-events-auto !flex !h-auto',
83
- ),
84
- [isCollapsible, groupLabelStyle, groupLabelPlainClass, groupLabelUppercaseClass, content.showGroupLabels],
85
- );
86
-
87
- const sidebarGroupClass = useMemo(
88
- () => cn('gap-0', menuNav.groupPad),
89
- [menuNav.groupPad],
90
- );
91
-
92
- const handleOpenChange = useCallback((value: boolean) => {
93
- setOpen(value);
94
- }, []);
53
+ const items = group.items.map((item) => (
54
+ <SidebarNavItem key={item.href} item={item} hideItemIcons={hideItemIcons} />
55
+ ));
95
56
 
96
57
  if (isCollapsible) {
97
- const triggerIcon = group.icon ? (
98
- <LucideIcon
99
- icon={group.icon}
100
- className={cn(menuNav.iconClass, 'shrink-0 text-sidebar-foreground/70')}
101
- />
102
- ) : null;
103
-
104
58
  return (
105
- <SidebarGroup className={sidebarGroupClass}>
59
+ <SidebarGroup className={GROUP_CLASS}>
106
60
  <Collapsible open={open} onOpenChange={handleOpenChange} className="w-full">
107
61
  <CollapsibleTrigger asChild>
108
62
  <button
109
63
  type="button"
64
+ aria-expanded={open}
110
65
  className={cn(
111
- 'group/trig flex w-full items-center gap-2 rounded-md px-2 py-1.5',
112
- 'text-sm font-semibold text-sidebar-foreground',
113
- 'transition-colors hover:bg-sidebar-accent/40',
114
- 'data-[no-expand]',
66
+ 'flex w-full items-center gap-2.5 rounded-lg font-normal text-sidebar-foreground',
67
+ 'transition-colors duration-150 hover:bg-sidebar-accent/60',
68
+ 'outline-none ring-sidebar-ring focus-visible:ring-2',
69
+ geometry.item,
115
70
  )}
116
- aria-expanded={open}
117
- data-no-expand
118
71
  >
119
- {triggerIcon}
72
+ {group.icon ? <LucideIcon icon={group.icon} className="shrink-0 text-sidebar-foreground/60" /> : null}
120
73
  <span className="flex-1 truncate text-left">{group.label}</span>
121
74
  <ChevronDown
75
+ aria-hidden
122
76
  className={cn(
123
- 'h-4 w-4 shrink-0 text-sidebar-foreground/55 transition-transform duration-200',
77
+ 'size-4 shrink-0 text-sidebar-foreground/50 transition-transform duration-200',
124
78
  open && 'rotate-180',
125
79
  )}
126
- aria-hidden
127
80
  />
128
81
  </button>
129
82
  </CollapsibleTrigger>
130
83
  <CollapsibleContent className="overflow-hidden data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down">
131
84
  <SidebarGroupContent>
132
- <SidebarMenu className={cn(menuNav.menu, 'mt-1')}>
133
- {items}
134
- </SidebarMenu>
85
+ <SidebarMenu className="mt-0.5 gap-0.5">{items}</SidebarMenu>
135
86
  </SidebarGroupContent>
136
87
  </CollapsibleContent>
137
88
  </Collapsible>
@@ -140,50 +91,39 @@ function SidebarNavGroupRaw({ group }: SidebarNavGroupProps) {
140
91
  }
141
92
 
142
93
  return (
143
- <SidebarGroup className={sidebarGroupClass}>
94
+ <SidebarGroup className={GROUP_CLASS}>
144
95
  {hasLabel ? (
145
- <SidebarGroupLabel className={labelClass}>{group.label}</SidebarGroupLabel>
96
+ <SidebarGroupLabel
97
+ className={cn('font-medium normal-case tracking-normal text-sidebar-foreground/50', geometry.label)}
98
+ >
99
+ {group.label}
100
+ </SidebarGroupLabel>
146
101
  ) : null}
147
- <SidebarGroupContent className="mt-1">
148
- <SidebarMenu className={menuNav.menu}>{items}</SidebarMenu>
102
+ <SidebarGroupContent>
103
+ <SidebarMenu className="gap-0.5">{items}</SidebarMenu>
149
104
  </SidebarGroupContent>
150
105
  </SidebarGroup>
151
106
  );
152
107
  }
153
108
 
154
- /**
155
- * Shallow equality for SidebarGroupConfig — compares all scalar fields and
156
- * deep-shallow-compares the `items` array. This lets badge counts and
157
- * dynamic group visibility update, while skipping re-renders caused by
158
- * parent reference churn.
159
- */
160
- function groupShallowEqual(a: SidebarGroupConfig, b: SidebarGroupConfig): boolean {
161
- return (
162
- a.label === b.label &&
163
- a.collapsible === b.collapsible &&
164
- a.defaultOpen === b.defaultOpen &&
165
- a.icon === b.icon &&
166
- a.hideItemIcons === b.hideItemIcons &&
167
- a.dynamic === b.dynamic &&
168
- shallowEqualBy(
169
- a.items,
170
- b.items,
171
- (ai, bi) =>
172
- ai.href === bi.href &&
173
- ai.label === bi.label &&
174
- ai.icon === bi.icon &&
175
- ai.badge === bi.badge &&
176
- ai.badgeVariant === bi.badgeVariant &&
177
- ai.tooltip === bi.tooltip,
178
- )
109
+ const groupEqual = (a: SidebarGroupConfig, b: SidebarGroupConfig) =>
110
+ a.label === b.label &&
111
+ a.collapsible === b.collapsible &&
112
+ a.defaultOpen === b.defaultOpen &&
113
+ a.icon === b.icon &&
114
+ a.hideItemIcons === b.hideItemIcons &&
115
+ a.dynamic === b.dynamic &&
116
+ shallowEqualBy(
117
+ a.items,
118
+ b.items,
119
+ (x, y) =>
120
+ x.href === y.href &&
121
+ x.label === y.label &&
122
+ x.icon === y.icon &&
123
+ x.badge === y.badge &&
124
+ x.badgeVariant === y.badgeVariant,
179
125
  );
180
- }
181
126
 
182
- /**
183
- * Memoised group wrapper. Re-renders only when the `group` prop changes
184
- * (label, items, badges, etc.). Same data from a new parent object is
185
- * ignored thanks to `groupShallowEqual`.
186
- */
187
- export const SidebarNavGroup = memo(SidebarNavGroupRaw, (prev, next) => {
188
- return groupShallowEqual(prev.group, next.group);
189
- });
127
+ export const SidebarNavGroup = memo(SidebarNavGroupRaw, (prev, next) =>
128
+ groupEqual(prev.group, next.group),
129
+ );
@@ -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';