@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.
- package/README.md +184 -5
- package/app/README.md +100 -0
- package/package.json +11 -10
- package/shell/src/AppShell.tsx +130 -43
- package/shell/src/AppSidebar.tsx +27 -125
- 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/index.ts +9 -1
- package/src/ui/confirm-dialog-parts.tsx +355 -0
- package/src/ui/confirm-dialog.tsx +65 -106
- package/src/ui/overlay-a11y.ts +5 -3
- package/tokens/README.md +98 -0
package/shell/src/AppSidebar.tsx
CHANGED
|
@@ -1,11 +1,5 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
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
|
|
64
|
-
mobileMenuLabel
|
|
65
|
-
closeLabel
|
|
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
|
-
{
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
180
|
-
</
|
|
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
|
-
/**
|
|
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. */
|