@fayz-ai/ui 0.9.0 → 0.10.0

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 (156) hide show
  1. package/README.md +2 -2
  2. package/dist/analytics/AnalyticsCard.d.ts +10 -0
  3. package/dist/analytics/AnalyticsCard.d.ts.map +1 -0
  4. package/dist/analytics/AnalyticsControls.d.ts +18 -0
  5. package/dist/analytics/AnalyticsControls.d.ts.map +1 -0
  6. package/dist/analytics/AnalyticsGrid.d.ts +16 -0
  7. package/dist/analytics/AnalyticsGrid.d.ts.map +1 -0
  8. package/dist/analytics/context.d.ts +58 -0
  9. package/dist/analytics/context.d.ts.map +1 -0
  10. package/dist/analytics/derive.d.ts +81 -0
  11. package/dist/analytics/derive.d.ts.map +1 -0
  12. package/dist/analytics/index.d.ts +13 -0
  13. package/dist/analytics/index.d.ts.map +1 -0
  14. package/dist/analytics/range.d.ts +22 -0
  15. package/dist/analytics/range.d.ts.map +1 -0
  16. package/dist/{chunk-PVIB724L.js → chunk-22EVFMS3.js} +5 -3
  17. package/dist/chunk-22EVFMS3.js.map +1 -0
  18. package/dist/{chunk-G6OKZ7WL.js → chunk-6V5MGYXH.js} +13 -9
  19. package/dist/chunk-6V5MGYXH.js.map +1 -0
  20. package/dist/{chunk-KV3MHB6N.js → chunk-H63TOSQ7.js} +196 -53
  21. package/dist/chunk-H63TOSQ7.js.map +1 -0
  22. package/dist/{chunk-OUPJDGKE.js → chunk-I6CPKAOD.js} +134 -37
  23. package/dist/chunk-I6CPKAOD.js.map +1 -0
  24. package/dist/{chunk-YRVE4FOD.js → chunk-OKZ6WQB3.js} +3 -3
  25. package/dist/chunk-OKZ6WQB3.js.map +1 -0
  26. package/dist/{chunk-S5KNM4D2.js → chunk-SEIY2F5F.js} +235 -107
  27. package/dist/chunk-SEIY2F5F.js.map +1 -0
  28. package/dist/{chunk-75EFDLST.js → chunk-VAKWUUVC.js} +157 -6
  29. package/dist/chunk-VAKWUUVC.js.map +1 -0
  30. package/dist/dashboard/OnboardingWidget.d.ts +2 -14
  31. package/dist/dashboard/OnboardingWidget.d.ts.map +1 -1
  32. package/dist/dashboard/SidebarOnboarding.d.ts +28 -0
  33. package/dist/dashboard/SidebarOnboarding.d.ts.map +1 -0
  34. package/dist/dashboard/index.d.ts +2 -0
  35. package/dist/dashboard/index.d.ts.map +1 -1
  36. package/dist/dashboard/index.js +4 -4
  37. package/dist/dashboard/types.d.ts +18 -0
  38. package/dist/dashboard/types.d.ts.map +1 -1
  39. package/dist/dashboard/useOnboardingProgress.d.ts +28 -0
  40. package/dist/dashboard/useOnboardingProgress.d.ts.map +1 -0
  41. package/dist/icons.js +1 -1
  42. package/dist/index.d.ts +5 -4
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +960 -14
  45. package/dist/index.js.map +1 -1
  46. package/dist/layout/AppShell.d.ts +14 -1
  47. package/dist/layout/AppShell.d.ts.map +1 -1
  48. package/dist/layout/ModulePage.d.ts +11 -2
  49. package/dist/layout/ModulePage.d.ts.map +1 -1
  50. package/dist/layout/Sidebar.d.ts +8 -1
  51. package/dist/layout/Sidebar.d.ts.map +1 -1
  52. package/dist/layout/index.js +4 -4
  53. package/dist/lib/cn.d.ts.map +1 -0
  54. package/dist/preset.d.ts +3 -0
  55. package/dist/preset.d.ts.map +1 -0
  56. package/dist/primitives/data-table.d.ts +19 -4
  57. package/dist/primitives/data-table.d.ts.map +1 -1
  58. package/dist/primitives/date-picker.d.ts +4 -2
  59. package/dist/primitives/date-picker.d.ts.map +1 -1
  60. package/dist/primitives/filter-chip.d.ts +26 -0
  61. package/dist/primitives/filter-chip.d.ts.map +1 -0
  62. package/dist/primitives/header-tabs.d.ts +32 -0
  63. package/dist/primitives/header-tabs.d.ts.map +1 -0
  64. package/dist/primitives/hint.d.ts +25 -0
  65. package/dist/primitives/hint.d.ts.map +1 -0
  66. package/dist/primitives/index.d.ts +7 -0
  67. package/dist/primitives/index.d.ts.map +1 -1
  68. package/dist/primitives/index.js +4 -4
  69. package/dist/primitives/list-view.d.ts +14 -1
  70. package/dist/primitives/list-view.d.ts.map +1 -1
  71. package/dist/primitives/page-header.d.ts +23 -0
  72. package/dist/primitives/page-header.d.ts.map +1 -0
  73. package/dist/primitives/stat-tiles.d.ts +30 -0
  74. package/dist/primitives/stat-tiles.d.ts.map +1 -0
  75. package/dist/primitives/switch.d.ts +11 -0
  76. package/dist/primitives/switch.d.ts.map +1 -0
  77. package/dist/stores/index.d.ts +1 -0
  78. package/dist/stores/index.d.ts.map +1 -1
  79. package/dist/stores/page-header.store.d.ts +69 -0
  80. package/dist/stores/page-header.store.d.ts.map +1 -0
  81. package/package.json +4 -13
  82. package/dist/chunk-75EFDLST.js.map +0 -1
  83. package/dist/chunk-G6OKZ7WL.js.map +0 -1
  84. package/dist/chunk-KV3MHB6N.js.map +0 -1
  85. package/dist/chunk-OUPJDGKE.js.map +0 -1
  86. package/dist/chunk-PVIB724L.js.map +0 -1
  87. package/dist/chunk-S5KNM4D2.js.map +0 -1
  88. package/dist/chunk-YRVE4FOD.js.map +0 -1
  89. package/dist/utils/cn.d.ts.map +0 -1
  90. package/src/dashboard/ChartWidget.tsx +0 -50
  91. package/src/dashboard/ChartWidgetInner.tsx +0 -83
  92. package/src/dashboard/DashboardCanvas.tsx +0 -210
  93. package/src/dashboard/DashboardCustomizeMenu.tsx +0 -53
  94. package/src/dashboard/DashboardGrid.tsx +0 -35
  95. package/src/dashboard/DashboardRange.tsx +0 -62
  96. package/src/dashboard/GoldCard.tsx +0 -38
  97. package/src/dashboard/KpiCard.tsx +0 -238
  98. package/src/dashboard/OnboardingWidget.tsx +0 -140
  99. package/src/dashboard/TableWidget.tsx +0 -51
  100. package/src/dashboard/define.ts +0 -53
  101. package/src/dashboard/icon.ts +0 -13
  102. package/src/dashboard/index.ts +0 -38
  103. package/src/dashboard/layout.ts +0 -50
  104. package/src/dashboard/preferences.ts +0 -71
  105. package/src/dashboard/types.ts +0 -41
  106. package/src/icons.ts +0 -66
  107. package/src/index.ts +0 -238
  108. package/src/layout/AppShell.tsx +0 -987
  109. package/src/layout/ContentSplit.tsx +0 -153
  110. package/src/layout/ModulePage.tsx +0 -466
  111. package/src/layout/PageTransition.tsx +0 -57
  112. package/src/layout/SaveBar.tsx +0 -247
  113. package/src/layout/Sidebar.tsx +0 -535
  114. package/src/layout/Topbar.tsx +0 -462
  115. package/src/layout/index.ts +0 -39
  116. package/src/layout/useCrossModuleBack.ts +0 -30
  117. package/src/primitives/Avatar.tsx +0 -48
  118. package/src/primitives/SearchCombobox.tsx +0 -216
  119. package/src/primitives/badge.tsx +0 -68
  120. package/src/primitives/breadcrumb.tsx +0 -43
  121. package/src/primitives/button.tsx +0 -60
  122. package/src/primitives/card.tsx +0 -79
  123. package/src/primitives/checkbox.tsx +0 -53
  124. package/src/primitives/confirm-dialog.tsx +0 -125
  125. package/src/primitives/currency-input.tsx +0 -121
  126. package/src/primitives/data-table.tsx +0 -160
  127. package/src/primitives/date-picker.tsx +0 -225
  128. package/src/primitives/dropdown.tsx +0 -196
  129. package/src/primitives/index.ts +0 -87
  130. package/src/primitives/input.tsx +0 -50
  131. package/src/primitives/list-view.tsx +0 -252
  132. package/src/primitives/markdown-editor.tsx +0 -75
  133. package/src/primitives/markdown.tsx +0 -169
  134. package/src/primitives/modal.tsx +0 -156
  135. package/src/primitives/phone-link.tsx +0 -138
  136. package/src/primitives/popover.tsx +0 -30
  137. package/src/primitives/search-select.tsx +0 -314
  138. package/src/primitives/segmented-control.tsx +0 -66
  139. package/src/primitives/select.tsx +0 -157
  140. package/src/primitives/separator.tsx +0 -29
  141. package/src/primitives/sheet.tsx +0 -181
  142. package/src/primitives/skeleton.tsx +0 -46
  143. package/src/primitives/tabs.tsx +0 -53
  144. package/src/primitives/time-picker.tsx +0 -148
  145. package/src/primitives/toast.tsx +0 -46
  146. package/src/primitives/tooltip.tsx +0 -28
  147. package/src/stores/index.ts +0 -10
  148. package/src/stores/layout.store.ts +0 -31
  149. package/src/stores/theme.store.ts +0 -457
  150. package/src/styles.css +0 -461
  151. package/src/tailwind.ts +0 -43
  152. package/src/theme/index.ts +0 -94
  153. package/src/theme/preset.ts +0 -209
  154. package/src/theme/presets.ts +0 -106
  155. package/src/utils/cn.ts +0 -6
  156. /package/dist/{utils → lib}/cn.d.ts +0 -0
@@ -1,535 +0,0 @@
1
- import * as React from 'react'
2
- import * as Tooltip from '@radix-ui/react-tooltip'
3
- import {
4
- Bell, ChevronDown, Crown, ExternalLink, Home, PanelLeft, PanelLeftClose, Search, Settings, User,
5
- type LucideIcon,
6
- } from 'lucide-react'
7
- import { ICON_MAP } from '../icons'
8
- import { cn } from '../utils/cn'
9
- import { useLayoutStore } from '../stores/layout.store'
10
-
11
- export interface NavigationItem {
12
- id: string
13
- label: string
14
- icon: string
15
- route: string
16
- section: 'main' | 'secondary' | 'settings'
17
- badge?: string | number
18
- permission?: { feature: string; action: 'read' | 'create' | 'edit' | 'delete' }
19
- /** Freemium discovery: the user's role allows this item, but their plan does
20
- * not entitle the feature. The item stays visible with a small Crown badge
21
- * and, on click, leads to the plan's UpgradePrompt. */
22
- premium?: boolean
23
- /** Sales-channel / external link: when set, the item opens this URL in a new
24
- * tab instead of navigating an internal route. Rendered as an anchor with an
25
- * external-link glyph. `route` is ignored for matching when `href` is set. */
26
- href?: string
27
- children?: NavigationItem[]
28
- }
29
-
30
- export interface SidebarUser {
31
- fullName: string
32
- avatarUrl?: string
33
- email: string
34
- }
35
-
36
- export interface SidebarProps {
37
- navigation: NavigationItem[]
38
- logo?: React.ReactNode
39
- collapsed: boolean
40
- onToggle: () => void
41
- onNavigate?: (route: string) => void
42
- currentPath?: string
43
- user?: SidebarUser
44
- onSignOut?: () => void
45
- onProfile?: () => void
46
- onSettings?: () => void
47
- onBilling?: () => void
48
- userMenuExtras?: { label: string; icon?: React.ReactNode; onClick: () => void }[]
49
- orgSwitcher?: React.ReactNode
50
- topContent?: React.ReactNode
51
- footerContent?: React.ReactNode
52
- /** When true, removes all borders for a cleaner framed look */
53
- borderless?: boolean
54
- /** User menu component to render at the bottom */
55
- userMenuSlot?: React.ReactNode
56
- /** Notification slot to render at the bottom */
57
- notificationSlot?: React.ReactNode
58
- /** Unread notification count for the bell indicator */
59
- unreadCount?: number
60
- /** Optional headings above each nav section. Used by contextual sidebars
61
- * (e.g. /settings swaps the rail for "Settings links" + "Plugins"); the
62
- * normal app rail leaves them undefined and renders no headings. */
63
- sectionLabels?: { main?: string; secondary?: string; settings?: string }
64
- /** Identity of the current rail context. When it changes, the rail animates
65
- * as a push: the new nav slides in while the previous one slides out.
66
- * Leave undefined for a static rail (no swap animation). */
67
- navKey?: string
68
- /** Push direction for a `navKey` change. 'forward' pulls the new rail in
69
- * from the right (drilling in), 'back' from the left (returning). */
70
- navDirection?: 'forward' | 'back'
71
- }
72
-
73
- // ICON_MAP now lives in ../icons (central curated registry, shared by every
74
- // string-name icon lookup in the SDK); re-exported here for compat.
75
- export { ICON_MAP }
76
-
77
- /** Kept in sync with --saas-rail-duration in styles.css. */
78
- const RAIL_PUSH_MS = 260
79
-
80
- /** Layout effect on the client, plain effect during SSR (avoids the warning).
81
- * The rail push needs the layout variant: the animation classes must be on
82
- * the DOM before paint, or the new rail shows one frame at its final spot. */
83
- const useIsomorphicLayoutEffect =
84
- typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect
85
-
86
- function getIcon(name: string): LucideIcon {
87
- return ICON_MAP[name] ?? Home
88
- }
89
-
90
- function NavItem({
91
- item,
92
- collapsed,
93
- isActive,
94
- onNavigate,
95
- }: {
96
- item: NavigationItem
97
- collapsed: boolean
98
- isActive: boolean
99
- onNavigate: (route: string) => void
100
- }) {
101
- const Icon = getIcon(item.icon)
102
- const isExternal = !!item.href
103
-
104
- const className = cn(
105
- 'flex w-full items-center gap-3 rounded-md px-2.5 py-1.5 text-[13px] font-medium transition-colors',
106
- 'hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground',
107
- isActive
108
- ? 'bg-sidebar-accent font-semibold text-sidebar-accent-foreground shadow-sm'
109
- : 'text-sidebar-foreground/80',
110
- collapsed && 'justify-center px-2',
111
- )
112
-
113
- const inner = (
114
- <>
115
- <Icon className="h-4 w-4 shrink-0" />
116
- {!collapsed && (
117
- <>
118
- <span className="flex-1 truncate text-left">{item.label}</span>
119
- {isExternal && (
120
- <ExternalLink className="ml-auto h-3.5 w-3.5 shrink-0 opacity-50" aria-hidden />
121
- )}
122
- {item.premium && (
123
- <Crown className={cn('h-3.5 w-3.5 shrink-0 text-amber-500', !isExternal && 'ml-auto')} aria-hidden />
124
- )}
125
- {item.badge !== undefined && (
126
- <span className={cn('rounded-full bg-primary px-2 py-0.5 text-xs font-semibold text-primary-foreground', !isExternal && !item.premium && 'ml-auto')}>
127
- {item.badge}
128
- </span>
129
- )}
130
- </>
131
- )}
132
- </>
133
- )
134
-
135
- const content = isExternal ? (
136
- <a href={item.href} target="_blank" rel="noopener noreferrer" className={className}>
137
- {inner}
138
- </a>
139
- ) : (
140
- <button onClick={() => onNavigate(item.route)} className={className}>
141
- {inner}
142
- </button>
143
- )
144
-
145
- if (collapsed) {
146
- return (
147
- <Tooltip.Root delayDuration={0}>
148
- <Tooltip.Trigger asChild>{content}</Tooltip.Trigger>
149
- <Tooltip.Portal>
150
- <Tooltip.Content
151
- side="right"
152
- sideOffset={8}
153
- className="fayz-glass-surface z-50 rounded-md bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md"
154
- >
155
- {item.label}
156
- {item.premium && (
157
- <Crown className="ml-1.5 inline h-3 w-3 text-amber-500" aria-hidden />
158
- )}
159
- {item.badge !== undefined && (
160
- <span className="ml-2 rounded-full bg-primary px-1.5 py-0.5 text-xs text-primary-foreground">
161
- {item.badge}
162
- </span>
163
- )}
164
- <Tooltip.Arrow className="fill-popover" />
165
- </Tooltip.Content>
166
- </Tooltip.Portal>
167
- </Tooltip.Root>
168
- )
169
- }
170
-
171
- return content
172
- }
173
-
174
- /** How specifically `route` matches `path` (-1 = no match; higher = more specific).
175
- * An exact match beats a prefix match; a longer prefix beats a shorter one. */
176
- function routeMatchScore(route: string, path: string): number {
177
- if (path === route) return route.length + 1
178
- if (route !== '/' && path.startsWith(route + '/')) return route.length
179
- return -1
180
- }
181
-
182
- /** The single most-specific nav route that matches the current path (longest wins).
183
- * Prevents a parent like `/courses` from staying active on `/courses/sales`. */
184
- function resolveActiveRoute(navigation: NavigationItem[], path: string): string | null {
185
- let best: string | null = null
186
- let bestScore = 0
187
- const consider = (route?: string) => {
188
- if (!route) return
189
- const score = routeMatchScore(route, path)
190
- if (score > bestScore) { bestScore = score; best = route }
191
- }
192
- for (const item of navigation) {
193
- consider(item.route)
194
- for (const child of item.children ?? []) consider(child.route)
195
- }
196
- return best
197
- }
198
-
199
- function NavGroup({
200
- item,
201
- collapsed,
202
- activeRoute,
203
- onNavigate,
204
- }: {
205
- item: NavigationItem
206
- collapsed: boolean
207
- activeRoute: string | null
208
- onNavigate: (route: string) => void
209
- }) {
210
- const isChildActive = item.children?.some((c) => c.route === activeRoute)
211
- const [open, setOpen] = React.useState(() => isChildActive ?? false)
212
- const Icon = getIcon(item.icon)
213
-
214
- if (collapsed) {
215
- return (
216
- <Tooltip.Root delayDuration={0}>
217
- <Tooltip.Trigger asChild>
218
- <button
219
- onClick={() => onNavigate(item.route)}
220
- className={cn(
221
- 'flex w-full items-center justify-center rounded-md px-2 py-2 text-sm font-medium transition-colors',
222
- 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
223
- isChildActive ? 'bg-sidebar-accent text-sidebar-accent-foreground' : 'text-sidebar-muted'
224
- )}
225
- >
226
- <Icon className="h-4 w-4 shrink-0" />
227
- </button>
228
- </Tooltip.Trigger>
229
- <Tooltip.Portal>
230
- <Tooltip.Content side="right" sideOffset={8} className="fayz-glass-surface z-50 rounded-md bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md">
231
- {item.label}
232
- <Tooltip.Arrow className="fill-popover" />
233
- </Tooltip.Content>
234
- </Tooltip.Portal>
235
- </Tooltip.Root>
236
- )
237
- }
238
-
239
- return (
240
- <div>
241
- <button
242
- onClick={() => setOpen(!open)}
243
- className={cn(
244
- 'flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
245
- 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
246
- isChildActive ? 'text-sidebar-accent-foreground' : 'text-sidebar-muted'
247
- )}
248
- >
249
- <Icon className="h-4 w-4 shrink-0" />
250
- <span className="flex-1 truncate text-left">{item.label}</span>
251
- <ChevronDown className={cn('h-3.5 w-3.5 shrink-0 transition-transform', open && 'rotate-180')} />
252
- </button>
253
- {open && (
254
- <div className="ml-4 mt-0.5 space-y-0.5 border-l border-sidebar-border/50 pl-3">
255
- {item.children?.map((child) => {
256
- const ChildIcon = getIcon(child.icon)
257
- const isActive = child.route === activeRoute
258
- const childExternal = !!child.href
259
- const childClass = cn(
260
- 'flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-sm transition-colors',
261
- isActive
262
- ? 'font-medium text-sidebar-accent-foreground'
263
- : 'text-sidebar-muted hover:text-sidebar-accent-foreground'
264
- )
265
- const childInner = (
266
- <>
267
- <ChildIcon className="h-3.5 w-3.5 shrink-0" />
268
- <span className="flex-1 truncate text-left">{child.label}</span>
269
- {childExternal && (
270
- <ExternalLink className="h-3 w-3 shrink-0 opacity-50" aria-hidden />
271
- )}
272
- {child.premium && (
273
- <Crown className="h-3 w-3 shrink-0 text-amber-500" aria-hidden />
274
- )}
275
- </>
276
- )
277
- return childExternal ? (
278
- <a key={child.id} href={child.href} target="_blank" rel="noopener noreferrer" className={childClass}>
279
- {childInner}
280
- </a>
281
- ) : (
282
- <button key={child.id} onClick={() => onNavigate(child.route)} className={childClass}>
283
- {childInner}
284
- </button>
285
- )
286
- })}
287
- </div>
288
- )}
289
- </div>
290
- )
291
- }
292
-
293
- export function Sidebar({
294
- navigation,
295
- logo,
296
- collapsed,
297
- onToggle,
298
- onNavigate,
299
- currentPath: currentPathProp = '/',
300
- user: _user,
301
- orgSwitcher,
302
- topContent,
303
- footerContent,
304
- borderless,
305
- userMenuSlot,
306
- notificationSlot,
307
- unreadCount = 0,
308
- sectionLabels,
309
- navKey,
310
- navDirection = 'forward',
311
- }: SidebarProps) {
312
- const currentPath = currentPathProp
313
- const setCommandPaletteOpen = useLayoutStore((s) => s.setCommandPaletteOpen)
314
-
315
- // Most-specific match wins, so sibling routes that share a prefix (e.g.
316
- // /courses vs /courses/sales) don't both light up.
317
- const activeRoute = resolveActiveRoute(navigation, currentPath)
318
-
319
- const mainItems = navigation.filter((item) => item.section === 'main')
320
- const secondaryItems = navigation.filter((item) => item.section === 'secondary')
321
- const settingsItems = navigation.filter((item) => item.section === 'settings')
322
-
323
- const handleNavigate = (route: string) => {
324
- onNavigate?.(route)
325
- }
326
-
327
- const sectionHeading = (label?: string) =>
328
- label && !collapsed ? (
329
- <p className="px-2.5 pb-1 pt-1 text-[10px] font-semibold uppercase tracking-wider text-sidebar-muted">
330
- {label}
331
- </p>
332
- ) : null
333
-
334
- const renderItem = (item: NavigationItem) =>
335
- item.children && item.children.length > 0 ? (
336
- <NavGroup key={item.id} item={item} collapsed={collapsed} activeRoute={activeRoute} onNavigate={handleNavigate} />
337
- ) : (
338
- <NavItem
339
- key={item.id}
340
- item={item}
341
- collapsed={collapsed}
342
- isActive={item.route === activeRoute}
343
- onNavigate={handleNavigate}
344
- />
345
- )
346
-
347
- // The swappable part of the rail (top slot + both nav sections). Kept as one
348
- // node so a context change can snapshot the outgoing rail and animate it out.
349
- const navBody = (
350
- <>
351
- {topContent && (
352
- <div className="mb-4 space-y-2">
353
- {React.isValidElement(topContent)
354
- ? React.cloneElement(topContent as React.ReactElement<any>, { collapsed })
355
- : topContent}
356
- </div>
357
- )}
358
- {mainItems.length > 0 && (
359
- <div className="space-y-1">
360
- {sectionHeading(sectionLabels?.main)}
361
- {mainItems.map(renderItem)}
362
- </div>
363
- )}
364
- {secondaryItems.length > 0 && (
365
- <div className="mt-6 space-y-1">
366
- {sectionHeading(sectionLabels?.secondary)}
367
- {secondaryItems.map(renderItem)}
368
- </div>
369
- )}
370
- </>
371
- )
372
-
373
- // Rail push: when `navKey` changes, hold the previous rail on screen for one
374
- // animation cycle. `lastBodyRef` is refreshed after every render, so the
375
- // snapshot is the rail exactly as it looked before the swap (correct active
376
- // item included) rather than a stale one.
377
- const [outgoing, setOutgoing] = React.useState<{ body: React.ReactNode; direction: 'forward' | 'back' } | null>(null)
378
- const lastBodyRef = React.useRef<React.ReactNode>(null)
379
- const lastKeyRef = React.useRef(navKey)
380
- const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
381
-
382
- useIsomorphicLayoutEffect(() => {
383
- if (lastKeyRef.current !== navKey) {
384
- const previousBody = lastBodyRef.current
385
- lastKeyRef.current = navKey
386
- if (previousBody) {
387
- setOutgoing({ body: previousBody, direction: navDirection })
388
- if (timerRef.current) clearTimeout(timerRef.current)
389
- timerRef.current = setTimeout(() => setOutgoing(null), RAIL_PUSH_MS)
390
- }
391
- }
392
- lastBodyRef.current = navBody
393
- })
394
-
395
- React.useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current) }, [])
396
-
397
- return (
398
- <Tooltip.Provider>
399
- <aside
400
- data-print="hide"
401
- className={cn(
402
- 'flex h-full flex-col bg-sidebar text-sidebar-foreground transition-all duration-200',
403
- !borderless && 'border-r border-sidebar-border',
404
- collapsed ? 'w-16' : 'w-64'
405
- )}
406
- >
407
- {/* Logo + Collapse Toggle */}
408
- <div
409
- className={cn(
410
- 'flex h-14 items-center justify-between px-4',
411
- !borderless && 'border-b border-sidebar-border',
412
- collapsed && 'justify-center px-2'
413
- )}
414
- >
415
- {!collapsed && (
416
- <>
417
- {logo ?? <span className="text-lg font-bold">App</span>}
418
- <button
419
- onClick={onToggle}
420
- className="ml-auto rounded-md p-1.5 text-sidebar-muted transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
421
- aria-label="Collapse sidebar"
422
- >
423
- <PanelLeftClose className="h-4 w-4" />
424
- </button>
425
- </>
426
- )}
427
- {collapsed && (
428
- <button
429
- onClick={onToggle}
430
- className="rounded-md p-1.5 text-sidebar-muted transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
431
- aria-label="Expand sidebar"
432
- >
433
- <PanelLeft className="h-4 w-4" />
434
- </button>
435
- )}
436
- </div>
437
-
438
- {/* Main Navigation. During a rail swap the outgoing nav is pinned on
439
- top of the incoming one so the two cross-slide; overflow is clipped
440
- for the duration so neither pane can scroll the rail sideways. */}
441
- <nav
442
- className={cn(
443
- 'relative flex-1 p-2',
444
- // scrollbar-none: the rail still scrolls, but without the native
445
- // scrollbar gutter eating into the nav items.
446
- outgoing ? 'overflow-hidden' : 'overflow-y-auto scrollbar-none',
447
- )}
448
- >
449
- {outgoing && (
450
- <div
451
- aria-hidden
452
- className={cn(
453
- 'pointer-events-none absolute inset-x-2 top-2 space-y-1',
454
- outgoing.direction === 'back' ? 'saas-rail-out-back' : 'saas-rail-out-forward',
455
- )}
456
- >
457
- {outgoing.body}
458
- </div>
459
- )}
460
- <div
461
- key={navKey}
462
- className={cn(
463
- 'space-y-1',
464
- outgoing && (outgoing.direction === 'back' ? 'saas-rail-in-back' : 'saas-rail-in-forward'),
465
- )}
466
- >
467
- {navBody}
468
- </div>
469
- </nav>
470
-
471
- {/* Settings nav items */}
472
- {settingsItems.length > 0 && (
473
- <div className={cn('p-2 space-y-1', !borderless && 'border-t border-sidebar-border')}>
474
- {settingsItems.map((item) => (
475
- <NavItem
476
- key={item.id}
477
- item={item}
478
- collapsed={collapsed}
479
- isActive={item.route === activeRoute}
480
- onNavigate={handleNavigate}
481
- />
482
- ))}
483
- </div>
484
- )}
485
-
486
- {footerContent && (
487
- <div className={cn('p-2', !borderless && 'border-t border-sidebar-border')}>
488
- <div className="space-y-2">{footerContent}</div>
489
- </div>
490
- )}
491
-
492
- {/* Org Switcher — above user row */}
493
- {orgSwitcher && React.isValidElement(orgSwitcher)
494
- ? React.cloneElement(orgSwitcher as React.ReactElement<any>, { collapsed })
495
- : orgSwitcher}
496
-
497
- {/* Bottom: User row with inline action icons */}
498
- <div className={cn('p-2', !borderless && 'border-t border-sidebar-border')}>
499
- <div className={cn(
500
- 'flex items-center rounded-md',
501
- collapsed ? 'flex-col gap-1' : 'gap-1 px-1'
502
- )}>
503
- {/* User Menu slot */}
504
- {userMenuSlot}
505
-
506
- {/* Spacer pushes icons to right when expanded */}
507
- {!collapsed && <div className="flex-1" />}
508
-
509
- {/* Search — opens command palette */}
510
- <button
511
- onClick={() => setCommandPaletteOpen(true)}
512
- className="inline-flex shrink-0 items-center justify-center rounded-md p-2 text-sidebar-muted transition-colors hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground"
513
- aria-label="Search"
514
- >
515
- <Search className="h-4 w-4" />
516
- </button>
517
-
518
- {/* Notification slot or default bell */}
519
- {notificationSlot ?? (
520
- <button
521
- className="relative inline-flex shrink-0 items-center justify-center rounded-md p-2 text-sidebar-muted transition-colors hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground"
522
- aria-label={unreadCount > 0 ? `${unreadCount} unread notifications` : 'Notifications'}
523
- >
524
- <Bell className="h-4 w-4" />
525
- {unreadCount > 0 && (
526
- <span className="absolute right-1 top-1 h-2 w-2 rounded-full bg-destructive" />
527
- )}
528
- </button>
529
- )}
530
- </div>
531
- </div>
532
- </aside>
533
- </Tooltip.Provider>
534
- )
535
- }