@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.
@@ -1,11 +1,5 @@
1
- import { useEffect, useId, type KeyboardEvent, type ReactNode } from "react";
2
- import { AnimatePresence, motion, type PanInfo } from "motion/react";
3
- import { IconMenu2 } from "@tabler/icons-react";
4
- import { Button } from "../../src/ui/button";
5
- import { motionEase, motionTiming } from "../../src/motion/presets";
6
- import { cn } from "../../src/utils/cn";
7
- import { mobileMenuButtonClass } from "./app-shell-classes";
8
- import { SidebarResizeHandle, useSidebarResizeContext } from "./SidebarResize";
1
+ import type { ReactNode } from "react";
2
+ import { SidebarChromeParts as Parts } from "./SidebarChrome";
9
3
 
10
4
  export type AppSidebarProps = {
11
5
  /** Icon rail, usually a `SidebarRail`. */
@@ -39,13 +33,15 @@ export type AppSidebarProps = {
39
33
  closeLabel?: string;
40
34
  };
41
35
 
42
- const defaultMobileMenuIcon = () => <IconMenu2 size={20} />;
43
-
44
36
  /**
45
37
  * Sidebar chrome: the mobile header, the drawer backdrop and the `<aside>`
46
38
  * (fixed on desktop, a swipe-to-close drawer below it). Reads the
47
39
  * `--app-shell-*` width variables set by `AppShell`. Inside a resizable
48
40
  * `AppShell` it adds the resize handle while the panel shows on desktop.
41
+ *
42
+ * Legacy (still supported): the `rail`, `panel` and `mobile*` slot props.
43
+ * Compose `Sidebar.Root`, `.MobileHeader`, `.Backdrop`, `.Aside` and
44
+ * `.ResizeHandle` instead.
49
45
  */
50
46
  export function AppSidebar({
51
47
  rail,
@@ -60,124 +56,30 @@ export function AppSidebar({
60
56
  className,
61
57
  mobileTitle,
62
58
  mobileHeaderActions,
63
- mobileMenuIcon = defaultMobileMenuIcon,
64
- mobileMenuLabel = "Open navigation menu",
65
- closeLabel = "Close navigation",
59
+ mobileMenuIcon,
60
+ mobileMenuLabel,
61
+ closeLabel,
66
62
  }: AppSidebarProps) {
67
- const autoId = useId();
68
- const drawerId = id ?? autoId;
69
- const layout = isDesktop ? "desktop" : "drawer";
70
- const resize = useSidebarResizeContext();
71
- const showResizeHandle = resize !== null && isDesktop && !collapsed && !panelHidden;
72
-
73
- // Reaching the desktop breakpoint closes the drawer, so it does not reopen
74
- // on the way back down. Runs on breakpoint change only.
75
- useEffect(() => {
76
- if (isDesktop && mobileOpen) onMobileOpenChange(false);
77
- }, [isDesktop]);
78
-
79
- const close = () => onMobileOpenChange(false);
80
-
81
- const handleDrawerDragEnd = (
82
- _event: MouseEvent | TouchEvent | PointerEvent,
83
- info: PanInfo,
84
- ) => {
85
- if (isDesktop || !mobileOpen) return;
86
- if (info.offset.x < -72 || info.velocity.x < -500) {
87
- close();
88
- }
89
- };
90
-
91
- const closeOnEscape = (event: KeyboardEvent) => {
92
- if (event.key === "Escape" && !isDesktop && mobileOpen) {
93
- close();
94
- }
95
- };
96
-
97
63
  return (
98
- <>
99
- {/* Three columns keep the title centred and stop it overlapping the actions: it truncates instead. */}
100
- <header
101
- onKeyDown={closeOnEscape}
102
- className="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"
103
- >
104
- <Button
105
- type="button"
106
- variant="ghost"
107
- size="icon"
108
- onClick={() => onMobileOpenChange(true)}
109
- aria-label={mobileMenuLabel}
110
- aria-expanded={mobileOpen}
111
- aria-controls={drawerId}
112
- className={cn(mobileMenuButtonClass, "justify-self-start")}
113
- >
114
- {mobileMenuIcon(mobileOpen)}
115
- </Button>
116
- {mobileTitle ? (
117
- <h1 className="pointer-events-none min-w-0 truncate text-center text-base font-semibold leading-tight tracking-tight text-foreground">
118
- {mobileTitle}
119
- </h1>
120
- ) : (
121
- <span aria-hidden="true" />
122
- )}
123
- {mobileHeaderActions ? (
124
- <div
125
- data-app-sidebar-mobile-actions=""
126
- className="flex shrink-0 items-center justify-self-end gap-2"
127
- >
128
- {mobileHeaderActions}
129
- </div>
130
- ) : (
131
- <div className="h-11 w-11 shrink-0 justify-self-end" aria-hidden="true" />
132
- )}
133
- </header>
134
-
135
- <AnimatePresence initial={false}>
136
- {mobileOpen && !isDesktop ? (
137
- <motion.button
138
- type="button"
139
- aria-label={closeLabel}
140
- className="fixed inset-0 z-40 bg-backdrop/80 md:hidden"
141
- onClick={close}
142
- initial={{ opacity: 0 }}
143
- animate={{ opacity: 1 }}
144
- exit={{ opacity: 0 }}
145
- transition={{ duration: motionTiming.sidebar, ease: motionEase }}
146
- />
147
- ) : null}
148
- </AnimatePresence>
149
-
150
- <motion.aside
151
- id={drawerId}
152
- data-sidebar-layout={layout}
153
- {...asideProps}
154
- inert={!isDesktop && !mobileOpen}
155
- onKeyDown={closeOnEscape}
156
- className={cn(
157
- "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",
158
- panelHidden
159
- ? "w-[var(--app-shell-rail-width)]"
160
- : "w-[min(var(--app-shell-sidebar-width),calc(100vw-1.5rem))]",
161
- collapsed || panelHidden
162
- ? "md:w-[var(--app-shell-rail-width)]"
163
- : "md:w-[var(--app-shell-sidebar-width)]",
164
- !resize?.isResizing &&
165
- "md:transition-[width] md:[transition-duration:280ms] md:[transition-timing-function:cubic-bezier(0.22,1,0.36,1)]",
166
- className,
167
- )}
168
- initial={false}
169
- animate={{ x: isDesktop || mobileOpen ? 0 : "-100%" }}
170
- transition={{ duration: motionTiming.sidebar, ease: motionEase }}
171
- drag={!isDesktop && mobileOpen ? "x" : false}
172
- dragDirectionLock
173
- dragConstraints={{ left: 0, right: 0 }}
174
- dragElastic={{ left: 0.45, right: 0.08 }}
175
- onDragEnd={handleDrawerDragEnd}
176
- >
64
+ <Parts.Root
65
+ isDesktop={isDesktop}
66
+ mobileOpen={mobileOpen}
67
+ onMobileOpenChange={onMobileOpenChange}
68
+ collapsed={collapsed}
69
+ panelHidden={panelHidden}
70
+ id={id}
71
+ >
72
+ <Parts.MobileHeader>
73
+ <Parts.MobileMenuButton label={mobileMenuLabel} icon={mobileMenuIcon} />
74
+ <Parts.MobileTitle>{mobileTitle}</Parts.MobileTitle>
75
+ <Parts.MobileActions>{mobileHeaderActions}</Parts.MobileActions>
76
+ </Parts.MobileHeader>
77
+ <Parts.Backdrop label={closeLabel} />
78
+ <Parts.Aside {...asideProps} className={className}>
177
79
  {rail}
178
80
  {panelHidden ? null : panel}
179
- {showResizeHandle ? <SidebarResizeHandle /> : null}
180
- </motion.aside>
181
- </>
81
+ <Parts.ResizeHandle />
82
+ </Parts.Aside>
83
+ </Parts.Root>
182
84
  );
183
85
  }
@@ -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. */