@vvedantb/ui 0.5.4 → 0.6.1

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.
@@ -0,0 +1,295 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useEffect,
5
+ useId,
6
+ type ComponentProps,
7
+ type KeyboardEvent,
8
+ type ReactNode,
9
+ } from "react";
10
+ import { AnimatePresence, motion, type HTMLMotionProps, type PanInfo } from "motion/react";
11
+ import { IconMenu2 } from "@tabler/icons-react";
12
+ import { Button, type ButtonProps } from "../../src/ui/button";
13
+ import { motionEase, motionTiming } from "../../src/motion/presets";
14
+ import { cn } from "../../src/utils/cn";
15
+ import { mobileMenuButtonClass } from "./app-shell-classes";
16
+ import { SidebarResizeHandle, useSidebarResizeContext, type SidebarResizeHandleProps } from "./SidebarResize";
17
+
18
+ type SidebarContextValue = {
19
+ isDesktop: boolean;
20
+ mobileOpen: boolean;
21
+ onMobileOpenChange: (open: boolean) => void;
22
+ collapsed: boolean;
23
+ panelHidden: boolean;
24
+ drawerId: string;
25
+ };
26
+
27
+ const SidebarContext = createContext<SidebarContextValue | null>(null);
28
+
29
+ /** The nearest `Sidebar.Root`, or `null`. */
30
+ export function useOptionalSidebar(): SidebarContextValue | null {
31
+ return useContext(SidebarContext);
32
+ }
33
+
34
+ function useSidebar(part: string): SidebarContextValue {
35
+ const sidebar = useContext(SidebarContext);
36
+ if (!sidebar) throw new Error(`Sidebar.${part} must be inside Sidebar.Root`);
37
+ return sidebar;
38
+ }
39
+
40
+ export type SidebarRootProps = {
41
+ /** From the app's desktop media query (for example `(min-width: 768px)`). */
42
+ isDesktop: boolean;
43
+ /** Mobile drawer state. Owned by the app (which also closes it on route change). */
44
+ mobileOpen: boolean;
45
+ onMobileOpenChange: (open: boolean) => void;
46
+ /** Desktop: show only the rail. `Sidebar.Panel` hides itself (`md:hidden`). Default `false`. */
47
+ collapsed?: boolean;
48
+ /** The current section has no panel: rail-wide on every viewport, `Sidebar.Panel` renders nothing. Default `false`. */
49
+ panelHidden?: boolean;
50
+ /** `id` of the drawer (`Sidebar.Aside`), used by the menu button `aria-controls`. Default from `useId`. */
51
+ id?: string;
52
+ children?: ReactNode;
53
+ };
54
+
55
+ /** Sidebar state for the other parts. Renders no DOM. Closes the drawer when the desktop breakpoint is reached. */
56
+ function Root({
57
+ isDesktop,
58
+ mobileOpen,
59
+ onMobileOpenChange,
60
+ collapsed = false,
61
+ panelHidden = false,
62
+ id,
63
+ children,
64
+ }: SidebarRootProps) {
65
+ const autoId = useId();
66
+
67
+ // Reaching the desktop breakpoint closes the drawer, so it does not reopen
68
+ // on the way back down. Runs on breakpoint change only.
69
+ useEffect(() => {
70
+ if (isDesktop && mobileOpen) onMobileOpenChange(false);
71
+ }, [isDesktop]);
72
+
73
+ return (
74
+ <SidebarContext.Provider
75
+ value={{ isDesktop, mobileOpen, onMobileOpenChange, collapsed, panelHidden, drawerId: id ?? autoId }}
76
+ >
77
+ {children}
78
+ </SidebarContext.Provider>
79
+ );
80
+ }
81
+
82
+ function closeOnEscape(sidebar: SidebarContextValue, event: KeyboardEvent) {
83
+ if (event.key === "Escape" && !sidebar.isDesktop && sidebar.mobileOpen) {
84
+ sidebar.onMobileOpenChange(false);
85
+ }
86
+ }
87
+
88
+ export type SidebarMobileHeaderProps = ComponentProps<"header">;
89
+
90
+ /**
91
+ * Fixed header below `md`. Escape inside it closes the drawer. Three columns
92
+ * keep the title centred and stop it overlapping the actions: it truncates instead.
93
+ */
94
+ function MobileHeader({ className, onKeyDown, ...props }: SidebarMobileHeaderProps) {
95
+ const sidebar = useSidebar("MobileHeader");
96
+ return (
97
+ <header
98
+ {...props}
99
+ onKeyDown={(event) => {
100
+ onKeyDown?.(event);
101
+ closeOnEscape(sidebar, event);
102
+ }}
103
+ className={cn(
104
+ "fixed inset-x-0 top-0 z-30 grid h-[var(--app-shell-mobile-header-height)] grid-cols-[minmax(max-content,1fr)_minmax(0,auto)_minmax(max-content,1fr)] items-center gap-2 bg-surface px-3 pt-[env(safe-area-inset-top,0px)] pl-[max(0.75rem,env(safe-area-inset-left))] pr-[max(0.75rem,env(safe-area-inset-right))] md:hidden",
105
+ className,
106
+ )}
107
+ />
108
+ );
109
+ }
110
+
111
+ const defaultMobileMenuIcon = () => <IconMenu2 size={20} />;
112
+
113
+ export type SidebarMobileMenuButtonProps = Omit<ButtonProps, "children"> & {
114
+ /** `aria-label`. Default `"Open navigation menu"`. */
115
+ label?: string;
116
+ /** Icon for the drawer state. Default a 20px menu icon. */
117
+ icon?: (open: boolean) => ReactNode;
118
+ };
119
+
120
+ /** Opens the drawer. */
121
+ function MobileMenuButton({
122
+ label = "Open navigation menu",
123
+ icon = defaultMobileMenuIcon,
124
+ className,
125
+ onClick,
126
+ ...props
127
+ }: SidebarMobileMenuButtonProps) {
128
+ const sidebar = useSidebar("MobileMenuButton");
129
+ return (
130
+ <Button
131
+ type="button"
132
+ variant="ghost"
133
+ size="icon"
134
+ {...props}
135
+ onClick={(event) => {
136
+ onClick?.(event);
137
+ sidebar.onMobileOpenChange(true);
138
+ }}
139
+ aria-label={label}
140
+ aria-expanded={sidebar.mobileOpen}
141
+ aria-controls={sidebar.drawerId}
142
+ className={cn(mobileMenuButtonClass, "justify-self-start", className)}
143
+ >
144
+ {icon(sidebar.mobileOpen)}
145
+ </Button>
146
+ );
147
+ }
148
+
149
+ export type SidebarMobileTitleProps = ComponentProps<"h1">;
150
+
151
+ /** Centred title in the mobile header. Without children, an empty `<span>` keeps the middle column. */
152
+ function MobileTitle({ className, children, ...props }: SidebarMobileTitleProps) {
153
+ if (!children) return <span aria-hidden="true" />;
154
+ return (
155
+ <h1
156
+ {...props}
157
+ className={cn(
158
+ "pointer-events-none min-w-0 truncate text-center text-base font-semibold leading-tight tracking-tight text-foreground",
159
+ className,
160
+ )}
161
+ >
162
+ {children}
163
+ </h1>
164
+ );
165
+ }
166
+
167
+ export type SidebarMobileActionsProps = ComponentProps<"div">;
168
+
169
+ /** Trailing actions in the mobile header. Without children, a menu-button-sized spacer keeps the title centred. */
170
+ function MobileActions({ className, children, ...props }: SidebarMobileActionsProps) {
171
+ if (!children) {
172
+ return <div {...props} className={cn("h-11 w-11 shrink-0 justify-self-end", className)} aria-hidden="true" />;
173
+ }
174
+ return (
175
+ <div
176
+ data-app-sidebar-mobile-actions=""
177
+ {...props}
178
+ className={cn("flex shrink-0 items-center justify-self-end gap-2", className)}
179
+ >
180
+ {children}
181
+ </div>
182
+ );
183
+ }
184
+
185
+ export type SidebarBackdropProps = Omit<HTMLMotionProps<"button">, "children"> & {
186
+ /** `aria-label`. Default `"Close navigation"`. */
187
+ label?: string;
188
+ };
189
+
190
+ /** Drawer backdrop below `md`; a click closes the drawer. Fades in and out. */
191
+ function Backdrop({ label = "Close navigation", className, onClick, ...props }: SidebarBackdropProps) {
192
+ const sidebar = useSidebar("Backdrop");
193
+ return (
194
+ <AnimatePresence initial={false}>
195
+ {sidebar.mobileOpen && !sidebar.isDesktop ? (
196
+ <motion.button
197
+ type="button"
198
+ aria-label={label}
199
+ {...props}
200
+ className={cn("fixed inset-0 z-40 bg-backdrop/80 md:hidden", className)}
201
+ onClick={(event) => {
202
+ onClick?.(event);
203
+ sidebar.onMobileOpenChange(false);
204
+ }}
205
+ initial={{ opacity: 0 }}
206
+ animate={{ opacity: 1 }}
207
+ exit={{ opacity: 0 }}
208
+ transition={{ duration: motionTiming.sidebar, ease: motionEase }}
209
+ />
210
+ ) : null}
211
+ </AnimatePresence>
212
+ );
213
+ }
214
+
215
+ export type SidebarAsideProps = HTMLMotionProps<"aside">;
216
+
217
+ /**
218
+ * The `<aside>`: fixed on desktop, a swipe-to-close drawer below it. Width
219
+ * reads the `--app-shell-*` variables. `id` overrides the Root drawer id
220
+ * (keep them equal so `aria-controls` points here).
221
+ */
222
+ function Aside({ id, className, onKeyDown, children, ...props }: SidebarAsideProps) {
223
+ const sidebar = useSidebar("Aside");
224
+ const resize = useSidebarResizeContext();
225
+ const { isDesktop, mobileOpen, collapsed, panelHidden } = sidebar;
226
+
227
+ const handleDrawerDragEnd = (_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
228
+ if (isDesktop || !mobileOpen) return;
229
+ if (info.offset.x < -72 || info.velocity.x < -500) {
230
+ sidebar.onMobileOpenChange(false);
231
+ }
232
+ };
233
+
234
+ return (
235
+ <motion.aside
236
+ id={id ?? sidebar.drawerId}
237
+ data-sidebar-layout={isDesktop ? "desktop" : "drawer"}
238
+ {...props}
239
+ inert={!isDesktop && !mobileOpen}
240
+ onKeyDown={(event) => {
241
+ onKeyDown?.(event);
242
+ closeOnEscape(sidebar, event);
243
+ }}
244
+ className={cn(
245
+ "fixed inset-y-0 left-0 z-50 flex bg-background pb-[env(safe-area-inset-bottom,0px)] pt-[env(safe-area-inset-top,0px)] md:py-0",
246
+ panelHidden
247
+ ? "w-[var(--app-shell-rail-width)]"
248
+ : "w-[min(var(--app-shell-sidebar-width),calc(100vw-1.5rem))]",
249
+ collapsed || panelHidden
250
+ ? "md:w-[var(--app-shell-rail-width)]"
251
+ : "md:w-[var(--app-shell-sidebar-width)]",
252
+ !resize?.isResizing &&
253
+ "md:transition-[width] md:[transition-duration:280ms] md:[transition-timing-function:cubic-bezier(0.22,1,0.36,1)]",
254
+ className,
255
+ )}
256
+ initial={false}
257
+ animate={{ x: isDesktop || mobileOpen ? 0 : "-100%" }}
258
+ transition={{ duration: motionTiming.sidebar, ease: motionEase }}
259
+ drag={!isDesktop && mobileOpen ? "x" : false}
260
+ dragDirectionLock
261
+ dragConstraints={{ left: 0, right: 0 }}
262
+ dragElastic={{ left: 0.45, right: 0.08 }}
263
+ onDragEnd={handleDrawerDragEnd}
264
+ >
265
+ {children}
266
+ </motion.aside>
267
+ );
268
+ }
269
+
270
+ export type SidebarResizeHandlePartProps = SidebarResizeHandleProps;
271
+
272
+ /**
273
+ * `SidebarResizeHandle` inside a resizable `AppShell`. Under a `Sidebar.Root`
274
+ * it shows only on desktop while the panel shows (not collapsed or hidden).
275
+ * Place it inside `Sidebar.Aside`.
276
+ */
277
+ function ResizeHandle(props: SidebarResizeHandlePartProps) {
278
+ const sidebar = useOptionalSidebar();
279
+ const resize = useSidebarResizeContext();
280
+ const show =
281
+ resize !== null &&
282
+ (sidebar === null || (sidebar.isDesktop && !sidebar.collapsed && !sidebar.panelHidden));
283
+ return show ? <SidebarResizeHandle {...props} /> : null;
284
+ }
285
+
286
+ export const SidebarChromeParts = {
287
+ Root,
288
+ MobileHeader,
289
+ MobileMenuButton,
290
+ MobileTitle,
291
+ MobileActions,
292
+ Backdrop,
293
+ Aside,
294
+ ResizeHandle,
295
+ };
@@ -1,5 +1,7 @@
1
1
  import type { ReactNode } from "react";
2
+ import { sidebarHeaderRowClass } from "./app-shell-classes";
2
3
  import { cn } from "./cn.js";
4
+ import { PanelActions, PanelTitle } from "./SidebarPanel";
3
5
 
4
6
  export type SidebarHeaderProps = {
5
7
  title: string;
@@ -7,15 +9,12 @@ export type SidebarHeaderProps = {
7
9
  className?: string;
8
10
  };
9
11
 
12
+ /** Header row: a title and optional trailing actions. Inside a panel, compose `Sidebar.PanelHeader` instead. */
10
13
  export function SidebarHeader({ title, trailing, className }: SidebarHeaderProps) {
11
14
  return (
12
- <div className={cn("flex h-11 items-center gap-1 pl-3 pr-1", className)}>
13
- <h2 className="min-w-0 flex-1 truncate text-left text-base font-semibold leading-none tracking-tight text-foreground">
14
- {title}
15
- </h2>
16
- {trailing ? (
17
- <div className="flex items-center justify-end gap-0.5">{trailing}</div>
18
- ) : null}
15
+ <div className={cn(sidebarHeaderRowClass, className)}>
16
+ <PanelTitle>{title}</PanelTitle>
17
+ {trailing ? <PanelActions>{trailing}</PanelActions> : null}
19
18
  </div>
20
19
  );
21
20
  }
@@ -1,19 +1,19 @@
1
- import type { ReactNode } from "react";
1
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
2
2
  import { IconX } from "@tabler/icons-react";
3
- import { Button } from "../../src/ui/button";
3
+ import { Button, type ButtonProps } from "../../src/ui/button";
4
4
  import { cn } from "../../src/utils/cn";
5
- import { panelCloseButtonClass } from "./app-shell-classes";
6
- import { SidebarHeader } from "./SidebarHeader";
5
+ import { panelCloseButtonClass, sidebarHeaderRowClass } from "./app-shell-classes";
6
+ import { useOptionalSidebar } from "./SidebarChrome";
7
7
 
8
8
  export type SidebarPanelProps = {
9
9
  title: string;
10
- /** Rendered in the header, before the close button. */
10
+ /** Legacy (still supported): rendered in the header, before the close button. Compose `Sidebar.PanelActions`. */
11
11
  headerTrailing?: ReactNode;
12
- /** When set, a close button follows `headerTrailing`. Pass it on the mobile drawer only. */
12
+ /** Legacy (still supported): when set, a close button follows `headerTrailing`. Pass it on the mobile drawer only. */
13
13
  onClose?: () => void;
14
- /** Close button label. Default `"Close navigation"`. */
14
+ /** Legacy (still supported): close button label. Default `"Close navigation"`. */
15
15
  closeLabel?: string;
16
- /** Merged onto the close button. */
16
+ /** Legacy (still supported): merged onto the close button. */
17
17
  closeButtonClassName?: string;
18
18
  /** Desktop: hides the panel (`md:hidden`) and leaves the rail. */
19
19
  collapsed?: boolean;
@@ -22,50 +22,112 @@ export type SidebarPanelProps = {
22
22
  children?: ReactNode;
23
23
  };
24
24
 
25
- /** Sidebar panel column beside the rail: a `SidebarHeader` and the app's body. */
25
+ /** Sidebar panel column beside the rail: a header and the app's body. Composed from `Sidebar.Panel` parts. */
26
26
  export function SidebarPanel({
27
27
  title,
28
28
  headerTrailing,
29
29
  onClose,
30
- closeLabel = "Close navigation",
30
+ closeLabel,
31
31
  closeButtonClassName,
32
32
  collapsed = false,
33
33
  className,
34
34
  children,
35
35
  }: SidebarPanelProps) {
36
- const trailing =
37
- headerTrailing || onClose ? (
38
- <>
39
- {headerTrailing}
40
- {onClose ? (
41
- <Button
42
- type="button"
43
- variant="ghost"
44
- size="icon-sm"
45
- onClick={onClose}
46
- aria-label={closeLabel}
47
- className={cn(panelCloseButtonClass, closeButtonClassName)}
48
- >
49
- <IconX className="h-4 w-4" />
50
- </Button>
36
+ return (
37
+ <Panel collapsed={collapsed} panelHidden={false} className={className}>
38
+ <PanelHeader>
39
+ <PanelTitle>{title}</PanelTitle>
40
+ {headerTrailing || onClose ? (
41
+ <PanelActions>
42
+ {headerTrailing}
43
+ {onClose ? (
44
+ <PanelClose onClick={onClose} label={closeLabel} className={closeButtonClassName} />
45
+ ) : null}
46
+ </PanelActions>
51
47
  ) : null}
52
- </>
53
- ) : null;
48
+ </PanelHeader>
49
+ {children}
50
+ </Panel>
51
+ );
52
+ }
54
53
 
54
+ export type SidebarPanelRootProps = ComponentPropsWithoutRef<"div"> & {
55
+ /** Desktop: hides the panel (`md:hidden`). Default the `Sidebar.Root` `collapsed`, else `false`. */
56
+ collapsed?: boolean;
57
+ /** Renders nothing. Default the `Sidebar.Root` `panelHidden`, else `false`. */
58
+ panelHidden?: boolean;
59
+ };
60
+
61
+ /** Panel column `<div>` beside the rail. */
62
+ export function Panel({ collapsed, panelHidden, className, ...props }: SidebarPanelRootProps) {
63
+ const sidebar = useOptionalSidebar();
64
+ if (panelHidden ?? sidebar?.panelHidden ?? false) return null;
55
65
  return (
56
66
  <div
67
+ {...props}
57
68
  className={cn(
58
69
  // No border-r: the main content card floats beside this panel, so a
59
70
  // right edge line reads as a hard divider.
60
71
  "relative flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-background",
61
- collapsed && "md:hidden",
72
+ (collapsed ?? sidebar?.collapsed ?? false) && "md:hidden",
62
73
  className,
63
74
  )}
64
- >
65
- <div className="px-2 pt-3">
66
- <SidebarHeader title={title} trailing={trailing} />
67
- </div>
68
- {children}
75
+ />
76
+ );
77
+ }
78
+
79
+ export type SidebarPanelHeaderProps = ComponentPropsWithoutRef<"div">;
80
+
81
+ /** Padded header row of the panel. Holds `PanelTitle` and `PanelActions`. `className` goes to the outer padding `<div>`. */
82
+ export function PanelHeader({ className, children, ...props }: SidebarPanelHeaderProps) {
83
+ return (
84
+ <div {...props} className={cn("px-2 pt-3", className)}>
85
+ <div className={sidebarHeaderRowClass}>{children}</div>
69
86
  </div>
70
87
  );
71
88
  }
89
+
90
+ export type SidebarPanelTitleProps = ComponentPropsWithoutRef<"h2">;
91
+
92
+ /** Panel title `<h2>`, truncated. */
93
+ export function PanelTitle({ className, ...props }: SidebarPanelTitleProps) {
94
+ return (
95
+ <h2
96
+ {...props}
97
+ className={cn(
98
+ "min-w-0 flex-1 truncate text-left text-base font-semibold leading-none tracking-tight text-foreground",
99
+ className,
100
+ )}
101
+ />
102
+ );
103
+ }
104
+
105
+ export type SidebarPanelActionsProps = ComponentPropsWithoutRef<"div">;
106
+
107
+ /** Trailing actions in the panel header. */
108
+ export function PanelActions({ className, ...props }: SidebarPanelActionsProps) {
109
+ return <div {...props} className={cn("flex items-center justify-end gap-0.5", className)} />;
110
+ }
111
+
112
+ export type SidebarPanelCloseProps = ButtonProps & {
113
+ /** `aria-label`. Default `"Close navigation"`. */
114
+ label?: string;
115
+ };
116
+
117
+ /** Ghost close button. Without `onClick` it closes the `Sidebar.Root` drawer. */
118
+ export function PanelClose({ label = "Close navigation", onClick, className, children, ...props }: SidebarPanelCloseProps) {
119
+ const sidebar = useOptionalSidebar();
120
+ return (
121
+ <Button
122
+ type="button"
123
+ variant="ghost"
124
+ size="icon-sm"
125
+ {...props}
126
+ onClick={onClick ?? (() => sidebar?.onMobileOpenChange(false))}
127
+ aria-label={label}
128
+ className={cn(panelCloseButtonClass, className)}
129
+ >
130
+ {children ?? <IconX className="h-4 w-4" />}
131
+ </Button>
132
+ );
133
+ }
@@ -28,7 +28,13 @@ export type SidebarRailProps = Omit<ComponentPropsWithoutRef<"div">, "children"
28
28
  footerClassName?: string;
29
29
  };
30
30
 
31
- /** Icon rail column. Width reads `--app-shell-rail-width`; override with `className`. Other props go to the root `<div>`. */
31
+ /**
32
+ * Icon rail column. Width reads `--app-shell-rail-width`; override with `className`. Other props go to the root `<div>`.
33
+ *
34
+ * Legacy (still supported): the `top`, `footer`, `bodyAsChild`, `tabs` and
35
+ * `*ClassName` props. Compose `Sidebar.Rail`, `.RailHeader`, `.RailBody` and
36
+ * `.RailFooter` instead.
37
+ */
32
38
  export function SidebarRail({
33
39
  top,
34
40
  children,
@@ -42,11 +48,24 @@ export function SidebarRail({
42
48
  footerClassName,
43
49
  ...props
44
50
  }: SidebarRailProps) {
45
- const Body = bodyAsChild ? Slot : "div";
46
- const bodyClass = cn(
47
- "flex w-full flex-1 flex-col items-center gap-1.5 overflow-y-auto py-2",
48
- bodyClassName,
51
+ return (
52
+ <Rail {...props} showDivider={showDivider} className={className}>
53
+ {top == null ? null : <RailHeader className={topClassName}>{top}</RailHeader>}
54
+ <RailBody asChild={bodyAsChild} tabs={tabs} className={bodyClassName}>
55
+ {children}
56
+ </RailBody>
57
+ {footer == null ? null : <RailFooter className={footerClassName}>{footer}</RailFooter>}
58
+ </Rail>
49
59
  );
60
+ }
61
+
62
+ export type SidebarRailRootProps = ComponentPropsWithoutRef<"div"> & {
63
+ /** Adds a right border between the rail and the panel. Default `false`. */
64
+ showDivider?: boolean;
65
+ };
66
+
67
+ /** Rail column `<div>`. Width reads `--app-shell-rail-width`. */
68
+ export function Rail({ showDivider = false, className, ...props }: SidebarRailRootProps) {
50
69
  return (
51
70
  <div
52
71
  {...props}
@@ -55,29 +74,43 @@ export function SidebarRail({
55
74
  showDivider && "border-r border-separator",
56
75
  className,
57
76
  )}
58
- >
59
- {top == null ? null : (
60
- <div className={cn("flex w-full flex-col items-center gap-1.5 px-0 pt-3", topClassName)}>
61
- {top}
62
- </div>
63
- )}
64
- {tabs ? (
65
- <TabsPrimitive.List asChild={bodyAsChild} className={bodyClass}>
66
- {children}
67
- </TabsPrimitive.List>
68
- ) : (
69
- <Body className={bodyClass}>{children}</Body>
70
- )}
71
- {footer == null ? null : (
72
- <div className={cn("flex w-full flex-col items-center gap-1.5 py-3", footerClassName)}>
73
- {footer}
74
- </div>
75
- )}
76
- </div>
77
+ />
77
78
  );
78
79
  }
79
80
 
80
- export type RailTileProps = Omit<ComponentPropsWithoutRef<"button">, "children"> & {
81
+ export type SidebarRailHeaderProps = ComponentPropsWithoutRef<"div">;
82
+
83
+ /** Block pinned at the top of the rail, above the scroll body. */
84
+ export function RailHeader({ className, ...props }: SidebarRailHeaderProps) {
85
+ return <div {...props} className={cn("flex w-full flex-col items-center gap-1.5 px-0 pt-3", className)} />;
86
+ }
87
+
88
+ export type SidebarRailBodyProps = ComponentPropsWithoutRef<"div"> & {
89
+ /** Render the body as its single child element (for example a `<nav>`), with the body classes merged. */
90
+ asChild?: boolean;
91
+ /**
92
+ * Render a Radix tabs list (arrow-key roving focus). Use it inside a Tabs
93
+ * root with `orientation="vertical"` and give each item a `tabValue`.
94
+ */
95
+ tabs?: boolean;
96
+ };
97
+
98
+ /** Scroll body of the rail. */
99
+ export function RailBody({ asChild = false, tabs = false, className, ...props }: SidebarRailBodyProps) {
100
+ const bodyClass = cn("flex w-full flex-1 flex-col items-center gap-1.5 overflow-y-auto py-2", className);
101
+ if (tabs) return <TabsPrimitive.List {...props} asChild={asChild} className={bodyClass} />;
102
+ const Body = asChild ? Slot : "div";
103
+ return <Body {...props} className={bodyClass} />;
104
+ }
105
+
106
+ export type SidebarRailFooterProps = ComponentPropsWithoutRef<"div">;
107
+
108
+ /** Block pinned at the bottom of the rail. */
109
+ export function RailFooter({ className, ...props }: SidebarRailFooterProps) {
110
+ return <div {...props} className={cn("flex w-full flex-col items-center gap-1.5 py-3", className)} />;
111
+ }
112
+
113
+ export type SidebarRailItemProps = Omit<ComponentPropsWithoutRef<"button">, "children"> & {
81
114
  /** `aria-label`, and the tooltip unless `tooltip` is set. */
82
115
  label: string;
83
116
  /** Active state. With `asChild`, also sets `aria-current="page"`. */
@@ -99,7 +132,7 @@ export type RailTileProps = Omit<ComponentPropsWithoutRef<"button">, "children">
99
132
  };
100
133
 
101
134
  /** Rail tile with a right-side tooltip. Renders a native `<button>`, its child with `asChild`, or a tabs trigger with `tabValue`. */
102
- export function RailTile({
135
+ export function RailItem({
103
136
  label,
104
137
  active = false,
105
138
  tooltip,
@@ -110,7 +143,7 @@ export function RailTile({
110
143
  className,
111
144
  children,
112
145
  ...props
113
- }: RailTileProps) {
146
+ }: SidebarRailItemProps) {
114
147
  return (
115
148
  <SidebarIconTooltip label={tooltip ?? label} enabled={showTooltip}>
116
149
  {tabValue !== undefined ? (
@@ -148,8 +181,21 @@ export function RailTile({
148
181
  );
149
182
  }
150
183
 
184
+ export type RailTileProps = SidebarRailItemProps;
185
+
186
+ /** Legacy name of `Sidebar.RailItem`. */
187
+ export const RailTile = RailItem;
188
+
189
+ export type SidebarRailSeparatorProps = ComponentPropsWithoutRef<"div">;
190
+
191
+ /** Short horizontal rule between rail items. */
192
+ export function RailSeparator({ className, ...props }: SidebarRailSeparatorProps) {
193
+ return <div {...props} className={cn("h-px w-8 bg-separator", className)} aria-hidden />;
194
+ }
195
+
151
196
  export type RailDividerProps = { className?: string };
152
197
 
198
+ /** Legacy name of `Sidebar.RailSeparator`. */
153
199
  export function RailDivider({ className }: RailDividerProps) {
154
- return <div className={cn("h-px w-8 bg-separator", className)} aria-hidden />;
200
+ return <RailSeparator className={className} />;
155
201
  }