@djangocfg/layouts 2.1.591 → 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
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The sidebar's content: brand header, scrollable nav, account footer.
3
+ * Rendered by the docked sidebar, the peek panel and the mobile drawer alike.
4
+ */
5
+
6
+ 'use client';
7
+
8
+ import React, { useMemo } from 'react';
9
+
10
+ import { usePrivateLayoutContext } from '../context';
11
+ import { PrivateSidebarAccount } from './PrivateSidebarAccount';
12
+ import { SidebarBrand } from './SidebarBrand';
13
+ import { SidebarNavGroup } from './SidebarNavGroup';
14
+ import { SidebarSlots } from './SidebarSlots';
15
+
16
+ export function SidebarBody() {
17
+ const { sidebar, header } = usePrivateLayoutContext();
18
+
19
+ const groups = useMemo(
20
+ () =>
21
+ (sidebar?.groups ?? []).map((group) =>
22
+ group.dynamic && group.items.length === 0 ? null : (
23
+ <SidebarNavGroup
24
+ key={group.label || `__flat_${group.items.map((i) => i.href).join('|')}`}
25
+ group={group}
26
+ />
27
+ ),
28
+ ),
29
+ [sidebar?.groups],
30
+ );
31
+
32
+ return (
33
+ <>
34
+ <SidebarBrand />
35
+ <div
36
+ data-sidebar="content"
37
+ className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto overflow-x-hidden py-2"
38
+ >
39
+ <SidebarSlots />
40
+ {groups}
41
+ </div>
42
+ <PrivateSidebarAccount header={header} />
43
+ </>
44
+ );
45
+ }
@@ -1,179 +1,91 @@
1
1
  /**
2
- * Sidebar Brand Component
3
- *
4
- * Renders the sidebar header with brand mark + title.
5
- * Three modes: expanded (desktop), collapsed rail (desktop), mobile drawer.
2
+ * Sidebar header: brand (or workspace switcher) on the left, one control on the
3
+ * right — collapse on desktop, close on the mobile drawer.
6
4
  */
7
5
 
8
6
  'use client';
9
7
 
10
- import React, { memo, useMemo } from 'react';
8
+ import React, { memo } from 'react';
9
+ import { X } from 'lucide-react';
11
10
 
12
- import { Link, SidebarHeader, SidebarTrigger } from '@djangocfg/ui-core/components';
11
+ import { Button, Link, SidebarTrigger, useSidebar } from '@djangocfg/ui-core/components';
13
12
  import { cn } from '@djangocfg/ui-core/lib';
14
13
 
15
14
  import { LucideIcon } from '../../../components';
16
15
  import { usePrivateLayoutContext } from '../context';
17
- import { useShellVisualState } from '../hooks';
18
16
  import { SidebarBrandSwitcher } from './SidebarBrandSwitcher';
19
17
 
20
- const HEADER_ROW_CLASS = 'flex items-center gap-2 mb-2';
18
+ const BRAND_LINK =
19
+ 'flex min-w-0 items-center gap-2.5 rounded-lg py-1 outline-none ring-sidebar-ring focus-visible:ring-2';
21
20
 
22
21
  function SidebarBrandRaw() {
23
- const { header, homeHref, brandTitle, brandMonogram, isMobile } =
24
- usePrivateLayoutContext();
25
- const { content } = useShellVisualState();
26
-
27
- const brandMark = useMemo(
28
- () =>
29
- header?.brandIcon ? (
30
- <LucideIcon
31
- icon={header.brandIcon}
32
- className="h-4 w-4 text-sidebar-primary-foreground"
33
- />
34
- ) : (
35
- <span className="text-[11px] font-bold leading-none tracking-tight text-sidebar-primary-foreground">
36
- {brandMonogram}
37
- </span>
38
- ),
39
- [header?.brandIcon, brandMonogram],
40
- );
22
+ const { header, homeHref, brandTitle, brandMonogram, isMobile } = usePrivateLayoutContext();
23
+ const { setOpenMobile } = useSidebar();
41
24
 
42
25
  const customBrand = header?.brand;
43
26
 
44
- const headerRowClass = HEADER_ROW_CLASS;
45
-
46
- const expandedHeader = useMemo(
47
- () => (
48
- <div className={headerRowClass}>
49
- <div className="min-w-0 flex-1">
50
- {customBrand != null && customBrand !== false ? (
51
- typeof customBrand === 'string' ? (
52
- <Link
53
- href={homeHref}
54
- className="flex min-w-0 items-center gap-2 rounded-md py-0.5 outline-none ring-sidebar-ring focus-visible:ring-2"
55
- >
56
- <span className="truncate text-sm font-semibold tracking-tight text-sidebar-foreground">
57
- {customBrand}
58
- </span>
59
- </Link>
60
- ) : (
61
- customBrand
62
- )
63
- ) : (
64
- <Link
65
- href={homeHref}
66
- className="flex min-w-0 items-center gap-2 rounded-md py-0.5 outline-none ring-sidebar-ring focus-visible:ring-2"
67
- >
68
- <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-sidebar-primary">
69
- {brandMark}
70
- </div>
71
- <span className="truncate text-sm font-semibold tracking-tight text-sidebar-foreground">
72
- {brandTitle}
73
- </span>
74
- </Link>
27
+ const brand =
28
+ customBrand != null && customBrand !== false ? (
29
+ typeof customBrand === 'string' ? (
30
+ <Link href={homeHref} className={BRAND_LINK}>
31
+ <span className="truncate text-base font-medium tracking-tight">{customBrand}</span>
32
+ </Link>
33
+ ) : (
34
+ customBrand
35
+ )
36
+ ) : (
37
+ <Link href={homeHref} className={BRAND_LINK}>
38
+ <span
39
+ className={cn(
40
+ 'flex shrink-0 items-center justify-center rounded-lg bg-sidebar-primary',
41
+ isMobile ? 'size-9' : 'size-7',
75
42
  )}
76
- </div>
77
- {!isMobile && <SidebarTrigger className="shrink-0" aria-label="Collapse sidebar" />}
78
- </div>
79
- ),
80
- [headerRowClass, customBrand, homeHref, brandMark, brandTitle, isMobile],
81
- );
82
-
83
- const collapsedHeader = useMemo(
84
- () => (
85
- <div className="group/collapsed-brand flex justify-center py-1">
86
- <div className="relative h-7 w-7">
87
- <Link
88
- href={homeHref}
89
- className="absolute inset-0 flex items-center justify-center rounded-md bg-sidebar-primary outline-none ring-sidebar-ring focus-visible:ring-2 transition-opacity group-hover/collapsed-brand:opacity-0"
90
- aria-label={brandTitle}
91
- >
92
- {brandMark}
93
- </Link>
94
- <div className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-hover/collapsed-brand:opacity-100">
95
- <SidebarTrigger aria-label="Expand sidebar" />
96
- </div>
97
- </div>
98
- </div>
99
- ),
100
- [homeHref, brandTitle, brandMark],
101
- );
102
-
103
- /** Mobile drawer: menu open/close only from the main column trigger — no duplicate toggle in the sheet. */
104
- const mobileHeader = useMemo(
105
- () => (
106
- <div className="flex items-center gap-3">
107
- <div className="min-w-0 flex-1">
108
- {customBrand != null && customBrand !== false ? (
109
- typeof customBrand === 'string' ? (
110
- <Link
111
- href={homeHref}
112
- className="flex min-w-0 items-center gap-3 rounded-md py-1 outline-none ring-sidebar-ring focus-visible:ring-2"
113
- >
114
- <span className="truncate text-sm font-semibold tracking-tight text-sidebar-foreground">
115
- {customBrand}
116
- </span>
117
- </Link>
118
- ) : (
119
- customBrand
120
- )
43
+ >
44
+ {header?.brandIcon ? (
45
+ <LucideIcon icon={header.brandIcon} className="size-4 text-sidebar-primary-foreground" />
121
46
  ) : (
122
- <Link
123
- href={homeHref}
124
- className="flex min-w-0 items-center gap-3 rounded-md py-1 outline-none ring-sidebar-ring focus-visible:ring-2"
125
- >
126
- <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-sidebar-primary">
127
- {brandMark}
128
- </div>
129
- <span className="truncate text-sm font-semibold tracking-tight text-sidebar-foreground">
130
- {brandTitle}
131
- </span>
132
- </Link>
47
+ <span className="text-[11px] font-bold leading-none text-sidebar-primary-foreground">
48
+ {brandMonogram}
49
+ </span>
133
50
  )}
134
- </div>
135
- </div>
136
- ),
137
- [customBrand, homeHref, brandMark, brandTitle],
51
+ </span>
52
+ <span className={cn('truncate font-medium tracking-tight', isMobile ? 'text-lg' : 'text-base')}>
53
+ {brandTitle}
54
+ </span>
55
+ </Link>
56
+ );
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" />
138
71
  );
139
72
 
140
- // Switcher mode: trigger embedded inside switcher row on desktop expanded
141
- const switcherContent = header?.switcher
142
- ? (
143
- <div className="mb-2">
144
- <SidebarBrandSwitcher
145
- config={header.switcher}
146
- showCollapseTrigger={content.showLabels && !isMobile}
147
- />
148
- </div>
149
- )
150
- : null;
151
-
152
- const sidebarHeaderContent = switcherContent
153
- ?? (isMobile
154
- ? mobileHeader
155
- : content.showLabels
156
- ? expandedHeader
157
- : collapsedHeader);
158
-
159
- const sidebarHeaderClass = useMemo(
160
- () =>
161
- cn(
162
- 'pb-2',
73
+ return (
74
+ <div
75
+ data-sidebar="header"
76
+ className={cn(
77
+ 'flex shrink-0 items-center gap-2',
163
78
  isMobile
164
- ? 'pb-3 pt-[max(1.25rem,env(safe-area-inset-top,0px))]'
165
- : 'pt-3.5',
166
- ),
167
- [isMobile],
79
+ ? 'px-4 pb-2 pt-[max(0.5rem,env(safe-area-inset-top,0px))]'
80
+ : 'px-3 pb-1 pt-3',
81
+ )}
82
+ >
83
+ <div className="min-w-0 flex-1">
84
+ {header?.switcher ? <SidebarBrandSwitcher config={header.switcher} showCollapseTrigger={!isMobile} /> : brand}
85
+ </div>
86
+ {trailing}
87
+ </div>
168
88
  );
169
-
170
- return <SidebarHeader className={sidebarHeaderClass}>{sidebarHeaderContent}</SidebarHeader>;
171
89
  }
172
90
 
173
- /**
174
- * Memoised brand header. Re-renders only when context values change
175
- * (header config, brand title, mobile state, showLabels). The three
176
- * visual modes (expanded / collapsed rail / mobile) are pre-built with
177
- * useMemo so the JSX tree is stable across renders.
178
- */
179
91
  export const SidebarBrand = memo(SidebarBrandRaw);
@@ -4,8 +4,6 @@
4
4
  * Dropdown for switching workspaces / accounts / projects.
5
5
  * Renders in the sidebar header area, replacing the static brand block.
6
6
  *
7
- * Collapsed rail: shows only the active item's avatar/monogram (no dropdown trigger).
8
- * Hover-expanded / mobile: shows full trigger + dropdown.
9
7
  */
10
8
 
11
9
  'use client';
@@ -27,7 +25,7 @@ import {
27
25
  import { Link } from '@djangocfg/ui-core/components';
28
26
  import { cn } from '@djangocfg/ui-core/lib';
29
27
 
30
- import { useShellVisualState } from '../hooks';
28
+ import { usePeekPin } from '../hooks';
31
29
  import type { SidebarBrandSwitcherConfig, SidebarBrandSwitcherItem } from '../types';
32
30
 
33
31
  interface SidebarBrandSwitcherProps {
@@ -37,8 +35,8 @@ interface SidebarBrandSwitcherProps {
37
35
  }
38
36
 
39
37
  function SidebarBrandSwitcherRaw({ config, showCollapseTrigger }: SidebarBrandSwitcherProps) {
40
- const { content } = useShellVisualState();
41
38
  const [open, setOpen] = React.useState(false);
39
+ usePeekPin(open);
42
40
 
43
41
  const activeItem = useMemo(
44
42
  () => config.items.find((i) => i.active) ?? config.items[0] ?? null,
@@ -66,32 +64,6 @@ function SidebarBrandSwitcherRaw({ config, showCollapseTrigger }: SidebarBrandSw
66
64
  </Avatar>
67
65
  );
68
66
 
69
- // Collapsed rail — avatar with trigger on hover
70
- if (!content.showLabels) {
71
- return (
72
- <div className="group/collapsed-switcher flex justify-center py-1">
73
- <div className="relative h-7 w-7">
74
- {activeItem.href ? (
75
- <Link
76
- href={activeItem.href}
77
- className="absolute inset-0 flex items-center justify-center rounded-md outline-none ring-sidebar-ring focus-visible:ring-2 transition-opacity group-hover/collapsed-switcher:opacity-0"
78
- aria-label={activeItem.label}
79
- >
80
- {activeAvatar}
81
- </Link>
82
- ) : (
83
- <div className="absolute inset-0 flex items-center justify-center transition-opacity group-hover/collapsed-switcher:opacity-0">
84
- {activeAvatar}
85
- </div>
86
- )}
87
- <div className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-hover/collapsed-switcher:opacity-100">
88
- <SidebarTrigger aria-label="Expand sidebar" />
89
- </div>
90
- </div>
91
- </div>
92
- );
93
- }
94
-
95
67
  return (
96
68
  <div className="flex items-center gap-1">
97
69
  <DropdownMenu open={open} onOpenChange={onOpenChange}>
@@ -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
+ );