@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.
- package/README.md +165 -7
- package/package.json +1 -1
- package/shell/src/AppShell.tsx +130 -43
- package/shell/src/AppSidebar.tsx +27 -119
- package/shell/src/Sidebar.tsx +78 -0
- package/shell/src/SidebarAccountMenu.tsx +169 -77
- package/shell/src/SidebarChrome.tsx +295 -0
- package/shell/src/SidebarHeader.tsx +6 -7
- package/shell/src/SidebarPanel.tsx +95 -33
- package/shell/src/SidebarRail.tsx +74 -28
- package/shell/src/SignOutDialog.tsx +24 -51
- package/shell/src/app-shell-classes.ts +6 -0
- package/shell/src/index.ts +9 -1
- package/src/composer/input-area/InputAreaEditor.tsx +52 -13
- package/src/index.ts +9 -1
- package/src/ui/confirm-dialog-parts.tsx +318 -0
- package/src/ui/confirm-dialog.tsx +65 -106
- package/src/ui/overlay-a11y.ts +5 -3
|
@@ -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
|
-
/**
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
<
|
|
96
|
-
<
|
|
97
|
-
|
|
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={
|
|
75
|
+
className={contentClassName}
|
|
120
76
|
>
|
|
121
|
-
<
|
|
122
|
-
|
|
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
|
-
</
|
|
135
|
-
</
|
|
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
|
-
<
|
|
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(
|
|
13
|
-
<
|
|
14
|
-
|
|
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
|
}
|