@majapisoftwares/ui 6.0.0 → 6.1.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 (73) hide show
  1. package/AGENTS.md +27 -27
  2. package/README.md +79 -79
  3. package/THIRD_PARTY_NOTICES.md +39 -39
  4. package/components.json +25 -25
  5. package/docs/MIGRATION-0.2.md +20 -20
  6. package/docs/MIGRATION-6.0.md +33 -33
  7. package/docs/NEXT-STARTER-VALIDATION.md +14 -14
  8. package/docs/STANDARDS.md +41 -41
  9. package/docs/VALIDATION.md +11 -11
  10. package/docs/VERSIONING.md +28 -28
  11. package/docs/validation/preference-titles.md +22 -0
  12. package/package.json +88 -84
  13. package/scripts/check-standards.mjs +41 -41
  14. package/src/components/ui/accordion.tsx +82 -82
  15. package/src/components/ui/alert-dialog.tsx +187 -187
  16. package/src/components/ui/alert.tsx +77 -77
  17. package/src/components/ui/avatar.tsx +108 -108
  18. package/src/components/ui/badge.tsx +55 -55
  19. package/src/components/ui/breadcrumb.tsx +125 -125
  20. package/src/components/ui/button-group.tsx +89 -89
  21. package/src/components/ui/button.tsx +59 -59
  22. package/src/components/ui/card.tsx +101 -101
  23. package/src/components/ui/checkbox.tsx +27 -27
  24. package/src/components/ui/collapsible.tsx +21 -21
  25. package/src/components/ui/context-menu.tsx +271 -271
  26. package/src/components/ui/dialog.tsx +161 -161
  27. package/src/components/ui/dropdown-menu.tsx +271 -271
  28. package/src/components/ui/empty.tsx +105 -105
  29. package/src/components/ui/field.tsx +238 -238
  30. package/src/components/ui/hover-card.tsx +50 -50
  31. package/src/components/ui/input-group.tsx +155 -155
  32. package/src/components/ui/input.tsx +21 -21
  33. package/src/components/ui/kbd.tsx +28 -28
  34. package/src/components/ui/label.tsx +19 -19
  35. package/src/components/ui/orb.css +1002 -1002
  36. package/src/components/ui/orb.tsx +752 -752
  37. package/src/components/ui/popover.tsx +89 -89
  38. package/src/components/ui/progress.tsx +82 -82
  39. package/src/components/ui/radio-group.tsx +37 -37
  40. package/src/components/ui/scroll-area.tsx +54 -54
  41. package/src/components/ui/select.tsx +204 -204
  42. package/src/components/ui/separator.tsx +24 -24
  43. package/src/components/ui/sheet.tsx +139 -139
  44. package/src/components/ui/sidebar.tsx +1039 -1035
  45. package/src/components/ui/skeleton.tsx +15 -15
  46. package/src/components/ui/slider.tsx +53 -53
  47. package/src/components/ui/sonner.tsx +44 -44
  48. package/src/components/ui/spinner.tsx +32 -32
  49. package/src/components/ui/switch.tsx +31 -31
  50. package/src/components/ui/table.tsx +121 -121
  51. package/src/components/ui/tabs.tsx +81 -81
  52. package/src/components/ui/textarea.tsx +19 -19
  53. package/src/components/ui/toggle-group.tsx +89 -89
  54. package/src/components/ui/toggle.tsx +44 -44
  55. package/src/components/ui/tooltip.tsx +65 -65
  56. package/src/hooks/use-mobile.ts +20 -20
  57. package/src/lib/i18n.tsx +109 -109
  58. package/src/lib/utils.ts +1 -1
  59. package/src/patterns/app-shell.tsx +177 -175
  60. package/src/patterns/data-table.tsx +2486 -2486
  61. package/src/patterns/details-sheet.tsx +54 -53
  62. package/src/patterns/filter-tabs.tsx +53 -53
  63. package/src/patterns/majapi-logo.tsx +32 -32
  64. package/src/patterns/page-header.tsx +62 -62
  65. package/src/patterns/preferences.tsx +15 -7
  66. package/src/patterns/status-badge.tsx +24 -24
  67. package/src/patterns/user-menu.tsx +218 -0
  68. package/src/patterns/workspace-switcher.tsx +176 -0
  69. package/src/styles.css +165 -165
  70. package/src/templates/admin-dashboard.tsx +44 -44
  71. package/src/templates/auth-layout.tsx +30 -30
  72. package/src/templates/login-form.tsx +129 -129
  73. package/src/templates/signup-form.tsx +177 -177
@@ -1,1035 +1,1039 @@
1
- "use client";
2
-
3
- import * as React from "react";
4
- import { mergeProps } from "@base-ui/react/merge-props";
5
- import { useRender } from "@base-ui/react/use-render";
6
- import { cva, type VariantProps } from "class-variance-authority";
7
- import { cn } from "cn";
8
-
9
- import { useIsMobile } from "../../hooks/use-mobile";
10
- import { Button } from "./button";
11
- import { Input } from "./input";
12
- import { Separator } from "./separator";
13
- import {
14
- Sheet,
15
- SheetContent,
16
- SheetDescription,
17
- SheetHeader,
18
- SheetTitle,
19
- } from "./sheet";
20
- import { Skeleton } from "./skeleton";
21
- import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip";
22
- import {
23
- PanelLeftCloseIcon,
24
- PanelLeftIcon,
25
- PanelLeftOpenIcon,
26
- } from "lucide-react";
27
- import { useUiMessages } from "../../lib/i18n";
28
- import {
29
- ContextMenu,
30
- ContextMenuContent,
31
- ContextMenuRadioGroup,
32
- ContextMenuRadioItem,
33
- ContextMenuShortcut,
34
- ContextMenuTrigger,
35
- } from "./context-menu";
36
-
37
- const SIDEBAR_COOKIE_NAME = "sidebar_state";
38
- const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
39
- const SIDEBAR_WIDTH = 256;
40
- const SIDEBAR_WIDTH_MIN = 208;
41
- const SIDEBAR_WIDTH_MAX = 448;
42
- const SIDEBAR_WIDTH_MOBILE = "18rem";
43
- const SIDEBAR_WIDTH_ICON = "3rem";
44
- // Drag thresholds (px from the sidebar's edge) that switch modes while resizing.
45
- const SIDEBAR_CLOSE_BELOW = 24;
46
- const SIDEBAR_COMPACT_BELOW = 152;
47
- const SIDEBAR_KEYBOARD_SHORTCUT = "b";
48
-
49
- export type SidebarMode = "open" | "compact" | "closed";
50
-
51
- type SidebarContextProps = {
52
- state: "expanded" | "collapsed";
53
- mode: SidebarMode;
54
- setMode: (mode: SidebarMode) => void;
55
- width: number;
56
- setWidth: (width: number) => void;
57
- resizing: boolean;
58
- setResizing: (resizing: boolean) => void;
59
- resizeTo: (offset: number) => void;
60
- open: boolean;
61
- setOpen: (open: boolean) => void;
62
- openMobile: boolean;
63
- setOpenMobile: (open: boolean) => void;
64
- isMobile: boolean;
65
- toggleSidebar: () => void;
66
- toggleCompact: () => void;
67
- };
68
-
69
- const SidebarContext = React.createContext<SidebarContextProps | null>(null);
70
-
71
- function useSidebar() {
72
- const context = React.useContext(SidebarContext);
73
- if (!context) {
74
- throw new Error("useSidebar must be used within a SidebarProvider.");
75
- }
76
-
77
- return context;
78
- }
79
-
80
- const clampWidth = (width: number) =>
81
- Math.round(Math.min(SIDEBAR_WIDTH_MAX, Math.max(SIDEBAR_WIDTH_MIN, width)));
82
-
83
- /**
84
- * Applies the saved sidebar state to the server-rendered markup before the
85
- * browser paints (static pages can't read the cookie on the server).
86
- */
87
- function SidebarRestoreScript() {
88
- const script = `(function(){try{var m=document.cookie.match(/(?:^|; )${SIDEBAR_COOKIE_NAME}=([^;]*)/);if(!m)return;var p=m[1].split(":"),mode=p[0],w=Number(p[1]),wrap=document.currentScript.parentElement;if(w)wrap.setAttribute("style",(wrap.getAttribute("style")||"").replace(/--sidebar-width:[^;]*/,"--sidebar-width:"+Math.round(Math.min(${SIDEBAR_WIDTH_MAX},Math.max(${SIDEBAR_WIDTH_MIN},w)))+"px"));var sb=wrap.querySelector('[data-slot="sidebar"]');var r=wrap.querySelector('[data-slot="sidebar-rail"]');if(r)r.setAttribute("aria-valuenow",String(mode==="compact"?48:mode==="closed"?0:Math.round(Math.min(${SIDEBAR_WIDTH_MAX},Math.max(${SIDEBAR_WIDTH_MIN},w||${SIDEBAR_WIDTH})))));if(sb&&(mode==="compact"||mode==="closed")){sb.setAttribute("data-state","collapsed");sb.setAttribute("data-collapsible",mode==="closed"?"offcanvas":"icon")}}catch(e){}})()`;
89
- return (
90
- <script
91
- suppressHydrationWarning
92
- dangerouslySetInnerHTML={{ __html: script }}
93
- />
94
- );
95
- }
96
-
97
- /** Reads the persisted `mode:width` cookie, e.g. on the server via `cookies()`. */
98
- function parseSidebarCookie(value: string | undefined) {
99
- const [mode, width] = (value ?? "").split(":");
100
- return {
101
- mode: (["open", "compact", "closed"].includes(mode) ? mode : undefined) as
102
- SidebarMode | undefined,
103
- width: Number(width) ? clampWidth(Number(width)) : undefined,
104
- };
105
- }
106
-
107
- function readSidebarCookie() {
108
- if (typeof document === "undefined") return parseSidebarCookie(undefined);
109
- const match = document.cookie.match(
110
- new RegExp(`(?:^|; )${SIDEBAR_COOKIE_NAME}=([^;]*)`),
111
- );
112
- return parseSidebarCookie(match?.[1]);
113
- }
114
-
115
- function SidebarProvider({
116
- defaultMode,
117
- defaultWidth,
118
- mode: modeProp,
119
- onModeChange,
120
- className,
121
- style,
122
- children,
123
- ...props
124
- }: React.ComponentProps<"div"> & {
125
- defaultMode?: SidebarMode;
126
- defaultWidth?: number;
127
- mode?: SidebarMode;
128
- onModeChange?: (mode: SidebarMode) => void;
129
- }) {
130
- const isMobile = useIsMobile();
131
- const [openMobile, setOpenMobile] = React.useState(false);
132
- // Server/static HTML renders the defaults; SidebarRestoreScript patches that
133
- // markup to the saved state before paint, and the client starts from the
134
- // same saved state so hydration matches and nothing jumps.
135
- const [initial] = React.useState(() =>
136
- defaultMode !== undefined || defaultWidth !== undefined
137
- ? parseSidebarCookie(undefined)
138
- : readSidebarCookie(),
139
- );
140
- const [_mode, _setMode] = React.useState<SidebarMode>(
141
- defaultMode ?? initial.mode ?? "open",
142
- );
143
- const [width, _setWidth] = React.useState(
144
- clampWidth(defaultWidth ?? initial.width ?? SIDEBAR_WIDTH),
145
- );
146
- const [resizing, setResizing] = React.useState(false);
147
- const mode = modeProp ?? _mode;
148
- // Mode to restore when a closed sidebar is shown again.
149
- const lastVisible = React.useRef<SidebarMode>(
150
- mode === "closed" ? "open" : mode,
151
- );
152
-
153
- React.useEffect(() => {
154
- document.cookie = `${SIDEBAR_COOKIE_NAME}=${mode}:${width}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
155
- }, [mode, width]);
156
-
157
- const setMode = React.useCallback(
158
- (next: SidebarMode) => {
159
- if (next !== "closed") lastVisible.current = next;
160
- if (onModeChange) onModeChange(next);
161
- else _setMode(next);
162
- },
163
- [onModeChange],
164
- );
165
- const setWidth = React.useCallback(
166
- (next: number) => _setWidth(clampWidth(next)),
167
- [],
168
- );
169
-
170
- // Maps a dragged width to a mode: tiny closes, narrow shows icons, wide opens.
171
- const resizeTo = React.useCallback(
172
- (offset: number) => {
173
- if (offset < SIDEBAR_CLOSE_BELOW) setMode("closed");
174
- else if (offset < SIDEBAR_COMPACT_BELOW) setMode("compact");
175
- else {
176
- setMode("open");
177
- setWidth(offset);
178
- }
179
- },
180
- [setMode, setWidth],
181
- );
182
-
183
- const open = mode === "open";
184
- const setOpen = React.useCallback(
185
- (value: boolean) => setMode(value ? "open" : "compact"),
186
- [setMode],
187
- );
188
-
189
- const toggleSidebar = React.useCallback(() => {
190
- if (isMobile) return setOpenMobile((value) => !value);
191
- setMode(mode === "closed" ? lastVisible.current : "closed");
192
- }, [isMobile, mode, setMode]);
193
-
194
- const toggleCompact = React.useCallback(() => {
195
- if (isMobile) return setOpenMobile(false);
196
- setMode(mode === "open" ? "compact" : "open");
197
- }, [isMobile, mode, setMode]);
198
-
199
- React.useEffect(() => {
200
- const handleKeyDown = (event: KeyboardEvent) => {
201
- if (
202
- event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
203
- (event.metaKey || event.ctrlKey)
204
- ) {
205
- event.preventDefault();
206
- toggleSidebar();
207
- }
208
- };
209
-
210
- window.addEventListener("keydown", handleKeyDown);
211
- return () => window.removeEventListener("keydown", handleKeyDown);
212
- }, [toggleSidebar]);
213
-
214
- const state = open ? "expanded" : "collapsed";
215
-
216
- const contextValue = React.useMemo<SidebarContextProps>(
217
- () => ({
218
- state,
219
- mode,
220
- setMode,
221
- width,
222
- setWidth,
223
- resizing,
224
- setResizing,
225
- resizeTo,
226
- open,
227
- setOpen,
228
- isMobile,
229
- openMobile,
230
- setOpenMobile,
231
- toggleSidebar,
232
- toggleCompact,
233
- }),
234
- [
235
- state,
236
- mode,
237
- setMode,
238
- width,
239
- setWidth,
240
- resizing,
241
- resizeTo,
242
- open,
243
- setOpen,
244
- isMobile,
245
- openMobile,
246
- toggleSidebar,
247
- toggleCompact,
248
- ],
249
- );
250
-
251
- return (
252
- <SidebarContext.Provider value={contextValue}>
253
- <div
254
- data-slot="sidebar-wrapper"
255
- data-resizing={resizing || undefined}
256
- style={
257
- {
258
- "--sidebar-width": `${width}px`,
259
- "--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
260
- ...style,
261
- } as React.CSSProperties
262
- }
263
- className={cn(
264
- "group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar data-resizing:cursor-col-resize data-resizing:select-none",
265
- className,
266
- )}
267
- {...props}
268
- >
269
- {children}
270
- {defaultMode === undefined && defaultWidth === undefined ? (
271
- <SidebarRestoreScript />
272
- ) : null}
273
- </div>
274
- </SidebarContext.Provider>
275
- );
276
- }
277
-
278
- function Sidebar({
279
- side = "left",
280
- variant = "sidebar",
281
- collapsible = "auto",
282
- className,
283
- children,
284
- dir,
285
- ...props
286
- }: React.ComponentProps<"div"> & {
287
- side?: "left" | "right";
288
- variant?: "sidebar" | "floating" | "inset";
289
- /** `auto` follows the provider mode: compact shows icons, closed hides. */
290
- collapsible?: "auto" | "offcanvas" | "icon" | "none";
291
- }) {
292
- const { isMobile, state, mode, openMobile, setOpenMobile } = useSidebar();
293
- const messages = useUiMessages();
294
- const collapsedAs =
295
- collapsible === "auto"
296
- ? mode === "closed"
297
- ? "offcanvas"
298
- : "icon"
299
- : collapsible;
300
-
301
- if (collapsible === "none") {
302
- return (
303
- <div
304
- data-slot="sidebar"
305
- className={cn(
306
- "flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
307
- className,
308
- )}
309
- {...props}
310
- >
311
- {children}
312
- </div>
313
- );
314
- }
315
-
316
- if (isMobile) {
317
- return (
318
- <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
319
- <SheetContent
320
- dir={dir}
321
- data-sidebar="sidebar"
322
- data-slot="sidebar"
323
- data-mobile="true"
324
- className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
325
- style={
326
- {
327
- "--sidebar-width": SIDEBAR_WIDTH_MOBILE,
328
- } as React.CSSProperties
329
- }
330
- side={side}
331
- >
332
- <SheetHeader className="sr-only">
333
- <SheetTitle>{messages.sidebar}</SheetTitle>
334
- <SheetDescription>{messages.sidebar}</SheetDescription>
335
- </SheetHeader>
336
- <div className="flex h-full w-full flex-col">{children}</div>
337
- </SheetContent>
338
- </Sheet>
339
- );
340
- }
341
-
342
- return (
343
- <div
344
- className="group peer hidden text-sidebar-foreground md:block"
345
- data-state={state}
346
- data-collapsible={state === "collapsed" ? collapsedAs : ""}
347
- data-variant={variant}
348
- data-side={side}
349
- data-slot="sidebar"
350
- >
351
- {/* This is what handles the sidebar gap on desktop */}
352
- <div
353
- data-slot="sidebar-gap"
354
- className={cn(
355
- "relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear group-data-resizing/sidebar-wrapper:transition-none",
356
- "group-data-[collapsible=offcanvas]:w-0",
357
- "group-data-[side=right]:rotate-180",
358
- variant === "floating" || variant === "inset"
359
- ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
360
- : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
361
- )}
362
- />
363
- <div
364
- data-slot="sidebar-container"
365
- data-side={side}
366
- className={cn(
367
- "fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear group-data-resizing/sidebar-wrapper:transition-none data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
368
- // Adjust the padding for floating and inset variants.
369
- variant === "floating" || variant === "inset"
370
- ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
371
- : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
372
- className,
373
- )}
374
- {...props}
375
- >
376
- <div
377
- data-sidebar="sidebar"
378
- data-slot="sidebar-inner"
379
- className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-2xl group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
380
- >
381
- {children}
382
- </div>
383
- </div>
384
- </div>
385
- );
386
- }
387
-
388
- const isMac = () =>
389
- typeof navigator !== "undefined" &&
390
- /Mac|iP(hone|ad)/.test(navigator.platform);
391
-
392
- /**
393
- * Wraps a sidebar control with a tooltip (label + shortcut hint) and a
394
- * right-click menu to pick the mode directly: expanded, compact or hidden.
395
- */
396
- function SidebarModeControl({
397
- label,
398
- children,
399
- }: {
400
- label: string;
401
- children: React.ReactElement;
402
- }) {
403
- const { mode, setMode, isMobile } = useSidebar();
404
- const messages = useUiMessages();
405
- const shortcut = `${isMac() ? "⌘" : "Ctrl+"}${SIDEBAR_KEYBOARD_SHORTCUT.toUpperCase()}`;
406
- // Clicking changes the sidebar size and moves this control, so the tooltip
407
- // closes on press and stays closed until the pointer leaves.
408
- const [tooltipOpen, setTooltipOpen] = React.useState(false);
409
- const suppressed = React.useRef(false);
410
-
411
- if (isMobile) return children;
412
-
413
- return (
414
- <ContextMenu>
415
- <Tooltip
416
- open={tooltipOpen}
417
- onOpenChange={(open) => setTooltipOpen(open && !suppressed.current)}
418
- >
419
- <ContextMenuTrigger
420
- render={
421
- <TooltipTrigger
422
- render={children}
423
- delay={600}
424
- onPointerDown={() => {
425
- suppressed.current = true;
426
- setTooltipOpen(false);
427
- }}
428
- onPointerLeave={() => {
429
- suppressed.current = false;
430
- }}
431
- />
432
- }
433
- />
434
- <TooltipContent side="right" className="flex flex-col gap-0.5">
435
- <span>{label}</span>
436
- <span className="opacity-70">{messages.sidebarHint(shortcut)}</span>
437
- </TooltipContent>
438
- </Tooltip>
439
- <ContextMenuContent className="min-w-44">
440
- <ContextMenuRadioGroup
441
- value={mode}
442
- onValueChange={(value) => setMode(value as SidebarMode)}
443
- >
444
- <ContextMenuRadioItem value="open">
445
- {messages.modeOpen}
446
- </ContextMenuRadioItem>
447
- <ContextMenuRadioItem value="compact">
448
- {messages.modeCompact}
449
- </ContextMenuRadioItem>
450
- <ContextMenuRadioItem value="closed">
451
- {messages.modeClosed}
452
- <ContextMenuShortcut>{shortcut}</ContextMenuShortcut>
453
- </ContextMenuRadioItem>
454
- </ContextMenuRadioGroup>
455
- </ContextMenuContent>
456
- </ContextMenu>
457
- );
458
- }
459
-
460
- function SidebarTrigger({
461
- className,
462
- onClick,
463
- ...props
464
- }: React.ComponentProps<typeof Button>) {
465
- const { toggleSidebar } = useSidebar();
466
- const messages = useUiMessages();
467
-
468
- return (
469
- <SidebarModeControl label={messages.toggleSidebar}>
470
- <Button
471
- data-sidebar="trigger"
472
- data-slot="sidebar-trigger"
473
- variant="ghost"
474
- size="icon-sm"
475
- className={cn(className)}
476
- aria-label={messages.toggleSidebar}
477
- onClick={(event) => {
478
- onClick?.(event);
479
- toggleSidebar();
480
- }}
481
- {...props}
482
- >
483
- <PanelLeftIcon />
484
- </Button>
485
- </SidebarModeControl>
486
- );
487
- }
488
-
489
- /** Collapse control meant to live inside the sidebar: toggles open ↔ compact. */
490
- function SidebarCollapse({
491
- className,
492
- onClick,
493
- ...props
494
- }: React.ComponentProps<typeof Button>) {
495
- const { mode, toggleCompact, isMobile } = useSidebar();
496
- const messages = useUiMessages();
497
- const label =
498
- mode === "open" || isMobile
499
- ? messages.collapseSidebar
500
- : messages.expandSidebar;
501
-
502
- return (
503
- <SidebarModeControl label={label}>
504
- <Button
505
- data-sidebar="collapse"
506
- data-slot="sidebar-collapse"
507
- variant="ghost"
508
- size="icon-sm"
509
- className={cn(className)}
510
- // Stable label/icons (CSS-switched) so the pre-paint restored state
511
- // hydrates without mismatches.
512
- aria-label={messages.toggleSidebar}
513
- onClick={(event) => {
514
- onClick?.(event);
515
- toggleCompact();
516
- }}
517
- {...props}
518
- >
519
- <PanelLeftCloseIcon className="group-data-[collapsible=icon]:hidden" />
520
- <PanelLeftOpenIcon className="hidden group-data-[collapsible=icon]:block" />
521
- </Button>
522
- </SidebarModeControl>
523
- );
524
- }
525
-
526
- /**
527
- * Drag handle on the sidebar edge. Dragging resizes the open sidebar and
528
- * switches modes by width (closed → compact → open); a click toggles
529
- * open ↔ compact; arrow keys resize from the keyboard.
530
- */
531
- function SidebarRail({ className, ...props }: React.ComponentProps<"div">) {
532
- const { mode, width, resizeTo, setResizing, toggleCompact } = useSidebar();
533
- const messages = useUiMessages();
534
- const drag = React.useRef<{
535
- startX: number;
536
- startOffset: number;
537
- side: "left" | "right";
538
- moved: boolean;
539
- } | null>(null);
540
- const offset = mode === "open" ? width : mode === "compact" ? 48 : 0;
541
-
542
- return (
543
- <div
544
- role="separator"
545
- aria-orientation="vertical"
546
- aria-label={messages.resizeSidebar}
547
- aria-valuenow={offset}
548
- aria-valuemin={0}
549
- aria-valuemax={SIDEBAR_WIDTH_MAX}
550
- tabIndex={0}
551
- data-sidebar="rail"
552
- data-slot="sidebar-rail"
553
- onPointerDown={(event) => {
554
- if (event.button !== 0) return;
555
- event.currentTarget.setPointerCapture(event.pointerId);
556
- const side =
557
- event.currentTarget.closest<HTMLElement>("[data-side]")?.dataset
558
- .side === "right"
559
- ? "right"
560
- : "left";
561
- drag.current = {
562
- startX: event.clientX,
563
- startOffset: offset,
564
- side,
565
- moved: false,
566
- };
567
- }}
568
- onPointerMove={(event) => {
569
- const current = drag.current;
570
- if (!current) return;
571
- const delta =
572
- (event.clientX - current.startX) * (current.side === "left" ? 1 : -1);
573
- if (!current.moved && Math.abs(delta) < 4) return;
574
- if (!current.moved) setResizing(true);
575
- current.moved = true;
576
- resizeTo(current.startOffset + delta);
577
- }}
578
- onPointerUp={(event) => {
579
- event.currentTarget.releasePointerCapture(event.pointerId);
580
- if (drag.current && !drag.current.moved) toggleCompact();
581
- drag.current = null;
582
- setResizing(false);
583
- }}
584
- onPointerCancel={() => {
585
- drag.current = null;
586
- setResizing(false);
587
- }}
588
- onKeyDown={(event) => {
589
- const step = event.shiftKey ? 64 : 16;
590
- if (event.key === "ArrowRight") resizeTo(offset + step);
591
- else if (event.key === "ArrowLeft") resizeTo(offset - step);
592
- else if (event.key === "Enter" || event.key === " ") toggleCompact();
593
- else return;
594
- event.preventDefault();
595
- }}
596
- className={cn(
597
- "absolute inset-y-0 z-20 hidden w-4 touch-none cursor-col-resize outline-none group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] after:transition-colors hover:after:bg-sidebar-border focus-visible:after:bg-sidebar-ring sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
598
- "group-data-resizing/sidebar-wrapper:after:bg-sidebar-ring",
599
- "group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
600
- "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
601
- "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
602
- className,
603
- )}
604
- {...props}
605
- />
606
- );
607
- }
608
-
609
- function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
610
- return (
611
- <main
612
- data-slot="sidebar-inset"
613
- className={cn(
614
- "relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-2xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
615
- className,
616
- )}
617
- {...props}
618
- />
619
- );
620
- }
621
-
622
- function SidebarInput({
623
- className,
624
- ...props
625
- }: React.ComponentProps<typeof Input>) {
626
- return (
627
- <Input
628
- data-slot="sidebar-input"
629
- data-sidebar="input"
630
- className={cn("h-8 w-full bg-input/50 shadow-none", className)}
631
- {...props}
632
- />
633
- );
634
- }
635
-
636
- function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
637
- return (
638
- <div
639
- data-slot="sidebar-header"
640
- data-sidebar="header"
641
- className={cn(
642
- "flex flex-col gap-2 p-2 [--radius:var(--radius-xl)]",
643
- className,
644
- )}
645
- {...props}
646
- />
647
- );
648
- }
649
-
650
- function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
651
- return (
652
- <div
653
- data-slot="sidebar-footer"
654
- data-sidebar="footer"
655
- className={cn(
656
- "flex flex-col gap-2 p-2 [--radius:var(--radius-xl)]",
657
- className,
658
- )}
659
- {...props}
660
- />
661
- );
662
- }
663
-
664
- function SidebarSeparator({
665
- className,
666
- ...props
667
- }: React.ComponentProps<typeof Separator>) {
668
- return (
669
- <Separator
670
- data-slot="sidebar-separator"
671
- data-sidebar="separator"
672
- className={cn("mx-2 w-auto bg-sidebar-border", className)}
673
- {...props}
674
- />
675
- );
676
- }
677
-
678
- function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
679
- return (
680
- <div
681
- data-slot="sidebar-content"
682
- data-sidebar="content"
683
- className={cn(
684
- "no-scrollbar flex min-h-0 flex-1 flex-col gap-2 overflow-auto scroll-fade [--radius:var(--radius-xl)] group-data-[collapsible=icon]:overflow-hidden",
685
- className,
686
- )}
687
- {...props}
688
- />
689
- );
690
- }
691
-
692
- function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
693
- return (
694
- <div
695
- data-slot="sidebar-group"
696
- data-sidebar="group"
697
- className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
698
- {...props}
699
- />
700
- );
701
- }
702
-
703
- function SidebarGroupLabel({
704
- className,
705
- render,
706
- ...props
707
- }: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
708
- return useRender({
709
- defaultTagName: "div",
710
- props: mergeProps<"div">(
711
- {
712
- className: cn(
713
- "flex h-8 shrink-0 items-center rounded-xl px-3 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
714
- className,
715
- ),
716
- },
717
- props,
718
- ),
719
- render,
720
- state: {
721
- slot: "sidebar-group-label",
722
- sidebar: "group-label",
723
- },
724
- });
725
- }
726
-
727
- function SidebarGroupAction({
728
- className,
729
- render,
730
- ...props
731
- }: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
732
- return useRender({
733
- defaultTagName: "button",
734
- props: mergeProps<"button">(
735
- {
736
- className: cn(
737
- "absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-xl p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
738
- className,
739
- ),
740
- },
741
- props,
742
- ),
743
- render,
744
- state: {
745
- slot: "sidebar-group-action",
746
- sidebar: "group-action",
747
- },
748
- });
749
- }
750
-
751
- function SidebarGroupContent({
752
- className,
753
- ...props
754
- }: React.ComponentProps<"div">) {
755
- return (
756
- <div
757
- data-slot="sidebar-group-content"
758
- data-sidebar="group-content"
759
- className={cn("w-full text-sm", className)}
760
- {...props}
761
- />
762
- );
763
- }
764
-
765
- function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
766
- return (
767
- <ul
768
- data-slot="sidebar-menu"
769
- data-sidebar="menu"
770
- className={cn("flex w-full min-w-0 flex-col gap-0.5", className)}
771
- {...props}
772
- />
773
- );
774
- }
775
-
776
- function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
777
- return (
778
- <li
779
- data-slot="sidebar-menu-item"
780
- data-sidebar="menu-item"
781
- className={cn("group/menu-item relative", className)}
782
- {...props}
783
- />
784
- );
785
- }
786
-
787
- const sidebarMenuButtonVariants = cva(
788
- "peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-xl px-3 py-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
789
- {
790
- variants: {
791
- variant: {
792
- default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
793
- outline:
794
- "bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
795
- },
796
- size: {
797
- default: "h-9 text-sm",
798
- sm: "h-8 text-xs",
799
- lg: "h-14 px-3 text-sm group-data-[collapsible=icon]:p-0!",
800
- },
801
- },
802
- defaultVariants: {
803
- variant: "default",
804
- size: "default",
805
- },
806
- },
807
- );
808
-
809
- function SidebarMenuButton({
810
- render,
811
- isActive = false,
812
- variant = "default",
813
- size = "default",
814
- tooltip,
815
- className,
816
- ...props
817
- }: useRender.ComponentProps<"button"> &
818
- React.ComponentProps<"button"> & {
819
- isActive?: boolean;
820
- tooltip?: string | React.ComponentProps<typeof TooltipContent>;
821
- } & VariantProps<typeof sidebarMenuButtonVariants>) {
822
- const { isMobile, state } = useSidebar();
823
- const comp = useRender({
824
- defaultTagName: "button",
825
- props: mergeProps<"button">(
826
- {
827
- className: cn(sidebarMenuButtonVariants({ variant, size }), className),
828
- },
829
- props,
830
- ),
831
- render: !tooltip ? render : <TooltipTrigger render={render} />,
832
- state: {
833
- slot: "sidebar-menu-button",
834
- sidebar: "menu-button",
835
- size,
836
- active: isActive,
837
- },
838
- });
839
-
840
- if (!tooltip) {
841
- return comp;
842
- }
843
-
844
- if (typeof tooltip === "string") {
845
- tooltip = {
846
- children: tooltip,
847
- };
848
- }
849
-
850
- return (
851
- <Tooltip>
852
- {comp}
853
- <TooltipContent
854
- side="right"
855
- align="center"
856
- hidden={state !== "collapsed" || isMobile}
857
- {...tooltip}
858
- />
859
- </Tooltip>
860
- );
861
- }
862
-
863
- function SidebarMenuAction({
864
- className,
865
- render,
866
- showOnHover = false,
867
- ...props
868
- }: useRender.ComponentProps<"button"> &
869
- React.ComponentProps<"button"> & {
870
- showOnHover?: boolean;
871
- }) {
872
- return useRender({
873
- defaultTagName: "button",
874
- props: mergeProps<"button">(
875
- {
876
- className: cn(
877
- "absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-xl p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-2 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
878
- showOnHover &&
879
- "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
880
- className,
881
- ),
882
- },
883
- props,
884
- ),
885
- render,
886
- state: {
887
- slot: "sidebar-menu-action",
888
- sidebar: "menu-action",
889
- },
890
- });
891
- }
892
-
893
- function SidebarMenuBadge({
894
- className,
895
- ...props
896
- }: React.ComponentProps<"div">) {
897
- return (
898
- <div
899
- data-slot="sidebar-menu-badge"
900
- data-sidebar="menu-badge"
901
- className={cn(
902
- "pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-xl px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
903
- className,
904
- )}
905
- {...props}
906
- />
907
- );
908
- }
909
-
910
- function SidebarMenuSkeleton({
911
- className,
912
- showIcon = false,
913
- ...props
914
- }: React.ComponentProps<"div"> & {
915
- showIcon?: boolean;
916
- }) {
917
- // Random width between 50 to 90%.
918
- const [width] = React.useState(() => {
919
- return `${Math.floor(Math.random() * 40) + 50}%`;
920
- });
921
-
922
- return (
923
- <div
924
- data-slot="sidebar-menu-skeleton"
925
- data-sidebar="menu-skeleton"
926
- className={cn("flex h-8 items-center gap-2 rounded-xl px-2", className)}
927
- {...props}
928
- >
929
- {showIcon && (
930
- <Skeleton
931
- className="size-4 rounded-xl"
932
- data-sidebar="menu-skeleton-icon"
933
- />
934
- )}
935
- <Skeleton
936
- className="h-4 max-w-(--skeleton-width) flex-1"
937
- data-sidebar="menu-skeleton-text"
938
- style={
939
- {
940
- "--skeleton-width": width,
941
- } as React.CSSProperties
942
- }
943
- />
944
- </div>
945
- );
946
- }
947
-
948
- function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
949
- return (
950
- <ul
951
- data-slot="sidebar-menu-sub"
952
- data-sidebar="menu-sub"
953
- className={cn(
954
- "mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
955
- className,
956
- )}
957
- {...props}
958
- />
959
- );
960
- }
961
-
962
- function SidebarMenuSubItem({
963
- className,
964
- ...props
965
- }: React.ComponentProps<"li">) {
966
- return (
967
- <li
968
- data-slot="sidebar-menu-sub-item"
969
- data-sidebar="menu-sub-item"
970
- className={cn("group/menu-sub-item relative", className)}
971
- {...props}
972
- />
973
- );
974
- }
975
-
976
- function SidebarMenuSubButton({
977
- render,
978
- size = "md",
979
- isActive = false,
980
- className,
981
- ...props
982
- }: useRender.ComponentProps<"a"> &
983
- React.ComponentProps<"a"> & {
984
- size?: "sm" | "md";
985
- isActive?: boolean;
986
- }) {
987
- return useRender({
988
- defaultTagName: "a",
989
- props: mergeProps<"a">(
990
- {
991
- className: cn(
992
- "flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-xl px-3 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
993
- className,
994
- ),
995
- },
996
- props,
997
- ),
998
- render,
999
- state: {
1000
- slot: "sidebar-menu-sub-button",
1001
- sidebar: "menu-sub-button",
1002
- size,
1003
- active: isActive,
1004
- },
1005
- });
1006
- }
1007
-
1008
- export {
1009
- Sidebar,
1010
- SidebarContent,
1011
- SidebarFooter,
1012
- SidebarGroup,
1013
- SidebarGroupAction,
1014
- SidebarGroupContent,
1015
- SidebarGroupLabel,
1016
- SidebarHeader,
1017
- SidebarInput,
1018
- SidebarInset,
1019
- SidebarMenu,
1020
- SidebarMenuAction,
1021
- SidebarMenuBadge,
1022
- SidebarMenuButton,
1023
- SidebarMenuItem,
1024
- SidebarMenuSkeleton,
1025
- SidebarMenuSub,
1026
- SidebarMenuSubButton,
1027
- SidebarMenuSubItem,
1028
- SidebarCollapse,
1029
- SidebarProvider,
1030
- SidebarRail,
1031
- SidebarSeparator,
1032
- SidebarTrigger,
1033
- parseSidebarCookie,
1034
- useSidebar,
1035
- };
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { mergeProps } from "@base-ui/react/merge-props";
5
+ import { useRender } from "@base-ui/react/use-render";
6
+ import { cva, type VariantProps } from "class-variance-authority";
7
+ import { cn } from "cn";
8
+
9
+ import { useIsMobile } from "../../hooks/use-mobile";
10
+ import { Button } from "./button";
11
+ import { Input } from "./input";
12
+ import { Separator } from "./separator";
13
+ import {
14
+ Sheet,
15
+ SheetContent,
16
+ SheetDescription,
17
+ SheetHeader,
18
+ SheetTitle,
19
+ } from "./sheet";
20
+ import { Skeleton } from "./skeleton";
21
+ import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip";
22
+ import {
23
+ PanelLeftCloseIcon,
24
+ PanelLeftIcon,
25
+ PanelLeftOpenIcon,
26
+ } from "lucide-react";
27
+ import { useUiMessages } from "../../lib/i18n";
28
+ import {
29
+ ContextMenu,
30
+ ContextMenuContent,
31
+ ContextMenuRadioGroup,
32
+ ContextMenuRadioItem,
33
+ ContextMenuShortcut,
34
+ ContextMenuTrigger,
35
+ } from "./context-menu";
36
+
37
+ const SIDEBAR_COOKIE_NAME = "sidebar_state";
38
+ const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
39
+ const SIDEBAR_WIDTH = 256;
40
+ const SIDEBAR_WIDTH_MIN = 208;
41
+ const SIDEBAR_WIDTH_MAX = 448;
42
+ const SIDEBAR_WIDTH_MOBILE = "18rem";
43
+ const SIDEBAR_WIDTH_ICON = "3rem";
44
+ // Drag thresholds (px from the sidebar's edge) that switch modes while resizing.
45
+ const SIDEBAR_CLOSE_BELOW = 24;
46
+ const SIDEBAR_COMPACT_BELOW = 152;
47
+ const SIDEBAR_KEYBOARD_SHORTCUT = "b";
48
+
49
+ export type SidebarMode = "open" | "compact" | "closed";
50
+
51
+ type SidebarContextProps = {
52
+ state: "expanded" | "collapsed";
53
+ mode: SidebarMode;
54
+ setMode: (mode: SidebarMode) => void;
55
+ width: number;
56
+ setWidth: (width: number) => void;
57
+ resizing: boolean;
58
+ setResizing: (resizing: boolean) => void;
59
+ resizeTo: (offset: number) => void;
60
+ open: boolean;
61
+ setOpen: (open: boolean) => void;
62
+ openMobile: boolean;
63
+ setOpenMobile: (open: boolean) => void;
64
+ isMobile: boolean;
65
+ toggleSidebar: () => void;
66
+ toggleCompact: () => void;
67
+ };
68
+
69
+ const SidebarContext = React.createContext<SidebarContextProps | null>(null);
70
+
71
+ function useSidebar() {
72
+ const context = React.useContext(SidebarContext);
73
+ if (!context) {
74
+ throw new Error("useSidebar must be used within a SidebarProvider.");
75
+ }
76
+
77
+ return context;
78
+ }
79
+
80
+ const clampWidth = (width: number) =>
81
+ Math.round(Math.min(SIDEBAR_WIDTH_MAX, Math.max(SIDEBAR_WIDTH_MIN, width)));
82
+
83
+ /**
84
+ * Applies the saved sidebar state to the server-rendered markup before the
85
+ * browser paints (static pages can't read the cookie on the server).
86
+ */
87
+ function SidebarRestoreScript() {
88
+ const script = `(function(){try{var m=document.cookie.match(/(?:^|; )${SIDEBAR_COOKIE_NAME}=([^;]*)/);if(!m)return;var p=m[1].split(":"),mode=p[0],w=Number(p[1]),wrap=document.currentScript.parentElement;if(w)wrap.setAttribute("style",(wrap.getAttribute("style")||"").replace(/--sidebar-width:[^;]*/,"--sidebar-width:"+Math.round(Math.min(${SIDEBAR_WIDTH_MAX},Math.max(${SIDEBAR_WIDTH_MIN},w)))+"px"));var sb=wrap.querySelector('[data-slot="sidebar"]');var r=wrap.querySelector('[data-slot="sidebar-rail"]');if(r)r.setAttribute("aria-valuenow",String(mode==="compact"?48:mode==="closed"?0:Math.round(Math.min(${SIDEBAR_WIDTH_MAX},Math.max(${SIDEBAR_WIDTH_MIN},w||${SIDEBAR_WIDTH})))));if(sb&&(mode==="compact"||mode==="closed")){sb.setAttribute("data-state","collapsed");sb.setAttribute("data-collapsible",mode==="closed"?"offcanvas":"icon")}}catch(e){}})()`;
89
+ return (
90
+ <script
91
+ suppressHydrationWarning
92
+ dangerouslySetInnerHTML={{ __html: script }}
93
+ />
94
+ );
95
+ }
96
+
97
+ /** Reads the persisted `mode:width` cookie, e.g. on the server via `cookies()`. */
98
+ function parseSidebarCookie(value: string | undefined) {
99
+ const [mode, width] = (value ?? "").split(":");
100
+ return {
101
+ mode: (["open", "compact", "closed"].includes(mode) ? mode : undefined) as
102
+ SidebarMode | undefined,
103
+ width: Number(width) ? clampWidth(Number(width)) : undefined,
104
+ };
105
+ }
106
+
107
+ function readSidebarCookie() {
108
+ if (typeof document === "undefined") return parseSidebarCookie(undefined);
109
+ const match = document.cookie.match(
110
+ new RegExp(`(?:^|; )${SIDEBAR_COOKIE_NAME}=([^;]*)`),
111
+ );
112
+ return parseSidebarCookie(match?.[1]);
113
+ }
114
+
115
+ function SidebarProvider({
116
+ defaultMode,
117
+ defaultWidth,
118
+ mode: modeProp,
119
+ onModeChange,
120
+ className,
121
+ style,
122
+ children,
123
+ ...props
124
+ }: React.ComponentProps<"div"> & {
125
+ defaultMode?: SidebarMode;
126
+ defaultWidth?: number;
127
+ mode?: SidebarMode;
128
+ onModeChange?: (mode: SidebarMode) => void;
129
+ }) {
130
+ const isMobile = useIsMobile();
131
+ const [openMobile, setOpenMobile] = React.useState(false);
132
+ // Server/static HTML renders the defaults; SidebarRestoreScript patches that
133
+ // markup to the saved state before paint, and the client starts from the
134
+ // same saved state so hydration matches and nothing jumps.
135
+ const [initial] = React.useState(() =>
136
+ defaultMode !== undefined || defaultWidth !== undefined
137
+ ? parseSidebarCookie(undefined)
138
+ : readSidebarCookie(),
139
+ );
140
+ const [_mode, _setMode] = React.useState<SidebarMode>(
141
+ defaultMode ?? initial.mode ?? "open",
142
+ );
143
+ const [width, _setWidth] = React.useState(
144
+ clampWidth(defaultWidth ?? initial.width ?? SIDEBAR_WIDTH),
145
+ );
146
+ const [resizing, setResizing] = React.useState(false);
147
+ const mode = modeProp ?? _mode;
148
+ // Mode to restore when a closed sidebar is shown again.
149
+ const lastVisible = React.useRef<SidebarMode>(
150
+ mode === "closed" ? "open" : mode,
151
+ );
152
+
153
+ React.useEffect(() => {
154
+ document.cookie = `${SIDEBAR_COOKIE_NAME}=${mode}:${width}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
155
+ }, [mode, width]);
156
+
157
+ const setMode = React.useCallback(
158
+ (next: SidebarMode) => {
159
+ if (next !== "closed") lastVisible.current = next;
160
+ if (onModeChange) onModeChange(next);
161
+ else _setMode(next);
162
+ },
163
+ [onModeChange],
164
+ );
165
+ const setWidth = React.useCallback(
166
+ (next: number) => _setWidth(clampWidth(next)),
167
+ [],
168
+ );
169
+
170
+ // Maps a dragged width to a mode: tiny closes, narrow shows icons, wide opens.
171
+ const resizeTo = React.useCallback(
172
+ (offset: number) => {
173
+ if (offset < SIDEBAR_CLOSE_BELOW) setMode("closed");
174
+ else if (offset < SIDEBAR_COMPACT_BELOW) setMode("compact");
175
+ else {
176
+ setMode("open");
177
+ setWidth(offset);
178
+ }
179
+ },
180
+ [setMode, setWidth],
181
+ );
182
+
183
+ const open = mode === "open";
184
+ const setOpen = React.useCallback(
185
+ (value: boolean) => setMode(value ? "open" : "compact"),
186
+ [setMode],
187
+ );
188
+
189
+ const toggleSidebar = React.useCallback(() => {
190
+ if (isMobile) return setOpenMobile((value) => !value);
191
+ setMode(mode === "closed" ? lastVisible.current : "closed");
192
+ }, [isMobile, mode, setMode]);
193
+
194
+ const toggleCompact = React.useCallback(() => {
195
+ if (isMobile) return setOpenMobile(false);
196
+ setMode(mode === "open" ? "compact" : "open");
197
+ }, [isMobile, mode, setMode]);
198
+
199
+ React.useEffect(() => {
200
+ const handleKeyDown = (event: KeyboardEvent) => {
201
+ if (
202
+ event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
203
+ (event.metaKey || event.ctrlKey)
204
+ ) {
205
+ event.preventDefault();
206
+ toggleSidebar();
207
+ }
208
+ };
209
+
210
+ window.addEventListener("keydown", handleKeyDown);
211
+ return () => window.removeEventListener("keydown", handleKeyDown);
212
+ }, [toggleSidebar]);
213
+
214
+ const state = open ? "expanded" : "collapsed";
215
+
216
+ const contextValue = React.useMemo<SidebarContextProps>(
217
+ () => ({
218
+ state,
219
+ mode,
220
+ setMode,
221
+ width,
222
+ setWidth,
223
+ resizing,
224
+ setResizing,
225
+ resizeTo,
226
+ open,
227
+ setOpen,
228
+ isMobile,
229
+ openMobile,
230
+ setOpenMobile,
231
+ toggleSidebar,
232
+ toggleCompact,
233
+ }),
234
+ [
235
+ state,
236
+ mode,
237
+ setMode,
238
+ width,
239
+ setWidth,
240
+ resizing,
241
+ resizeTo,
242
+ open,
243
+ setOpen,
244
+ isMobile,
245
+ openMobile,
246
+ toggleSidebar,
247
+ toggleCompact,
248
+ ],
249
+ );
250
+
251
+ return (
252
+ <SidebarContext.Provider value={contextValue}>
253
+ <div
254
+ data-slot="sidebar-wrapper"
255
+ data-resizing={resizing || undefined}
256
+ style={
257
+ {
258
+ "--sidebar-width": `${width}px`,
259
+ "--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
260
+ ...style,
261
+ } as React.CSSProperties
262
+ }
263
+ className={cn(
264
+ "group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar data-resizing:cursor-col-resize data-resizing:select-none",
265
+ className,
266
+ )}
267
+ {...props}
268
+ >
269
+ {children}
270
+ {defaultMode === undefined && defaultWidth === undefined ? (
271
+ <SidebarRestoreScript />
272
+ ) : null}
273
+ </div>
274
+ </SidebarContext.Provider>
275
+ );
276
+ }
277
+
278
+ function Sidebar({
279
+ side = "left",
280
+ variant = "sidebar",
281
+ collapsible = "auto",
282
+ className,
283
+ children,
284
+ dir,
285
+ ...props
286
+ }: React.ComponentProps<"div"> & {
287
+ side?: "left" | "right";
288
+ variant?: "sidebar" | "floating" | "inset";
289
+ /** `auto` follows the provider mode: compact shows icons, closed hides. */
290
+ collapsible?: "auto" | "offcanvas" | "icon" | "none";
291
+ }) {
292
+ const { isMobile, state, mode, openMobile, setOpenMobile } = useSidebar();
293
+ const messages = useUiMessages();
294
+ const collapsedAs =
295
+ collapsible === "auto"
296
+ ? mode === "closed"
297
+ ? "offcanvas"
298
+ : "icon"
299
+ : collapsible;
300
+
301
+ if (collapsible === "none") {
302
+ return (
303
+ <div
304
+ data-slot="sidebar"
305
+ className={cn(
306
+ "flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
307
+ className,
308
+ )}
309
+ {...props}
310
+ >
311
+ {children}
312
+ </div>
313
+ );
314
+ }
315
+
316
+ if (isMobile) {
317
+ return (
318
+ <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
319
+ <SheetContent
320
+ dir={dir}
321
+ data-sidebar="sidebar"
322
+ data-slot="sidebar"
323
+ data-mobile="true"
324
+ className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
325
+ style={
326
+ {
327
+ "--sidebar-width": SIDEBAR_WIDTH_MOBILE,
328
+ } as React.CSSProperties
329
+ }
330
+ side={side}
331
+ >
332
+ <SheetHeader className="sr-only">
333
+ <SheetTitle>{messages.sidebar}</SheetTitle>
334
+ <SheetDescription>{messages.sidebar}</SheetDescription>
335
+ </SheetHeader>
336
+ <div className="flex h-full w-full flex-col">{children}</div>
337
+ </SheetContent>
338
+ </Sheet>
339
+ );
340
+ }
341
+
342
+ return (
343
+ <div
344
+ className="group peer hidden text-sidebar-foreground md:block"
345
+ data-state={state}
346
+ data-collapsible={state === "collapsed" ? collapsedAs : ""}
347
+ data-variant={variant}
348
+ data-side={side}
349
+ data-slot="sidebar"
350
+ >
351
+ {/* This is what handles the sidebar gap on desktop */}
352
+ <div
353
+ data-slot="sidebar-gap"
354
+ className={cn(
355
+ "relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear group-data-resizing/sidebar-wrapper:transition-none",
356
+ "group-data-[collapsible=offcanvas]:w-0",
357
+ "group-data-[side=right]:rotate-180",
358
+ variant === "floating" || variant === "inset"
359
+ ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
360
+ : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
361
+ )}
362
+ />
363
+ <div
364
+ data-slot="sidebar-container"
365
+ data-side={side}
366
+ className={cn(
367
+ "fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear group-data-resizing/sidebar-wrapper:transition-none data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
368
+ // Adjust the padding for floating and inset variants.
369
+ variant === "floating" || variant === "inset"
370
+ ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
371
+ : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
372
+ className,
373
+ )}
374
+ {...props}
375
+ >
376
+ <div
377
+ data-sidebar="sidebar"
378
+ data-slot="sidebar-inner"
379
+ className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-2xl group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
380
+ >
381
+ {children}
382
+ </div>
383
+ </div>
384
+ </div>
385
+ );
386
+ }
387
+
388
+ const isMac = () =>
389
+ typeof navigator !== "undefined" &&
390
+ /Mac|iP(hone|ad)/.test(navigator.platform);
391
+
392
+ /**
393
+ * Wraps a sidebar control with a tooltip (label + shortcut hint) and a
394
+ * right-click menu to pick the mode directly: expanded, compact or hidden.
395
+ */
396
+ function SidebarModeControl({
397
+ label,
398
+ children,
399
+ }: {
400
+ label: string;
401
+ children: React.ReactElement;
402
+ }) {
403
+ const { mode, setMode, isMobile } = useSidebar();
404
+ const messages = useUiMessages();
405
+ const shortcut = `${isMac() ? "⌘" : "Ctrl+"}${SIDEBAR_KEYBOARD_SHORTCUT.toUpperCase()}`;
406
+ // Clicking changes the sidebar size and moves this control, so the tooltip
407
+ // closes on press and stays closed until the pointer leaves.
408
+ const [tooltipOpen, setTooltipOpen] = React.useState(false);
409
+ const suppressed = React.useRef(false);
410
+
411
+ if (isMobile) return children;
412
+
413
+ return (
414
+ <ContextMenu>
415
+ <Tooltip
416
+ open={tooltipOpen}
417
+ onOpenChange={(open) => setTooltipOpen(open && !suppressed.current)}
418
+ >
419
+ <ContextMenuTrigger
420
+ render={
421
+ <TooltipTrigger
422
+ render={children}
423
+ delay={600}
424
+ onPointerDown={() => {
425
+ suppressed.current = true;
426
+ setTooltipOpen(false);
427
+ }}
428
+ onPointerLeave={() => {
429
+ suppressed.current = false;
430
+ }}
431
+ />
432
+ }
433
+ />
434
+ <TooltipContent side="right" className="flex flex-col gap-0.5">
435
+ <span>{label}</span>
436
+ <span className="opacity-70">{messages.sidebarHint(shortcut)}</span>
437
+ </TooltipContent>
438
+ </Tooltip>
439
+ <ContextMenuContent className="min-w-44">
440
+ <ContextMenuRadioGroup
441
+ value={mode}
442
+ onValueChange={(value) => setMode(value as SidebarMode)}
443
+ >
444
+ <ContextMenuRadioItem value="open">
445
+ {messages.modeOpen}
446
+ </ContextMenuRadioItem>
447
+ <ContextMenuRadioItem value="compact">
448
+ {messages.modeCompact}
449
+ </ContextMenuRadioItem>
450
+ <ContextMenuRadioItem value="closed">
451
+ {messages.modeClosed}
452
+ <ContextMenuShortcut>{shortcut}</ContextMenuShortcut>
453
+ </ContextMenuRadioItem>
454
+ </ContextMenuRadioGroup>
455
+ </ContextMenuContent>
456
+ </ContextMenu>
457
+ );
458
+ }
459
+
460
+ function SidebarTrigger({
461
+ className,
462
+ onClick,
463
+ ...props
464
+ }: React.ComponentProps<typeof Button>) {
465
+ const { toggleSidebar } = useSidebar();
466
+ const messages = useUiMessages();
467
+
468
+ return (
469
+ <SidebarModeControl label={messages.toggleSidebar}>
470
+ <Button
471
+ data-sidebar="trigger"
472
+ data-slot="sidebar-trigger"
473
+ variant="ghost"
474
+ size="icon-sm"
475
+ className={cn(className)}
476
+ aria-label={messages.toggleSidebar}
477
+ onClick={(event) => {
478
+ onClick?.(event);
479
+ toggleSidebar();
480
+ }}
481
+ {...props}
482
+ >
483
+ <PanelLeftIcon />
484
+ </Button>
485
+ </SidebarModeControl>
486
+ );
487
+ }
488
+
489
+ /** Collapse control meant to live inside the sidebar: toggles open ↔ compact. */
490
+ function SidebarCollapse({
491
+ className,
492
+ onClick,
493
+ ...props
494
+ }: React.ComponentProps<typeof Button>) {
495
+ const { mode, toggleCompact, isMobile } = useSidebar();
496
+ const messages = useUiMessages();
497
+ const label =
498
+ mode === "open" || isMobile
499
+ ? messages.collapseSidebar
500
+ : messages.expandSidebar;
501
+
502
+ return (
503
+ <SidebarModeControl label={label}>
504
+ <Button
505
+ data-sidebar="collapse"
506
+ data-slot="sidebar-collapse"
507
+ variant="ghost"
508
+ size="icon-sm"
509
+ className={cn(className)}
510
+ // Stable label/icons (CSS-switched) so the pre-paint restored state
511
+ // hydrates without mismatches.
512
+ aria-label={messages.toggleSidebar}
513
+ onClick={(event) => {
514
+ onClick?.(event);
515
+ toggleCompact();
516
+ }}
517
+ {...props}
518
+ >
519
+ <PanelLeftCloseIcon className="group-data-[collapsible=icon]:hidden" />
520
+ <PanelLeftOpenIcon className="hidden group-data-[collapsible=icon]:block" />
521
+ </Button>
522
+ </SidebarModeControl>
523
+ );
524
+ }
525
+
526
+ /**
527
+ * Drag handle on the sidebar edge. Dragging resizes the open sidebar and
528
+ * switches modes by width (closed → compact → open); a click toggles
529
+ * open ↔ compact; arrow keys resize from the keyboard.
530
+ */
531
+ function SidebarRail({ className, ...props }: React.ComponentProps<"div">) {
532
+ const { mode, width, resizeTo, setResizing, toggleCompact } = useSidebar();
533
+ const messages = useUiMessages();
534
+ const drag = React.useRef<{
535
+ startX: number;
536
+ startOffset: number;
537
+ side: "left" | "right";
538
+ moved: boolean;
539
+ } | null>(null);
540
+ const offset = mode === "open" ? width : mode === "compact" ? 48 : 0;
541
+
542
+ return (
543
+ <div
544
+ role="separator"
545
+ aria-orientation="vertical"
546
+ aria-label={messages.resizeSidebar}
547
+ aria-valuenow={offset}
548
+ aria-valuemin={0}
549
+ aria-valuemax={SIDEBAR_WIDTH_MAX}
550
+ tabIndex={0}
551
+ data-sidebar="rail"
552
+ data-slot="sidebar-rail"
553
+ onPointerDown={(event) => {
554
+ if (event.button !== 0) return;
555
+ event.currentTarget.setPointerCapture(event.pointerId);
556
+ const side =
557
+ event.currentTarget.closest<HTMLElement>("[data-side]")?.dataset
558
+ .side === "right"
559
+ ? "right"
560
+ : "left";
561
+ drag.current = {
562
+ startX: event.clientX,
563
+ startOffset: offset,
564
+ side,
565
+ moved: false,
566
+ };
567
+ }}
568
+ onPointerMove={(event) => {
569
+ const current = drag.current;
570
+ if (!current) return;
571
+ const delta =
572
+ (event.clientX - current.startX) * (current.side === "left" ? 1 : -1);
573
+ if (!current.moved && Math.abs(delta) < 4) return;
574
+ if (!current.moved) setResizing(true);
575
+ current.moved = true;
576
+ resizeTo(current.startOffset + delta);
577
+ }}
578
+ onPointerUp={(event) => {
579
+ event.currentTarget.releasePointerCapture(event.pointerId);
580
+ if (drag.current && !drag.current.moved) toggleCompact();
581
+ drag.current = null;
582
+ setResizing(false);
583
+ }}
584
+ onPointerCancel={() => {
585
+ drag.current = null;
586
+ setResizing(false);
587
+ }}
588
+ onKeyDown={(event) => {
589
+ const step = event.shiftKey ? 64 : 16;
590
+ if (event.key === "ArrowRight") resizeTo(offset + step);
591
+ else if (event.key === "ArrowLeft") resizeTo(offset - step);
592
+ else if (event.key === "Enter" || event.key === " ") toggleCompact();
593
+ else return;
594
+ event.preventDefault();
595
+ }}
596
+ className={cn(
597
+ "absolute inset-y-0 z-20 hidden w-4 touch-none cursor-col-resize outline-none group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] after:transition-colors hover:after:bg-sidebar-border focus-visible:after:bg-sidebar-ring sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
598
+ "group-data-resizing/sidebar-wrapper:after:bg-sidebar-ring",
599
+ "group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
600
+ "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
601
+ "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
602
+ className,
603
+ )}
604
+ {...props}
605
+ />
606
+ );
607
+ }
608
+
609
+ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
610
+ return (
611
+ <main
612
+ data-slot="sidebar-inset"
613
+ className={cn(
614
+ "relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-2xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
615
+ className,
616
+ )}
617
+ {...props}
618
+ />
619
+ );
620
+ }
621
+
622
+ function SidebarInput({
623
+ className,
624
+ ...props
625
+ }: React.ComponentProps<typeof Input>) {
626
+ return (
627
+ <Input
628
+ data-slot="sidebar-input"
629
+ data-sidebar="input"
630
+ className={cn("h-8 w-full bg-input/50 shadow-none", className)}
631
+ {...props}
632
+ />
633
+ );
634
+ }
635
+
636
+ function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
637
+ return (
638
+ <div
639
+ data-slot="sidebar-header"
640
+ data-sidebar="header"
641
+ className={cn(
642
+ "flex flex-col gap-2 p-2 [--radius:var(--radius-xl)]",
643
+ className,
644
+ )}
645
+ {...props}
646
+ />
647
+ );
648
+ }
649
+
650
+ function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
651
+ return (
652
+ <div
653
+ data-slot="sidebar-footer"
654
+ data-sidebar="footer"
655
+ className={cn(
656
+ "flex flex-col gap-2 p-2 [--radius:var(--radius-xl)]",
657
+ className,
658
+ )}
659
+ {...props}
660
+ />
661
+ );
662
+ }
663
+
664
+ function SidebarSeparator({
665
+ className,
666
+ ...props
667
+ }: React.ComponentProps<typeof Separator>) {
668
+ return (
669
+ <Separator
670
+ data-slot="sidebar-separator"
671
+ data-sidebar="separator"
672
+ className={cn("mx-2 w-auto bg-sidebar-border", className)}
673
+ {...props}
674
+ />
675
+ );
676
+ }
677
+
678
+ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
679
+ return (
680
+ <div
681
+ data-slot="sidebar-content"
682
+ data-sidebar="content"
683
+ className={cn(
684
+ "no-scrollbar flex min-h-0 flex-1 flex-col gap-2 overflow-auto scroll-fade [--radius:var(--radius-xl)] group-data-[collapsible=icon]:overflow-hidden",
685
+ className,
686
+ )}
687
+ {...props}
688
+ />
689
+ );
690
+ }
691
+
692
+ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
693
+ return (
694
+ <div
695
+ data-slot="sidebar-group"
696
+ data-sidebar="group"
697
+ className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
698
+ {...props}
699
+ />
700
+ );
701
+ }
702
+
703
+ function SidebarGroupLabel({
704
+ className,
705
+ render,
706
+ ...props
707
+ }: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
708
+ return useRender({
709
+ defaultTagName: "div",
710
+ props: mergeProps<"div">(
711
+ {
712
+ className: cn(
713
+ "flex h-8 shrink-0 items-center rounded-xl px-3 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
714
+ className,
715
+ ),
716
+ },
717
+ props,
718
+ ),
719
+ render,
720
+ state: {
721
+ slot: "sidebar-group-label",
722
+ sidebar: "group-label",
723
+ },
724
+ });
725
+ }
726
+
727
+ function SidebarGroupAction({
728
+ className,
729
+ render,
730
+ ...props
731
+ }: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
732
+ return useRender({
733
+ defaultTagName: "button",
734
+ props: mergeProps<"button">(
735
+ {
736
+ className: cn(
737
+ "absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-xl p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
738
+ className,
739
+ ),
740
+ },
741
+ props,
742
+ ),
743
+ render,
744
+ state: {
745
+ slot: "sidebar-group-action",
746
+ sidebar: "group-action",
747
+ },
748
+ });
749
+ }
750
+
751
+ function SidebarGroupContent({
752
+ className,
753
+ ...props
754
+ }: React.ComponentProps<"div">) {
755
+ return (
756
+ <div
757
+ data-slot="sidebar-group-content"
758
+ data-sidebar="group-content"
759
+ className={cn("w-full text-sm", className)}
760
+ {...props}
761
+ />
762
+ );
763
+ }
764
+
765
+ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
766
+ return (
767
+ <ul
768
+ data-slot="sidebar-menu"
769
+ data-sidebar="menu"
770
+ className={cn("flex w-full min-w-0 flex-col gap-0.5", className)}
771
+ {...props}
772
+ />
773
+ );
774
+ }
775
+
776
+ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
777
+ return (
778
+ <li
779
+ data-slot="sidebar-menu-item"
780
+ data-sidebar="menu-item"
781
+ className={cn("group/menu-item relative", className)}
782
+ {...props}
783
+ />
784
+ );
785
+ }
786
+
787
+ const sidebarMenuButtonVariants = cva(
788
+ "peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-xl px-3 py-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
789
+ {
790
+ variants: {
791
+ variant: {
792
+ default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
793
+ outline:
794
+ "bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
795
+ },
796
+ size: {
797
+ default: "h-9 text-sm",
798
+ sm: "h-8 text-xs",
799
+ // Compact identity marks fill the button and cover its hover background.
800
+ // Paint feedback above the mark so images and opaque avatars respond too.
801
+ lg: "h-14 px-3 text-sm group-data-[collapsible=icon]:p-0! group-data-[collapsible=icon]:relative group-data-[collapsible=icon]:after:pointer-events-none group-data-[collapsible=icon]:after:absolute group-data-[collapsible=icon]:after:inset-0 group-data-[collapsible=icon]:after:rounded-[inherit] group-data-[collapsible=icon]:after:bg-sidebar-foreground/10 group-data-[collapsible=icon]:after:opacity-0 group-data-[collapsible=icon]:after:transition-opacity group-data-[collapsible=icon]:hover:after:opacity-100 group-data-[collapsible=icon]:data-popup-open:after:opacity-100",
802
+ },
803
+ },
804
+ defaultVariants: {
805
+ variant: "default",
806
+ size: "default",
807
+ },
808
+ },
809
+ );
810
+
811
+ function SidebarMenuButton({
812
+ render,
813
+ isActive = false,
814
+ variant = "default",
815
+ size = "default",
816
+ tooltip,
817
+ className,
818
+ ...props
819
+ }: useRender.ComponentProps<"button"> &
820
+ React.ComponentProps<"button"> & {
821
+ isActive?: boolean;
822
+ tooltip?: string | React.ComponentProps<typeof TooltipContent>;
823
+ } & VariantProps<typeof sidebarMenuButtonVariants>) {
824
+ const { isMobile, state } = useSidebar();
825
+ const comp = useRender({
826
+ defaultTagName: "button",
827
+ props: mergeProps<"button">(
828
+ {
829
+ className: cn(sidebarMenuButtonVariants({ variant, size }), className),
830
+ },
831
+ props,
832
+ ),
833
+ render: !tooltip ? render : <TooltipTrigger render={render} />,
834
+ state: {
835
+ slot: "sidebar-menu-button",
836
+ sidebar: "menu-button",
837
+ size,
838
+ active: isActive,
839
+ },
840
+ });
841
+
842
+ if (!tooltip) {
843
+ return comp;
844
+ }
845
+
846
+ if (typeof tooltip === "string") {
847
+ tooltip = {
848
+ children: tooltip,
849
+ };
850
+ }
851
+
852
+ return (
853
+ <Tooltip>
854
+ {comp}
855
+ <TooltipContent
856
+ side="right"
857
+ align="center"
858
+ hidden={state !== "collapsed" || isMobile}
859
+ {...tooltip}
860
+ />
861
+ </Tooltip>
862
+ );
863
+ }
864
+
865
+ function SidebarMenuAction({
866
+ className,
867
+ render,
868
+ showOnHover = false,
869
+ ...props
870
+ }: useRender.ComponentProps<"button"> &
871
+ React.ComponentProps<"button"> & {
872
+ showOnHover?: boolean;
873
+ }) {
874
+ return useRender({
875
+ defaultTagName: "button",
876
+ props: mergeProps<"button">(
877
+ {
878
+ className: cn(
879
+ "absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-xl p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-2 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
880
+ showOnHover &&
881
+ "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
882
+ className,
883
+ ),
884
+ },
885
+ props,
886
+ ),
887
+ render,
888
+ state: {
889
+ slot: "sidebar-menu-action",
890
+ sidebar: "menu-action",
891
+ },
892
+ });
893
+ }
894
+
895
+ function SidebarMenuBadge({
896
+ className,
897
+ ...props
898
+ }: React.ComponentProps<"div">) {
899
+ return (
900
+ <div
901
+ data-slot="sidebar-menu-badge"
902
+ data-sidebar="menu-badge"
903
+ className={cn(
904
+ // Majapi UI: vertically centered for every button size (upstream offsets
905
+ // assume 32px buttons) and aligned with the button's inner padding.
906
+ "pointer-events-none absolute top-1/2 right-2 flex h-5 min-w-5 -translate-y-1/2 items-center justify-center rounded-xl px-1 text-xs font-medium text-muted-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-active/menu-button:text-sidebar-accent-foreground",
907
+ className,
908
+ )}
909
+ {...props}
910
+ />
911
+ );
912
+ }
913
+
914
+ function SidebarMenuSkeleton({
915
+ className,
916
+ showIcon = false,
917
+ ...props
918
+ }: React.ComponentProps<"div"> & {
919
+ showIcon?: boolean;
920
+ }) {
921
+ // Random width between 50 to 90%.
922
+ const [width] = React.useState(() => {
923
+ return `${Math.floor(Math.random() * 40) + 50}%`;
924
+ });
925
+
926
+ return (
927
+ <div
928
+ data-slot="sidebar-menu-skeleton"
929
+ data-sidebar="menu-skeleton"
930
+ className={cn("flex h-8 items-center gap-2 rounded-xl px-2", className)}
931
+ {...props}
932
+ >
933
+ {showIcon && (
934
+ <Skeleton
935
+ className="size-4 rounded-xl"
936
+ data-sidebar="menu-skeleton-icon"
937
+ />
938
+ )}
939
+ <Skeleton
940
+ className="h-4 max-w-(--skeleton-width) flex-1"
941
+ data-sidebar="menu-skeleton-text"
942
+ style={
943
+ {
944
+ "--skeleton-width": width,
945
+ } as React.CSSProperties
946
+ }
947
+ />
948
+ </div>
949
+ );
950
+ }
951
+
952
+ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
953
+ return (
954
+ <ul
955
+ data-slot="sidebar-menu-sub"
956
+ data-sidebar="menu-sub"
957
+ className={cn(
958
+ "mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
959
+ className,
960
+ )}
961
+ {...props}
962
+ />
963
+ );
964
+ }
965
+
966
+ function SidebarMenuSubItem({
967
+ className,
968
+ ...props
969
+ }: React.ComponentProps<"li">) {
970
+ return (
971
+ <li
972
+ data-slot="sidebar-menu-sub-item"
973
+ data-sidebar="menu-sub-item"
974
+ className={cn("group/menu-sub-item relative", className)}
975
+ {...props}
976
+ />
977
+ );
978
+ }
979
+
980
+ function SidebarMenuSubButton({
981
+ render,
982
+ size = "md",
983
+ isActive = false,
984
+ className,
985
+ ...props
986
+ }: useRender.ComponentProps<"a"> &
987
+ React.ComponentProps<"a"> & {
988
+ size?: "sm" | "md";
989
+ isActive?: boolean;
990
+ }) {
991
+ return useRender({
992
+ defaultTagName: "a",
993
+ props: mergeProps<"a">(
994
+ {
995
+ className: cn(
996
+ "flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-xl px-3 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
997
+ className,
998
+ ),
999
+ },
1000
+ props,
1001
+ ),
1002
+ render,
1003
+ state: {
1004
+ slot: "sidebar-menu-sub-button",
1005
+ sidebar: "menu-sub-button",
1006
+ size,
1007
+ active: isActive,
1008
+ },
1009
+ });
1010
+ }
1011
+
1012
+ export {
1013
+ Sidebar,
1014
+ SidebarContent,
1015
+ SidebarFooter,
1016
+ SidebarGroup,
1017
+ SidebarGroupAction,
1018
+ SidebarGroupContent,
1019
+ SidebarGroupLabel,
1020
+ SidebarHeader,
1021
+ SidebarInput,
1022
+ SidebarInset,
1023
+ SidebarMenu,
1024
+ SidebarMenuAction,
1025
+ SidebarMenuBadge,
1026
+ SidebarMenuButton,
1027
+ SidebarMenuItem,
1028
+ SidebarMenuSkeleton,
1029
+ SidebarMenuSub,
1030
+ SidebarMenuSubButton,
1031
+ SidebarMenuSubItem,
1032
+ SidebarCollapse,
1033
+ SidebarProvider,
1034
+ SidebarRail,
1035
+ SidebarSeparator,
1036
+ SidebarTrigger,
1037
+ parseSidebarCookie,
1038
+ useSidebar,
1039
+ };