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