@trycompai/design-system 1.0.1 → 1.0.2

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 (63) hide show
  1. package/package.json +5 -2
  2. package/src/components/atoms/badge.tsx +8 -7
  3. package/src/components/atoms/button.tsx +6 -1
  4. package/src/components/atoms/checkbox.tsx +3 -3
  5. package/src/components/atoms/heading.tsx +6 -6
  6. package/src/components/atoms/index.ts +2 -0
  7. package/src/components/atoms/logo.tsx +52 -0
  8. package/src/components/atoms/spinner.tsx +3 -3
  9. package/src/components/atoms/stack.tsx +97 -0
  10. package/src/components/atoms/switch.tsx +1 -1
  11. package/src/components/atoms/text.tsx +5 -1
  12. package/src/components/atoms/toggle.tsx +1 -1
  13. package/src/components/molecules/accordion.tsx +3 -3
  14. package/src/components/molecules/ai-chat.tsx +217 -0
  15. package/src/components/molecules/alert.tsx +5 -5
  16. package/src/components/molecules/breadcrumb.tsx +8 -7
  17. package/src/components/molecules/card.tsx +24 -5
  18. package/src/components/molecules/command-search.tsx +147 -0
  19. package/src/components/molecules/index.ts +4 -1
  20. package/src/components/molecules/input-otp.tsx +2 -2
  21. package/src/components/molecules/page-header.tsx +33 -4
  22. package/src/components/molecules/pagination.tsx +4 -4
  23. package/src/components/molecules/popover.tsx +4 -2
  24. package/src/components/molecules/radio-group.tsx +2 -2
  25. package/src/components/molecules/section.tsx +1 -1
  26. package/src/components/molecules/select.tsx +5 -5
  27. package/src/components/molecules/settings.tsx +169 -0
  28. package/src/components/molecules/table.tsx +5 -1
  29. package/src/components/molecules/tabs.tsx +5 -4
  30. package/src/components/molecules/theme-switcher.tsx +176 -0
  31. package/src/components/organisms/app-shell.tsx +822 -0
  32. package/src/components/organisms/calendar.tsx +4 -4
  33. package/src/components/organisms/carousel.tsx +3 -3
  34. package/src/components/organisms/combobox.tsx +5 -5
  35. package/src/components/organisms/command.tsx +3 -3
  36. package/src/components/organisms/context-menu.tsx +4 -4
  37. package/src/components/organisms/dialog.tsx +2 -2
  38. package/src/components/organisms/dropdown-menu.tsx +8 -6
  39. package/src/components/organisms/index.ts +1 -0
  40. package/src/components/organisms/menubar.tsx +3 -3
  41. package/src/components/organisms/navigation-menu.tsx +2 -2
  42. package/src/components/organisms/page-layout.tsx +50 -20
  43. package/src/components/organisms/sheet.tsx +2 -2
  44. package/src/components/organisms/sidebar.tsx +22 -6
  45. package/src/components/organisms/sonner.tsx +11 -11
  46. package/src/fonts/TWKLausanne/TWKLausanne-300-Italic.woff +0 -0
  47. package/src/fonts/TWKLausanne/TWKLausanne-300-Italic.woff2 +0 -0
  48. package/src/fonts/TWKLausanne/TWKLausanne-300.woff +0 -0
  49. package/src/fonts/TWKLausanne/TWKLausanne-300.woff2 +0 -0
  50. package/src/fonts/TWKLausanne/TWKLausanne-350-Italic.woff +0 -0
  51. package/src/fonts/TWKLausanne/TWKLausanne-350-Italic.woff2 +0 -0
  52. package/src/fonts/TWKLausanne/TWKLausanne-350.woff +0 -0
  53. package/src/fonts/TWKLausanne/TWKLausanne-350.woff2 +0 -0
  54. package/src/fonts/TWKLausanne/TWKLausanne-400-Italic.woff +0 -0
  55. package/src/fonts/TWKLausanne/TWKLausanne-400-Italic.woff2 +0 -0
  56. package/src/fonts/TWKLausanne/TWKLausanne-400.woff +0 -0
  57. package/src/fonts/TWKLausanne/TWKLausanne-400.woff2 +0 -0
  58. package/src/fonts/TWKLausanne/TWKLausanne-700-Italic.woff +0 -0
  59. package/src/fonts/TWKLausanne/TWKLausanne-700-Italic.woff2 +0 -0
  60. package/src/fonts/TWKLausanne/TWKLausanne-700.woff +0 -0
  61. package/src/fonts/TWKLausanne/TWKLausanne-700.woff2 +0 -0
  62. package/src/styles/globals.css +155 -23
  63. package/src/components/molecules/stack.tsx +0 -72
@@ -0,0 +1,822 @@
1
+ import { cva, type VariantProps } from 'class-variance-authority';
2
+ import { Search, SidePanelClose, SidePanelOpen } from '@carbon/icons-react';
3
+ import * as React from 'react';
4
+
5
+ import { Kbd } from '../atoms/kbd';
6
+ import { Stack } from '../atoms/stack';
7
+ import { AIChat } from '../molecules/ai-chat';
8
+ import { InputGroup, InputGroupAddon, InputGroupInput } from '../molecules/input-group';
9
+
10
+ // ============ CONTEXT ============
11
+
12
+ type AppShellContextProps = {
13
+ /** Desktop sidebar expanded state */
14
+ sidebarOpen: boolean;
15
+ setSidebarOpen: (open: boolean) => void;
16
+ toggleSidebar: () => void;
17
+ /** Mobile drawer open state (separate from desktop) */
18
+ mobileDrawerOpen: boolean;
19
+ setMobileDrawerOpen: (open: boolean) => void;
20
+ toggleMobileDrawer: () => void;
21
+ /** Rail content for mobile drawer */
22
+ railContent: React.ReactNode;
23
+ setRailContent: (content: React.ReactNode) => void;
24
+ /** Sidebar content for mobile drawer */
25
+ sidebarContent: React.ReactNode;
26
+ setSidebarContent: (content: React.ReactNode) => void;
27
+ /** Sidebar variant for mobile drawer styling */
28
+ sidebarVariant: 'default' | 'muted' | 'primary';
29
+ setSidebarVariant: (variant: 'default' | 'muted' | 'primary') => void;
30
+ };
31
+
32
+ const AppShellContext = React.createContext<AppShellContextProps | null>(null);
33
+
34
+ function useAppShell() {
35
+ const context = React.useContext(AppShellContext);
36
+ if (!context) {
37
+ throw new Error('useAppShell must be used within an AppShell.');
38
+ }
39
+ return context;
40
+ }
41
+
42
+ // ============ VARIANTS ============
43
+
44
+ const appShellNavbarVariants = cva(
45
+ 'flex h-14 shrink-0 items-center gap-2 bg-background/50 px-4',
46
+ {
47
+ variants: {
48
+ position: {
49
+ sticky: 'sticky top-0 z-40',
50
+ fixed: 'fixed top-0 inset-x-0 z-40',
51
+ static: '',
52
+ },
53
+ },
54
+ defaultVariants: {
55
+ position: 'sticky',
56
+ },
57
+ },
58
+ );
59
+
60
+ const sidebarWidths = {
61
+ sm: 'w-48',
62
+ default: 'w-64',
63
+ lg: 'w-72',
64
+ xl: 'w-80',
65
+ } as const;
66
+
67
+ const appShellSidebarVariants = cva(
68
+ 'shrink-0 overflow-hidden hidden md:flex md:flex-col transition-[width,padding,opacity] duration-200 ease-in-out',
69
+ {
70
+ variants: {
71
+ variant: {
72
+ default: 'bg-background border-r border-border/40',
73
+ muted: 'bg-muted border-r border-border/40',
74
+ primary: 'bg-primary border-r border-primary-foreground/10',
75
+ },
76
+ },
77
+ defaultVariants: {
78
+ variant: 'default',
79
+ },
80
+ },
81
+ );
82
+
83
+ const appShellContentVariants = cva('flex flex-1 flex-col overflow-auto bg-background min-h-0', {
84
+ variants: {
85
+ padding: {
86
+ none: '',
87
+ sm: 'p-4',
88
+ default: 'p-4 md:p-6',
89
+ lg: 'p-6 md:p-8',
90
+ },
91
+ },
92
+ defaultVariants: {
93
+ padding: 'default',
94
+ },
95
+ });
96
+
97
+ const appShellSearchVariants = cva('', {
98
+ variants: {
99
+ searchWidth: {
100
+ sm: 'w-48 md:w-64',
101
+ md: 'w-64 md:w-80',
102
+ lg: 'w-80 md:w-96',
103
+ full: 'w-full max-w-md',
104
+ },
105
+ },
106
+ defaultVariants: {
107
+ searchWidth: 'md',
108
+ },
109
+ });
110
+
111
+ // ============ TYPES ============
112
+
113
+ interface AppShellProps extends Omit<React.ComponentProps<'div'>, 'className'> {
114
+ /** Default sidebar open state */
115
+ defaultSidebarOpen?: boolean;
116
+ /** Controlled sidebar open state */
117
+ sidebarOpen?: boolean;
118
+ /** Callback when sidebar state changes */
119
+ onSidebarOpenChange?: (open: boolean) => void;
120
+ /** Show the floating AI chat button */
121
+ showAIChat?: boolean;
122
+ /** Custom content for the AI chat panel */
123
+ aiChatContent?: React.ReactNode;
124
+ }
125
+
126
+ interface AppShellNavbarProps
127
+ extends Omit<React.ComponentProps<'header'>, 'className'>,
128
+ VariantProps<typeof appShellNavbarVariants> {
129
+ /** Shows sidebar toggle button */
130
+ showSidebarToggle?: boolean;
131
+ /** Content for the start slot (after sidebar toggle) */
132
+ startContent?: React.ReactNode;
133
+ /** Content for the center slot (typically search) */
134
+ centerContent?: React.ReactNode;
135
+ /** Content for the end slot (typically user menu) */
136
+ endContent?: React.ReactNode;
137
+ }
138
+
139
+ interface AppShellSidebarProps
140
+ extends Omit<React.ComponentProps<'aside'>, 'className'>,
141
+ VariantProps<typeof appShellSidebarVariants> {
142
+ /** Width of the sidebar */
143
+ width?: keyof typeof sidebarWidths;
144
+ /** Collapsible on mobile */
145
+ collapsible?: boolean;
146
+ }
147
+
148
+ interface AppShellContentProps
149
+ extends Omit<React.ComponentProps<'main'>, 'className'>,
150
+ VariantProps<typeof appShellContentVariants> {}
151
+
152
+ interface AppShellSearchProps
153
+ extends Omit<React.ComponentProps<'input'>, 'className'>,
154
+ VariantProps<typeof appShellSearchVariants> {
155
+ /** Shows keyboard shortcut hint */
156
+ showShortcut?: boolean;
157
+ }
158
+
159
+ interface AppShellUserMenuProps extends Omit<React.ComponentProps<'div'>, 'className'> {}
160
+
161
+ interface AppShellBodyProps extends Omit<React.ComponentProps<'div'>, 'className'> {}
162
+
163
+ interface AppShellRailProps extends Omit<React.ComponentProps<'div'>, 'className'> {
164
+ /** Show the sidebar toggle button in the rail */
165
+ showSidebarToggle?: boolean;
166
+ }
167
+
168
+ interface AppShellRailItemProps extends Omit<React.ComponentProps<'button'>, 'className'> {
169
+ isActive?: boolean;
170
+ icon: React.ReactNode;
171
+ label?: string;
172
+ }
173
+
174
+ // Rail indicator context for tracking active item position
175
+ type RailIndicatorContextProps = {
176
+ registerItem: (id: string, element: HTMLElement | null) => void;
177
+ activeId: string | null;
178
+ setActiveId: (id: string | null) => void;
179
+ };
180
+
181
+ const RailIndicatorContext = React.createContext<RailIndicatorContextProps | null>(null);
182
+
183
+ // ============ COMPONENTS ============
184
+
185
+ function AppShell({
186
+ defaultSidebarOpen = true,
187
+ sidebarOpen: sidebarOpenProp,
188
+ onSidebarOpenChange,
189
+ showAIChat = false,
190
+ aiChatContent,
191
+ children,
192
+ ...props
193
+ }: AppShellProps) {
194
+ // Desktop sidebar state
195
+ const [_sidebarOpen, _setSidebarOpen] = React.useState(defaultSidebarOpen);
196
+ const sidebarOpen = sidebarOpenProp ?? _sidebarOpen;
197
+
198
+ // Mobile drawer state (always starts closed)
199
+ const [mobileDrawerOpen, setMobileDrawerOpen] = React.useState(false);
200
+
201
+ // Content for mobile drawer (populated by Rail and Sidebar components)
202
+ const [railContent, setRailContent] = React.useState<React.ReactNode>(null);
203
+ const [sidebarContent, setSidebarContent] = React.useState<React.ReactNode>(null);
204
+ const [sidebarVariant, setSidebarVariant] = React.useState<'default' | 'muted' | 'primary'>('default');
205
+
206
+ const setSidebarOpen = React.useCallback(
207
+ (open: boolean) => {
208
+ if (onSidebarOpenChange) {
209
+ onSidebarOpenChange(open);
210
+ } else {
211
+ _setSidebarOpen(open);
212
+ }
213
+ },
214
+ [onSidebarOpenChange],
215
+ );
216
+
217
+ const toggleSidebar = React.useCallback(() => {
218
+ setSidebarOpen(!sidebarOpen);
219
+ }, [sidebarOpen, setSidebarOpen]);
220
+
221
+ const toggleMobileDrawer = React.useCallback(() => {
222
+ setMobileDrawerOpen(!mobileDrawerOpen);
223
+ }, [mobileDrawerOpen]);
224
+
225
+ // Listen for Cmd+\ to toggle sidebar (desktop) or mobile drawer
226
+ React.useEffect(() => {
227
+ const handleKeyDown = (e: KeyboardEvent) => {
228
+ if (e.key === '\\' && (e.metaKey || e.ctrlKey)) {
229
+ e.preventDefault();
230
+ // Check if we're on mobile (md breakpoint is 768px)
231
+ const isMobile = window.matchMedia('(max-width: 767px)').matches;
232
+ if (isMobile) {
233
+ setMobileDrawerOpen((prev) => !prev);
234
+ } else {
235
+ setSidebarOpen(!sidebarOpen);
236
+ }
237
+ }
238
+ };
239
+
240
+ document.addEventListener('keydown', handleKeyDown);
241
+ return () => document.removeEventListener('keydown', handleKeyDown);
242
+ }, [sidebarOpen, setSidebarOpen]);
243
+
244
+ const contextValue = React.useMemo<AppShellContextProps>(
245
+ () => ({
246
+ sidebarOpen,
247
+ setSidebarOpen,
248
+ toggleSidebar,
249
+ mobileDrawerOpen,
250
+ setMobileDrawerOpen,
251
+ toggleMobileDrawer,
252
+ railContent,
253
+ setRailContent,
254
+ sidebarContent,
255
+ setSidebarContent,
256
+ sidebarVariant,
257
+ setSidebarVariant,
258
+ }),
259
+ [sidebarOpen, setSidebarOpen, toggleSidebar, mobileDrawerOpen, toggleMobileDrawer, railContent, sidebarContent, sidebarVariant],
260
+ );
261
+
262
+ return (
263
+ <AppShellContext.Provider value={contextValue}>
264
+ <div
265
+ data-slot="app-shell"
266
+ data-sidebar-open={sidebarOpen}
267
+ className="flex h-svh w-full flex-col bg-muted overflow-hidden"
268
+ {...props}
269
+ >
270
+ {children}
271
+ {showAIChat && <AIChat>{aiChatContent}</AIChat>}
272
+ </div>
273
+ </AppShellContext.Provider>
274
+ );
275
+ }
276
+
277
+ function AppShellNavbar({
278
+ position = 'sticky',
279
+ showSidebarToggle = false,
280
+ startContent,
281
+ centerContent,
282
+ endContent,
283
+ children,
284
+ ...props
285
+ }: AppShellNavbarProps) {
286
+ const { toggleSidebar, sidebarOpen, toggleMobileDrawer } = useAppShell();
287
+
288
+ return (
289
+ <header data-slot="app-shell-navbar" className={`${appShellNavbarVariants({ position })} relative`} {...props}>
290
+ {/* Left section: sidebar toggle + start content */}
291
+ <div className="flex items-center gap-2 z-10">
292
+ {/* Mobile hamburger menu - always visible on mobile, controls mobile drawer */}
293
+ <button
294
+ type="button"
295
+ onClick={toggleMobileDrawer}
296
+ className="inline-flex md:hidden size-8 items-center justify-center rounded-md hover:bg-background/50"
297
+ aria-label="Toggle menu"
298
+ >
299
+ <SidePanelOpen className="size-4" />
300
+ </button>
301
+ {/* Desktop toggle - only visible when prop is true */}
302
+ {showSidebarToggle && (
303
+ <button
304
+ type="button"
305
+ onClick={toggleSidebar}
306
+ className="hidden md:inline-flex size-8 items-center justify-center rounded-md hover:bg-background/50"
307
+ aria-label="Toggle sidebar"
308
+ >
309
+ <SidePanelOpen className="size-4" />
310
+ </button>
311
+ )}
312
+ {startContent}
313
+ </div>
314
+
315
+ {/* Center section: absolutely positioned for true center */}
316
+ {centerContent && (
317
+ <div className="hidden md:flex absolute inset-0 items-center justify-center pointer-events-none">
318
+ <div className="pointer-events-auto">{centerContent}</div>
319
+ </div>
320
+ )}
321
+
322
+ {/* Right section: user menu + end content */}
323
+ <div className="flex items-center gap-2 z-10 ml-auto">{endContent}</div>
324
+
325
+ {/* Allow additional children for custom layouts */}
326
+ {children}
327
+ </header>
328
+ );
329
+ }
330
+
331
+ function AppShellBody({ children, ...props }: AppShellBodyProps) {
332
+ const { mobileDrawerOpen, setMobileDrawerOpen, railContent, sidebarContent, sidebarVariant } = useAppShell();
333
+
334
+ return (
335
+ <div data-slot="app-shell-body" className="flex flex-1 overflow-hidden bg-background/50 min-h-0 gap-0" {...props}>
336
+ {/* Mobile drawer - shows both rail and sidebar */}
337
+ <div className="md:hidden">
338
+ {/* Backdrop */}
339
+ {mobileDrawerOpen && (
340
+ <div
341
+ className="fixed inset-0 z-40 bg-black/50"
342
+ onClick={() => setMobileDrawerOpen(false)}
343
+ />
344
+ )}
345
+ {/* Drawer panel */}
346
+ <div
347
+ data-slot="app-shell-mobile-drawer"
348
+ className={`fixed inset-y-0 left-0 z-50 flex transform transition-transform duration-200 ease-in-out ${
349
+ mobileDrawerOpen ? 'translate-x-0' : '-translate-x-full'
350
+ }`}
351
+ >
352
+ {/* Rail section - only show if there are rail items */}
353
+ {railContent && (
354
+ <div className="flex flex-col items-center w-16 shrink-0 py-3 gap-1 bg-muted border-r border-border/40">
355
+ {railContent}
356
+ </div>
357
+ )}
358
+ {/* Sidebar section */}
359
+ <div
360
+ data-variant={sidebarVariant}
361
+ className={`flex flex-col w-64 p-2 ${
362
+ sidebarVariant === 'primary' ? 'bg-primary' : sidebarVariant === 'muted' ? 'bg-muted' : 'bg-background'
363
+ }`}
364
+ >
365
+ {sidebarContent}
366
+ </div>
367
+ </div>
368
+ </div>
369
+ {children}
370
+ </div>
371
+ );
372
+ }
373
+
374
+ // Wrapper for sidebar + content that maintains consistent left edge
375
+ function AppShellMain({ children, ...props }: Omit<React.ComponentProps<'div'>, 'className'>) {
376
+ return (
377
+ <div
378
+ data-slot="app-shell-main"
379
+ className="flex flex-1 min-h-0 ml-2 mr-2 mb-2 rounded-xl overflow-hidden bg-background"
380
+ {...props}
381
+ >
382
+ {children}
383
+ </div>
384
+ );
385
+ }
386
+
387
+ function AppShellRail({ showSidebarToggle = true, children, ...props }: AppShellRailProps) {
388
+ const { sidebarOpen, toggleSidebar, setRailContent } = useAppShell();
389
+ const itemsContainerRef = React.useRef<HTMLDivElement>(null);
390
+ const indicatorRef = React.useRef<HTMLSpanElement>(null);
391
+ const itemsRef = React.useRef<Map<string, HTMLElement>>(new Map());
392
+ const [activeId, setActiveId] = React.useState<string | null>(null);
393
+ const isFirstRender = React.useRef(true);
394
+
395
+ // Register rail content for mobile drawer
396
+ React.useEffect(() => {
397
+ setRailContent(children);
398
+ return () => setRailContent(null);
399
+ }, [children, setRailContent]);
400
+
401
+ const registerItem = React.useCallback((id: string, element: HTMLElement | null) => {
402
+ if (element) {
403
+ itemsRef.current.set(id, element);
404
+ } else {
405
+ itemsRef.current.delete(id);
406
+ }
407
+ }, []);
408
+
409
+ // Function to update indicator position
410
+ const updateIndicatorPosition = React.useCallback((animate = true) => {
411
+ if (!activeId || !itemsContainerRef.current || !indicatorRef.current) {
412
+ if (indicatorRef.current) {
413
+ indicatorRef.current.style.opacity = '0';
414
+ }
415
+ return;
416
+ }
417
+
418
+ const activeElement = itemsRef.current.get(activeId);
419
+ if (!activeElement) {
420
+ indicatorRef.current.style.opacity = '0';
421
+ return;
422
+ }
423
+
424
+ const containerRect = itemsContainerRef.current.getBoundingClientRect();
425
+ const itemRect = activeElement.getBoundingClientRect();
426
+
427
+ // Calculate center position of the item relative to items container
428
+ // Item center Y relative to container, minus half the indicator height (h-6 = 24px, so 12px)
429
+ const top = itemRect.top - containerRect.top + (itemRect.height / 2) - 12;
430
+
431
+ if (!animate || isFirstRender.current) {
432
+ // Position instantly without animation
433
+ indicatorRef.current.style.transition = 'none';
434
+ indicatorRef.current.style.top = `${top}px`;
435
+ if (isFirstRender.current) {
436
+ indicatorRef.current.style.opacity = '0';
437
+ // Force reflow
438
+ indicatorRef.current.offsetHeight;
439
+ // Re-enable transitions, then fade in
440
+ indicatorRef.current.style.transition = '';
441
+ requestAnimationFrame(() => {
442
+ if (indicatorRef.current) {
443
+ indicatorRef.current.style.opacity = '1';
444
+ }
445
+ });
446
+ isFirstRender.current = false;
447
+ } else {
448
+ indicatorRef.current.style.opacity = '1';
449
+ // Force reflow then re-enable transitions
450
+ indicatorRef.current.offsetHeight;
451
+ indicatorRef.current.style.transition = '';
452
+ }
453
+ } else {
454
+ indicatorRef.current.style.top = `${top}px`;
455
+ indicatorRef.current.style.opacity = '1';
456
+ }
457
+ }, [activeId]);
458
+
459
+ // Update indicator position when active item changes
460
+ React.useEffect(() => {
461
+ updateIndicatorPosition(true);
462
+ }, [activeId, updateIndicatorPosition]);
463
+
464
+ // Recalculate position on resize (without animation)
465
+ React.useEffect(() => {
466
+ const handleResize = () => {
467
+ updateIndicatorPosition(false);
468
+ };
469
+
470
+ window.addEventListener('resize', handleResize);
471
+ return () => window.removeEventListener('resize', handleResize);
472
+ }, [updateIndicatorPosition]);
473
+
474
+ const contextValue = React.useMemo<RailIndicatorContextProps>(
475
+ () => ({ registerItem, activeId, setActiveId }),
476
+ [registerItem, activeId]
477
+ );
478
+
479
+ return (
480
+ <RailIndicatorContext.Provider value={contextValue}>
481
+ <div
482
+ data-slot="app-shell-rail"
483
+ className="hidden md:flex flex-col items-center w-14 shrink-0 py-2 gap-1"
484
+ {...props}
485
+ >
486
+ {/* App/module items with indicator */}
487
+ <div ref={itemsContainerRef} className="flex flex-col items-center gap-1 flex-1 relative">
488
+ {/* Animated indicator pill */}
489
+ <span
490
+ ref={indicatorRef}
491
+ className="absolute right-0 w-1 h-6 rounded-full bg-primary transition-all duration-300 ease-out pointer-events-none -mr-2"
492
+ style={{ opacity: 0, top: 0 }}
493
+ />
494
+ {children}
495
+ </div>
496
+ {/* Sidebar toggle at bottom */}
497
+ {showSidebarToggle && (
498
+ <button
499
+ type="button"
500
+ onClick={toggleSidebar}
501
+ className="flex size-10 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-background/50 transition-colors"
502
+ aria-label={sidebarOpen ? 'Collapse sidebar' : 'Expand sidebar'}
503
+ >
504
+ {sidebarOpen ? (
505
+ <SidePanelClose className="size-5" />
506
+ ) : (
507
+ <SidePanelOpen className="size-5" />
508
+ )}
509
+ </button>
510
+ )}
511
+ </div>
512
+ </RailIndicatorContext.Provider>
513
+ );
514
+ }
515
+
516
+ function AppShellRailItem({ isActive, icon, label, ...props }: AppShellRailItemProps) {
517
+ const buttonRef = React.useRef<HTMLButtonElement>(null);
518
+ const context = React.useContext(RailIndicatorContext);
519
+ const itemId = React.useId();
520
+
521
+ // Register this item with the rail
522
+ React.useEffect(() => {
523
+ context?.registerItem(itemId, buttonRef.current);
524
+ return () => context?.registerItem(itemId, null);
525
+ }, [context, itemId]);
526
+
527
+ // Update active state in context
528
+ React.useEffect(() => {
529
+ if (isActive) {
530
+ context?.setActiveId(itemId);
531
+ }
532
+ }, [isActive, context, itemId]);
533
+
534
+ return (
535
+ <button
536
+ ref={buttonRef}
537
+ data-slot="app-shell-rail-item"
538
+ data-active={isActive}
539
+ className={`flex size-10 items-center justify-center rounded-md transition-all duration-200 cursor-pointer ${
540
+ isActive
541
+ ? 'bg-primary/10 text-primary'
542
+ : 'text-muted-foreground hover:text-foreground hover:bg-background/50'
543
+ }`}
544
+ title={label}
545
+ aria-label={label}
546
+ {...props}
547
+ >
548
+ <span className="size-5 [&>svg]:size-5">{icon}</span>
549
+ </button>
550
+ );
551
+ }
552
+
553
+ function AppShellSidebar({
554
+ width = 'default',
555
+ variant = 'default',
556
+ collapsible = true,
557
+ children,
558
+ ...props
559
+ }: AppShellSidebarProps) {
560
+ const { sidebarOpen, setSidebarContent, setSidebarVariant } = useAppShell();
561
+ const isCollapsed = collapsible && !sidebarOpen;
562
+
563
+ // Register sidebar content and variant for mobile drawer
564
+ React.useEffect(() => {
565
+ setSidebarContent(children);
566
+ setSidebarVariant(variant ?? 'default');
567
+ return () => {
568
+ setSidebarContent(null);
569
+ setSidebarVariant('default');
570
+ };
571
+ }, [children, variant, setSidebarContent, setSidebarVariant]);
572
+
573
+ return (
574
+ <>
575
+ {/* Desktop sidebar - always rendered, animated collapse */}
576
+ <aside
577
+ data-slot="app-shell-sidebar"
578
+ data-variant={variant}
579
+ data-collapsed={isCollapsed}
580
+ className={`${appShellSidebarVariants({ variant })} ${
581
+ isCollapsed ? 'w-0 p-0 border-0' : `${sidebarWidths[width]} p-2`
582
+ }`}
583
+ {...props}
584
+ >
585
+ {/* Inner container maintains width to prevent text squishing */}
586
+ <div
587
+ className={`flex flex-col h-full w-60 transition-opacity duration-100 ${
588
+ isCollapsed ? 'opacity-0' : 'opacity-100 delay-75'
589
+ }`}
590
+ >
591
+ {children}
592
+ </div>
593
+ </aside>
594
+ </>
595
+ );
596
+ }
597
+
598
+ function AppShellContent({ padding = 'default', children, ...props }: AppShellContentProps) {
599
+ return (
600
+ <main data-slot="app-shell-content" className={appShellContentVariants({ padding })} {...props}>
601
+ {children}
602
+ </main>
603
+ );
604
+ }
605
+
606
+ function AppShellSearch({
607
+ searchWidth = 'md',
608
+ showShortcut = true,
609
+ placeholder = 'Search...',
610
+ ...props
611
+ }: AppShellSearchProps) {
612
+ return (
613
+ <div className={appShellSearchVariants({ searchWidth })}>
614
+ <InputGroup>
615
+ <InputGroupAddon align="inline-start">
616
+ <Search />
617
+ </InputGroupAddon>
618
+ <InputGroupInput placeholder={placeholder} {...props} />
619
+ {showShortcut && (
620
+ <InputGroupAddon align="inline-end">
621
+ <Kbd>⌘K</Kbd>
622
+ </InputGroupAddon>
623
+ )}
624
+ </InputGroup>
625
+ </div>
626
+ );
627
+ }
628
+
629
+ function AppShellUserMenu({ children, ...props }: AppShellUserMenuProps) {
630
+ return (
631
+ <div data-slot="app-shell-user-menu" className="flex items-center gap-2" {...props}>
632
+ {children}
633
+ </div>
634
+ );
635
+ }
636
+
637
+ // ============ NAV COMPONENTS ============
638
+
639
+ interface AppShellSidebarHeaderProps extends Omit<React.ComponentProps<'div'>, 'className'> {
640
+ /** Icon for the current app/context */
641
+ icon?: React.ReactNode;
642
+ /** Title of the current app/context */
643
+ title: string;
644
+ /** Optional description or subtitle */
645
+ description?: string;
646
+ /** Optional action element (e.g., dropdown, button) */
647
+ action?: React.ReactNode;
648
+ }
649
+
650
+ interface AppShellNavProps extends Omit<React.ComponentProps<'nav'>, 'className'> {}
651
+
652
+ interface AppShellNavGroupProps extends Omit<React.ComponentProps<'div'>, 'className'> {
653
+ label?: string;
654
+ }
655
+
656
+ interface AppShellNavItemProps extends Omit<React.ComponentProps<'button'>, 'className'> {
657
+ isActive?: boolean;
658
+ icon?: React.ReactNode;
659
+ }
660
+
661
+ interface AppShellNavFooterProps extends Omit<React.ComponentProps<'div'>, 'className'> {}
662
+
663
+ function AppShellSidebarHeader({ icon, title, description, action, children, ...props }: AppShellSidebarHeaderProps) {
664
+ const isSimpleHeader = !icon && !action && !children;
665
+ return (
666
+ <div
667
+ data-slot="app-shell-sidebar-header"
668
+ className={[
669
+ 'flex items-center px-2 mb-2 border-b',
670
+ isSimpleHeader ? 'py-3' : 'gap-3 py-2',
671
+ 'border-border/40',
672
+ '[[data-variant=primary]_&]:border-primary-foreground/20',
673
+ ].join(' ')}
674
+ {...props}
675
+ >
676
+ {icon && (
677
+ <span
678
+ className={[
679
+ 'flex size-8 items-center justify-center rounded-lg shrink-0',
680
+ 'bg-muted/50 dark:bg-muted text-foreground',
681
+ '[[data-variant=primary]_&]:bg-primary-foreground/15 [[data-variant=primary]_&]:text-primary-foreground',
682
+ '[&>svg]:size-4',
683
+ ].join(' ')}
684
+ >
685
+ {icon}
686
+ </span>
687
+ )}
688
+ <div className="flex-1 min-w-0">
689
+ <div
690
+ className={[
691
+ 'truncate',
692
+ isSimpleHeader ? 'text-base' : 'text-sm',
693
+ 'text-foreground',
694
+ '[[data-variant=primary]_&]:text-primary-foreground',
695
+ ].join(' ')}
696
+ >
697
+ {title}
698
+ </div>
699
+ {description && (
700
+ <div
701
+ className={[
702
+ 'text-xs truncate',
703
+ 'text-muted-foreground',
704
+ '[[data-variant=primary]_&]:text-primary-foreground/70',
705
+ ].join(' ')}
706
+ >
707
+ {description}
708
+ </div>
709
+ )}
710
+ </div>
711
+ {action && <div className="shrink-0">{action}</div>}
712
+ {children}
713
+ </div>
714
+ );
715
+ }
716
+
717
+ function AppShellNav({ children, ...props }: AppShellNavProps) {
718
+ return (
719
+ <nav data-slot="app-shell-nav" className="flex-1 space-y-4 py-2" {...props}>
720
+ {children}
721
+ </nav>
722
+ );
723
+ }
724
+
725
+ function AppShellNavGroup({ label, children, ...props }: AppShellNavGroupProps) {
726
+ return (
727
+ <div data-slot="app-shell-nav-group" {...props}>
728
+ {label && (
729
+ <div
730
+ className={[
731
+ 'px-2 pb-1 text-xs font-medium uppercase tracking-wider',
732
+ // Default & muted variants
733
+ 'text-muted-foreground',
734
+ // Primary variant - light text
735
+ '[[data-variant=primary]_&]:text-primary-foreground/70',
736
+ ].join(' ')}
737
+ >
738
+ {label}
739
+ </div>
740
+ )}
741
+ <Stack gap="1">{children}</Stack>
742
+ </div>
743
+ );
744
+ }
745
+
746
+ function AppShellNavItem({ isActive, icon, children, ...props }: AppShellNavItemProps) {
747
+ return (
748
+ <button
749
+ data-slot="app-shell-nav-item"
750
+ data-active={isActive}
751
+ className={[
752
+ 'flex w-full items-center gap-3 rounded-md px-2 py-1.5 text-sm cursor-pointer',
753
+ // Smooth transitions for premium feel
754
+ 'transition-all duration-150 ease-out',
755
+ // Subtle scale on hover for premium touch
756
+ 'active:scale-[0.98]',
757
+ // Base styles for default/muted sidebar variants
758
+ isActive
759
+ ? [
760
+ // Active state - default variant: softer in light, stronger in dark
761
+ '[[data-variant=default]_&]:bg-muted/50 [[data-variant=default]_&]:dark:bg-muted [[data-variant=default]_&]:text-foreground',
762
+ // Active state - muted variant (gray bg sidebar): use white bg
763
+ '[[data-variant=muted]_&]:bg-background [[data-variant=muted]_&]:text-foreground [[data-variant=muted]_&]:shadow-sm',
764
+ // Active state - primary variant: use white/10 overlay
765
+ '[[data-variant=primary]_&]:bg-primary-foreground/15 [[data-variant=primary]_&]:text-primary-foreground',
766
+ 'font-medium',
767
+ ].join(' ')
768
+ : [
769
+ // Inactive - default/muted variants
770
+ 'text-muted-foreground hover:text-foreground',
771
+ '[[data-variant=default]_&]:hover:bg-muted/30 [[data-variant=default]_&]:dark:hover:bg-muted/60',
772
+ '[[data-variant=muted]_&]:hover:bg-background/60',
773
+ // Inactive - primary variant
774
+ '[[data-variant=primary]_&]:text-primary-foreground/70 [[data-variant=primary]_&]:hover:text-primary-foreground [[data-variant=primary]_&]:hover:bg-primary-foreground/10',
775
+ ].join(' '),
776
+ ].join(' ')}
777
+ {...props}
778
+ >
779
+ {icon && <span className="size-4 shrink-0 [&>svg]:size-4 transition-transform duration-150 group-hover:scale-110">{icon}</span>}
780
+ {children}
781
+ </button>
782
+ );
783
+ }
784
+
785
+ function AppShellNavFooter({ children, ...props }: AppShellNavFooterProps) {
786
+ return (
787
+ <div
788
+ data-slot="app-shell-nav-footer"
789
+ className={[
790
+ 'mt-auto border-t pt-2 space-y-1',
791
+ 'border-border/40',
792
+ '[[data-variant=primary]_&]:border-primary-foreground/20',
793
+ ].join(' ')}
794
+ {...props}
795
+ >
796
+ {children}
797
+ </div>
798
+ );
799
+ }
800
+
801
+ export {
802
+ AppShell,
803
+ AppShellBody,
804
+ AppShellContent,
805
+ AppShellMain,
806
+ AppShellNav,
807
+ AppShellNavbar,
808
+ AppShellNavFooter,
809
+ AppShellNavGroup,
810
+ AppShellNavItem,
811
+ AppShellRail,
812
+ AppShellRailItem,
813
+ AppShellSearch,
814
+ AppShellSidebar,
815
+ AppShellSidebarHeader,
816
+ AppShellUserMenu,
817
+ appShellContentVariants,
818
+ appShellNavbarVariants,
819
+ appShellSearchVariants,
820
+ appShellSidebarVariants,
821
+ useAppShell,
822
+ };