@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.
- package/package.json +5 -2
- package/src/components/atoms/badge.tsx +8 -7
- package/src/components/atoms/button.tsx +6 -1
- package/src/components/atoms/checkbox.tsx +3 -3
- package/src/components/atoms/heading.tsx +6 -6
- package/src/components/atoms/index.ts +2 -0
- package/src/components/atoms/logo.tsx +52 -0
- package/src/components/atoms/spinner.tsx +3 -3
- package/src/components/atoms/stack.tsx +97 -0
- package/src/components/atoms/switch.tsx +1 -1
- package/src/components/atoms/text.tsx +5 -1
- package/src/components/atoms/toggle.tsx +1 -1
- package/src/components/molecules/accordion.tsx +3 -3
- package/src/components/molecules/ai-chat.tsx +217 -0
- package/src/components/molecules/alert.tsx +5 -5
- package/src/components/molecules/breadcrumb.tsx +8 -7
- package/src/components/molecules/card.tsx +24 -5
- package/src/components/molecules/command-search.tsx +147 -0
- package/src/components/molecules/index.ts +4 -1
- package/src/components/molecules/input-otp.tsx +2 -2
- package/src/components/molecules/page-header.tsx +33 -4
- package/src/components/molecules/pagination.tsx +4 -4
- package/src/components/molecules/popover.tsx +4 -2
- package/src/components/molecules/radio-group.tsx +2 -2
- package/src/components/molecules/section.tsx +1 -1
- package/src/components/molecules/select.tsx +5 -5
- package/src/components/molecules/settings.tsx +169 -0
- package/src/components/molecules/table.tsx +5 -1
- package/src/components/molecules/tabs.tsx +5 -4
- package/src/components/molecules/theme-switcher.tsx +176 -0
- package/src/components/organisms/app-shell.tsx +822 -0
- package/src/components/organisms/calendar.tsx +4 -4
- package/src/components/organisms/carousel.tsx +3 -3
- package/src/components/organisms/combobox.tsx +5 -5
- package/src/components/organisms/command.tsx +3 -3
- package/src/components/organisms/context-menu.tsx +4 -4
- package/src/components/organisms/dialog.tsx +2 -2
- package/src/components/organisms/dropdown-menu.tsx +8 -6
- package/src/components/organisms/index.ts +1 -0
- package/src/components/organisms/menubar.tsx +3 -3
- package/src/components/organisms/navigation-menu.tsx +2 -2
- package/src/components/organisms/page-layout.tsx +50 -20
- package/src/components/organisms/sheet.tsx +2 -2
- package/src/components/organisms/sidebar.tsx +22 -6
- package/src/components/organisms/sonner.tsx +11 -11
- package/src/fonts/TWKLausanne/TWKLausanne-300-Italic.woff +0 -0
- package/src/fonts/TWKLausanne/TWKLausanne-300-Italic.woff2 +0 -0
- package/src/fonts/TWKLausanne/TWKLausanne-300.woff +0 -0
- package/src/fonts/TWKLausanne/TWKLausanne-300.woff2 +0 -0
- package/src/fonts/TWKLausanne/TWKLausanne-350-Italic.woff +0 -0
- package/src/fonts/TWKLausanne/TWKLausanne-350-Italic.woff2 +0 -0
- package/src/fonts/TWKLausanne/TWKLausanne-350.woff +0 -0
- package/src/fonts/TWKLausanne/TWKLausanne-350.woff2 +0 -0
- package/src/fonts/TWKLausanne/TWKLausanne-400-Italic.woff +0 -0
- package/src/fonts/TWKLausanne/TWKLausanne-400-Italic.woff2 +0 -0
- package/src/fonts/TWKLausanne/TWKLausanne-400.woff +0 -0
- package/src/fonts/TWKLausanne/TWKLausanne-400.woff2 +0 -0
- package/src/fonts/TWKLausanne/TWKLausanne-700-Italic.woff +0 -0
- package/src/fonts/TWKLausanne/TWKLausanne-700-Italic.woff2 +0 -0
- package/src/fonts/TWKLausanne/TWKLausanne-700.woff +0 -0
- package/src/fonts/TWKLausanne/TWKLausanne-700.woff2 +0 -0
- package/src/styles/globals.css +155 -23
- 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
|
+
};
|