@vvedantb/ui 0.5.3 → 0.6.0

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,78 @@
1
+ import { SidebarChromeParts } from "./SidebarChrome";
2
+ import { SidebarIconTooltip } from "./SidebarIconTooltip";
3
+ import {
4
+ AccountAvatar,
5
+ AccountBadge,
6
+ AccountContent,
7
+ AccountLabel,
8
+ AccountMenu,
9
+ AccountSeparator,
10
+ AccountTrigger,
11
+ } from "./SidebarAccountMenu";
12
+ import { Panel, PanelActions, PanelClose, PanelHeader, PanelTitle } from "./SidebarPanel";
13
+ import { Rail, RailBody, RailFooter, RailHeader, RailItem, RailSeparator } from "./SidebarRail";
14
+
15
+ export type {
16
+ SidebarAsideProps,
17
+ SidebarBackdropProps,
18
+ SidebarMobileActionsProps,
19
+ SidebarMobileHeaderProps,
20
+ SidebarMobileMenuButtonProps,
21
+ SidebarMobileTitleProps,
22
+ SidebarResizeHandlePartProps,
23
+ SidebarRootProps,
24
+ } from "./SidebarChrome";
25
+ export type {
26
+ SidebarRailBodyProps,
27
+ SidebarRailFooterProps,
28
+ SidebarRailHeaderProps,
29
+ SidebarRailItemProps,
30
+ SidebarRailRootProps,
31
+ SidebarRailSeparatorProps,
32
+ } from "./SidebarRail";
33
+ export type {
34
+ SidebarPanelActionsProps,
35
+ SidebarPanelCloseProps,
36
+ SidebarPanelHeaderProps,
37
+ SidebarPanelRootProps,
38
+ SidebarPanelTitleProps,
39
+ } from "./SidebarPanel";
40
+ export type {
41
+ SidebarAccountAvatarProps,
42
+ SidebarAccountBadgeProps,
43
+ SidebarAccountContentProps,
44
+ SidebarAccountLabelProps,
45
+ SidebarAccountMenuRootProps,
46
+ SidebarAccountSeparatorProps,
47
+ SidebarAccountTriggerProps,
48
+ } from "./SidebarAccountMenu";
49
+
50
+ /**
51
+ * Composable sidebar parts. `Sidebar.Root` holds the drawer and layout state;
52
+ * the chrome, rail, panel and account parts read it. See the README
53
+ * "Composable parts" section for a full shell.
54
+ */
55
+ export const Sidebar = {
56
+ ...SidebarChromeParts,
57
+ Rail,
58
+ RailHeader,
59
+ RailBody,
60
+ RailFooter,
61
+ /** Alias of `RailFooter`. */
62
+ Footer: RailFooter,
63
+ RailItem,
64
+ RailSeparator,
65
+ Tooltip: SidebarIconTooltip,
66
+ Panel,
67
+ PanelHeader,
68
+ PanelTitle,
69
+ PanelActions,
70
+ PanelClose,
71
+ AccountMenu,
72
+ AccountTrigger,
73
+ AccountAvatar,
74
+ AccountContent,
75
+ AccountLabel,
76
+ AccountSeparator,
77
+ AccountBadge,
78
+ };
@@ -1,5 +1,5 @@
1
1
  import type { ComponentPropsWithoutRef, ReactNode } from "react";
2
- import { Button } from "../../src/ui/button";
2
+ import { Button, type ButtonProps } from "../../src/ui/button";
3
3
  import {
4
4
  DropdownMenu,
5
5
  DropdownMenuContent,
@@ -17,7 +17,7 @@ export type SidebarAccountMenuProps = {
17
17
  name: string;
18
18
  /** When set, the label shows the name and email on two lines. */
19
19
  email?: string;
20
- /** Always use the two-line label layout, even without `email`. Default `false`. */
20
+ /** Legacy (still supported): always use the two-line label layout, even without `email`. Default `false`. */
21
21
  stackedLabel?: boolean;
22
22
  /** Avatar image. Without it the avatar shows up to two initials of `name`. */
23
23
  imageUrl?: string;
@@ -31,14 +31,14 @@ export type SidebarAccountMenuProps = {
31
31
  /** Merged onto the menu content after `w-56`. */
32
32
  contentClassName?: string;
33
33
  /**
34
- * Unread dot over the trigger's top-right corner, a sibling of the trigger in
34
+ * Legacy (still supported): unread dot over the trigger's top-right corner, a sibling of the trigger in
35
35
  * a `relative` wrapper. `true` shows it, `false` keeps it mounted at zero
36
36
  * opacity so it fades out. Omitted: no wrapper and no dot.
37
37
  */
38
38
  badge?: boolean;
39
- /** Spread onto the trigger `Button` after the defaults (overrides `title`, `aria-label`, …). `className` merges last. */
39
+ /** Legacy (still supported): spread onto the trigger `Button` after the defaults (overrides `title`, `aria-label`, …). `className` merges last. */
40
40
  triggerProps?: Omit<ComponentPropsWithoutRef<typeof Button>, "children" | "asChild">;
41
- /** Spread onto `DropdownMenuContent`, for example `fitViewport` or `aria-label`. Use `contentClassName` for classes. */
41
+ /** Legacy (still supported): spread onto `DropdownMenuContent`, for example `fitViewport` or `aria-label`. Use `contentClassName` for classes. */
42
42
  contentProps?: Omit<
43
43
  ContentProps,
44
44
  "children" | "className" | "align" | "side" | "sideOffset" | "collisionPadding"
@@ -47,35 +47,7 @@ export type SidebarAccountMenuProps = {
47
47
  children?: ReactNode;
48
48
  };
49
49
 
50
- function AccountAvatar({ src, name, className }: { src?: string; name: string; className: string }) {
51
- if (src) {
52
- return (
53
- <img
54
- src={src}
55
- alt=""
56
- className={cn(className, "rounded-full object-cover outline outline-1 -outline-offset-1 outline-separator")}
57
- />
58
- );
59
- }
60
- const initials = name
61
- .split(/\s+/)
62
- .filter(Boolean)
63
- .slice(0, 2)
64
- .map((part) => part[0]?.toUpperCase() ?? "")
65
- .join("");
66
- return (
67
- <span
68
- className={cn(
69
- "flex items-center justify-center rounded-full bg-surface-tertiary text-[10px] font-medium text-foreground outline outline-1 -outline-offset-1 outline-separator",
70
- className,
71
- )}
72
- >
73
- {initials || "?"}
74
- </span>
75
- );
76
- }
77
-
78
- /** Account menu on a rail tile: avatar trigger, identity label, then the app's items. */
50
+ /** Account menu on a rail tile: avatar trigger, identity label, then the app's items. Composed from `Sidebar.Account*` parts. */
79
51
  export function SidebarAccountMenu({
80
52
  name,
81
53
  email,
@@ -91,66 +63,186 @@ export function SidebarAccountMenu({
91
63
  contentProps,
92
64
  children,
93
65
  }: SidebarAccountMenuProps) {
94
- const menu = (
95
- <DropdownMenu>
96
- <DropdownMenuTrigger asChild>
97
- <Button
98
- type="button"
99
- variant="ghost"
100
- title={name}
101
- aria-label={`Account menu for ${name}`}
102
- {...triggerProps}
103
- className={cn(
104
- railTileClass,
105
- "h-11 w-11 p-0",
106
- railTileStateClass(false),
107
- triggerProps?.className,
108
- )}
109
- >
110
- <AccountAvatar src={imageUrl} name={name} className="h-7 w-7" />
111
- </Button>
112
- </DropdownMenuTrigger>
113
- <DropdownMenuContent
66
+ return (
67
+ <AccountMenu badge={badge}>
68
+ <AccountTrigger {...triggerProps} userName={name} imageUrl={imageUrl} />
69
+ <AccountContent
114
70
  {...contentProps}
115
71
  align={align}
116
72
  side={side}
117
73
  sideOffset={sideOffset}
118
74
  collisionPadding={collisionPadding}
119
- className={cn("w-56", contentClassName)}
75
+ className={contentClassName}
120
76
  >
121
- <DropdownMenuLabel className="flex items-center gap-2 font-normal normal-case tracking-normal">
122
- <AccountAvatar src={imageUrl} name={name} className="h-6 w-6 shrink-0" />
123
- {stackedLabel || email ? (
124
- <span className="min-w-0">
125
- <span className="block truncate text-sm font-medium text-foreground">{name}</span>
126
- {email ? <span className={accountEmailClass}>{email}</span> : null}
127
- </span>
128
- ) : (
129
- <span className="truncate text-sm font-medium text-foreground">{name}</span>
130
- )}
131
- </DropdownMenuLabel>
132
- <DropdownMenuSeparator />
77
+ <AccountLabel userName={name} email={email} imageUrl={imageUrl} stacked={stackedLabel} />
78
+ <AccountSeparator />
133
79
  {children}
134
- </DropdownMenuContent>
135
- </DropdownMenu>
80
+ </AccountContent>
81
+ </AccountMenu>
136
82
  );
83
+ }
84
+
85
+ export type SidebarAccountMenuRootProps = ComponentPropsWithoutRef<typeof DropdownMenu> & {
86
+ /**
87
+ * Unread dot over the trigger's top-right corner (`AccountBadge`), a sibling
88
+ * of the trigger in a `relative` wrapper. `true` shows it, `false` keeps it
89
+ * mounted at zero opacity so it fades out. Omitted: no wrapper and no dot.
90
+ */
91
+ badge?: boolean;
92
+ };
93
+
94
+ /** Dropdown menu root. Holds `AccountTrigger` and `AccountContent`. */
95
+ export function AccountMenu({ badge, ...props }: SidebarAccountMenuRootProps) {
96
+ const menu = <DropdownMenu {...props} />;
137
97
  if (badge === undefined) return menu;
138
98
  // Sibling of the trigger, so its opacity and press scale do not reach the dot.
139
99
  return (
140
100
  <div className="relative">
141
101
  {menu}
142
- <span
143
- aria-hidden="true"
144
- data-account-badge=""
145
- className={cn(
146
- "pointer-events-none absolute right-0 top-0 h-2.5 w-2.5 rounded-full border-2 border-background bg-danger transition-opacity duration-150 ease-out",
147
- badge ? "opacity-100" : "opacity-0",
148
- )}
149
- />
102
+ <AccountBadge visible={badge} />
150
103
  </div>
151
104
  );
152
105
  }
153
106
 
107
+ export type SidebarAccountBadgeProps = Omit<ComponentPropsWithoutRef<"span">, "children"> & {
108
+ /** `false` keeps the dot mounted at zero opacity so it fades out. Default `true`. */
109
+ visible?: boolean;
110
+ };
111
+
112
+ /** Unread dot for the top-right corner of a `relative` parent. */
113
+ export function AccountBadge({ visible = true, className, ...props }: SidebarAccountBadgeProps) {
114
+ return (
115
+ <span
116
+ aria-hidden="true"
117
+ data-account-badge=""
118
+ {...props}
119
+ className={cn(
120
+ "pointer-events-none absolute right-0 top-0 h-2.5 w-2.5 rounded-full border-2 border-background bg-danger transition-opacity duration-150 ease-out",
121
+ visible ? "opacity-100" : "opacity-0",
122
+ className,
123
+ )}
124
+ />
125
+ );
126
+ }
127
+
128
+ export type SidebarAccountAvatarProps = {
129
+ userName: string;
130
+ /** Without it the avatar shows up to two initials of `userName`. */
131
+ imageUrl?: string;
132
+ /** Size classes, for example `h-7 w-7`. */
133
+ className?: string;
134
+ };
135
+
136
+ /** Round avatar image, or up to two initials of `userName`. */
137
+ export function AccountAvatar({ imageUrl, userName, className }: SidebarAccountAvatarProps) {
138
+ if (imageUrl) {
139
+ return (
140
+ <img
141
+ src={imageUrl}
142
+ alt=""
143
+ className={cn(className, "rounded-full object-cover outline outline-1 -outline-offset-1 outline-separator")}
144
+ />
145
+ );
146
+ }
147
+ const initials = userName
148
+ .split(/\s+/)
149
+ .filter(Boolean)
150
+ .slice(0, 2)
151
+ .map((part) => part[0]?.toUpperCase() ?? "")
152
+ .join("");
153
+ return (
154
+ <span
155
+ className={cn(
156
+ "flex items-center justify-center rounded-full bg-surface-tertiary text-[10px] font-medium text-foreground outline outline-1 -outline-offset-1 outline-separator",
157
+ className,
158
+ )}
159
+ >
160
+ {initials || "?"}
161
+ </span>
162
+ );
163
+ }
164
+
165
+ export type SidebarAccountTriggerProps = Omit<ButtonProps, "asChild"> & {
166
+ /** Default `title` and `aria-label`, and the avatar initials. Native `name` goes to the button. */
167
+ userName: string;
168
+ imageUrl?: string;
169
+ };
170
+
171
+ /** Rail-tile-sized avatar button that opens the menu. Children replace the avatar. */
172
+ export function AccountTrigger({ userName, imageUrl, className, children, ...props }: SidebarAccountTriggerProps) {
173
+ return (
174
+ <DropdownMenuTrigger asChild>
175
+ <Button
176
+ type="button"
177
+ variant="ghost"
178
+ title={userName}
179
+ aria-label={`Account menu for ${userName}`}
180
+ {...props}
181
+ className={cn(railTileClass, "h-11 w-11 p-0", railTileStateClass(false), className)}
182
+ >
183
+ {children ?? <AccountAvatar imageUrl={imageUrl} userName={userName} className="h-7 w-7" />}
184
+ </Button>
185
+ </DropdownMenuTrigger>
186
+ );
187
+ }
188
+
189
+ export type SidebarAccountContentProps = ContentProps;
190
+
191
+ /** Menu content. Defaults: `align="center"`, `side="right"`, `sideOffset={8}`, `w-56`. */
192
+ export function AccountContent({
193
+ align = "center",
194
+ side = "right",
195
+ sideOffset = 8,
196
+ className,
197
+ ...props
198
+ }: SidebarAccountContentProps) {
199
+ return (
200
+ <DropdownMenuContent
201
+ {...props}
202
+ align={align}
203
+ side={side}
204
+ sideOffset={sideOffset}
205
+ className={cn("w-56", className)}
206
+ />
207
+ );
208
+ }
209
+
210
+ export type SidebarAccountLabelProps = Omit<ComponentPropsWithoutRef<typeof DropdownMenuLabel>, "children"> & {
211
+ userName: string;
212
+ /** Shown under the name. */
213
+ email?: string;
214
+ imageUrl?: string;
215
+ /** Two-line layout even without `email`. Default `false`. */
216
+ stacked?: boolean;
217
+ };
218
+
219
+ /** Identity label: small avatar, name and optional email. Follow it with `AccountSeparator`. */
220
+ export function AccountLabel({ userName, email, imageUrl, stacked = false, className, ...props }: SidebarAccountLabelProps) {
221
+ return (
222
+ <DropdownMenuLabel
223
+ {...props}
224
+ className={cn("flex items-center gap-2 font-normal normal-case tracking-normal", className)}
225
+ >
226
+ <AccountAvatar imageUrl={imageUrl} userName={userName} className="h-6 w-6 shrink-0" />
227
+ {stacked || email ? (
228
+ <span className="min-w-0">
229
+ <span className="block truncate text-sm font-medium text-foreground">{userName}</span>
230
+ {email ? <span className={accountEmailClass}>{email}</span> : null}
231
+ </span>
232
+ ) : (
233
+ <span className="truncate text-sm font-medium text-foreground">{userName}</span>
234
+ )}
235
+ </DropdownMenuLabel>
236
+ );
237
+ }
238
+
239
+ export type SidebarAccountSeparatorProps = ComponentPropsWithoutRef<typeof DropdownMenuSeparator>;
240
+
241
+ /** Separator between the label and the items. */
242
+ export function AccountSeparator(props: SidebarAccountSeparatorProps) {
243
+ return <DropdownMenuSeparator {...props} />;
244
+ }
245
+
154
246
  export type SidebarAccountMenuPlaceholderProps = { className?: string };
155
247
 
156
248
  /** Tile-sized placeholder while the account loads. */
@@ -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
  }