@exegia/corpora-ui 0.7.0 → 0.9.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/dist-lib/components/blocks/auth/__tests__/signup-block.test.d.ts +1 -0
- package/dist-lib/components/blocks/auth/signup-block.d.ts +16 -1
- package/dist-lib/components/blocks/nav/__tests__/sidebar-block.test.d.ts +1 -0
- package/dist-lib/components/blocks/nav/sidebar-block.d.ts +67 -0
- package/dist-lib/components/blocks/profile/__tests__/profile-card-block.test.d.ts +1 -0
- package/dist-lib/components/blocks/profile/profile-card-block.d.ts +77 -0
- package/dist-lib/components/composed/__tests__/user-avatar.test.d.ts +1 -0
- package/dist-lib/components/composed/user-avatar.d.ts +30 -0
- package/dist-lib/components/motion/animated-sidebar.d.ts +98 -0
- package/dist-lib/components/motion/shared-layout-bg.d.ts +13 -0
- package/dist-lib/components/ui/avatar.d.ts +6 -0
- package/dist-lib/components/ui/menu.d.ts +43 -0
- package/dist-lib/components/ui/skeleton.d.ts +2 -0
- package/dist-lib/index.d.ts +9 -0
- package/dist-lib/index.js +1916 -874
- package/dist-lib/index.js.map +1 -1
- package/dist-lib/lib/ease.d.ts +46 -0
- package/package.json +1 -1
- package/src/components/blocks/auth/__tests__/signup-block.test.tsx +176 -0
- package/src/components/blocks/auth/signup-block.tsx +43 -10
- package/src/components/blocks/nav/__tests__/sidebar-block.test.tsx +125 -0
- package/src/components/blocks/nav/sidebar-block.tsx +264 -0
- package/src/components/blocks/profile/__tests__/profile-card-block.test.tsx +134 -0
- package/src/components/blocks/profile/profile-card-block.tsx +281 -0
- package/src/components/composed/__tests__/user-avatar.test.tsx +85 -0
- package/src/components/composed/user-avatar.tsx +98 -0
- package/src/components/motion/animated-sidebar.tsx +1145 -0
- package/src/components/motion/shared-layout-bg.tsx +151 -0
- package/src/components/ui/avatar.tsx +52 -0
- package/src/components/ui/menu.tsx +352 -0
- package/src/components/ui/skeleton.tsx +18 -0
- package/src/index.css +4 -0
- package/src/index.ts +11 -0
- package/src/lib/ease.ts +53 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { PanelLeftIcon } from "lucide-react"
|
|
4
|
+
import * as React from "react"
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
AnimatedSidebar,
|
|
8
|
+
AnimatedSidebarContent,
|
|
9
|
+
AnimatedSidebarFooter,
|
|
10
|
+
AnimatedSidebarGroup,
|
|
11
|
+
AnimatedSidebarGroupContent,
|
|
12
|
+
AnimatedSidebarGroupLabel,
|
|
13
|
+
AnimatedSidebarHeader,
|
|
14
|
+
AnimatedSidebarMenu,
|
|
15
|
+
AnimatedSidebarMenuButton,
|
|
16
|
+
AnimatedSidebarMenuItem,
|
|
17
|
+
AnimatedSidebarMenuSub,
|
|
18
|
+
AnimatedSidebarMenuSubButton,
|
|
19
|
+
AnimatedSidebarMenuSubItem,
|
|
20
|
+
AnimatedSidebarProvider,
|
|
21
|
+
AnimatedSidebarTrigger,
|
|
22
|
+
} from "@/components/motion/animated-sidebar"
|
|
23
|
+
import { cn } from "@/lib/utils"
|
|
24
|
+
|
|
25
|
+
/** A nested link, one level under a top-level entry. */
|
|
26
|
+
export interface SidebarNavSubItem {
|
|
27
|
+
id: string
|
|
28
|
+
label: string
|
|
29
|
+
icon?: React.ReactNode
|
|
30
|
+
/** Renders an anchor. Without one the row is a button. */
|
|
31
|
+
href?: string
|
|
32
|
+
disabled?: boolean
|
|
33
|
+
target?: "_blank" | "_self" | "_parent" | "_top"
|
|
34
|
+
onSelect?: () => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A top-level entry. With `items` it expands instead of navigating. */
|
|
38
|
+
export interface SidebarNavItem extends SidebarNavSubItem {
|
|
39
|
+
/** Trailing hint — a count, a "New" pill. Hidden while collapsed. */
|
|
40
|
+
badge?: React.ReactNode
|
|
41
|
+
items?: SidebarNavSubItem[]
|
|
42
|
+
/** Start expanded. Defaults to expanded when one of its children is active. */
|
|
43
|
+
defaultOpen?: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A titled run of entries. The title hides while the rail is collapsed. */
|
|
47
|
+
export interface SidebarNavSection {
|
|
48
|
+
id: string
|
|
49
|
+
label?: string
|
|
50
|
+
items: SidebarNavItem[]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SidebarBlockProps {
|
|
54
|
+
sections: SidebarNavSection[]
|
|
55
|
+
/** `id` of the current entry — matches top-level and nested ids alike. */
|
|
56
|
+
activeId?: string
|
|
57
|
+
/** Fires for every selection, after the entry's own `onSelect`. */
|
|
58
|
+
onNavigate?: (item: SidebarNavItem | SidebarNavSubItem) => void
|
|
59
|
+
/** Brand row above the navigation. */
|
|
60
|
+
header?: React.ReactNode
|
|
61
|
+
/** Pinned below the navigation — an account card, a version note. */
|
|
62
|
+
footer?: React.ReactNode
|
|
63
|
+
/** Collapse toggle in the header row. */
|
|
64
|
+
showTrigger?: boolean
|
|
65
|
+
side?: "left" | "right"
|
|
66
|
+
variant?: "sidebar" | "floating" | "inset"
|
|
67
|
+
/** How the rail collapses: to icons, off-canvas, or not at all. */
|
|
68
|
+
collapsible?: "offcanvas" | "icon" | "none"
|
|
69
|
+
open?: boolean
|
|
70
|
+
defaultOpen?: boolean
|
|
71
|
+
onOpenChange?: (open: boolean) => void
|
|
72
|
+
/** Under 768px the panel is a drawer — drive it from your own header. */
|
|
73
|
+
openMobile?: boolean
|
|
74
|
+
defaultOpenMobile?: boolean
|
|
75
|
+
onOpenMobileChange?: (open: boolean) => void
|
|
76
|
+
/** Rail widths. CSS lengths, e.g. "16rem". */
|
|
77
|
+
width?: string
|
|
78
|
+
iconWidth?: string
|
|
79
|
+
mobileWidth?: string
|
|
80
|
+
ariaLabel?: string
|
|
81
|
+
className?: string
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isActive(id: string, activeId?: string): boolean {
|
|
85
|
+
return activeId !== undefined && id === activeId
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface RowProps {
|
|
89
|
+
item: SidebarNavItem
|
|
90
|
+
activeId?: string
|
|
91
|
+
onNavigate?: (item: SidebarNavItem | SidebarNavSubItem) => void
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function SidebarNavRow({ item, activeId, onNavigate }: RowProps) {
|
|
95
|
+
const children = item.items ?? []
|
|
96
|
+
const hasChildren = children.length > 0
|
|
97
|
+
const childActive = children.some((child) => isActive(child.id, activeId))
|
|
98
|
+
// null = follow the active child; a boolean = the reader has said otherwise.
|
|
99
|
+
const [toggled, setToggled] = React.useState<boolean | null>(null)
|
|
100
|
+
const [wasChildActive, setWasChildActive] = React.useState(childActive)
|
|
101
|
+
|
|
102
|
+
// Navigating into a nested route from elsewhere reveals its parent, even if
|
|
103
|
+
// the reader had collapsed it. Adjusted during render rather than in an
|
|
104
|
+
// effect, so the group never paints closed for a frame first.
|
|
105
|
+
if (childActive !== wasChildActive) {
|
|
106
|
+
setWasChildActive(childActive)
|
|
107
|
+
if (childActive) setToggled(null)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const open = toggled ?? item.defaultOpen ?? childActive
|
|
111
|
+
|
|
112
|
+
function select(entry: SidebarNavItem | SidebarNavSubItem) {
|
|
113
|
+
entry.onSelect?.()
|
|
114
|
+
onNavigate?.(entry)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<AnimatedSidebarMenuItem>
|
|
119
|
+
<AnimatedSidebarMenuButton
|
|
120
|
+
ariaExpanded={hasChildren ? open : undefined}
|
|
121
|
+
badge={item.badge}
|
|
122
|
+
disabled={item.disabled}
|
|
123
|
+
href={hasChildren ? undefined : item.href}
|
|
124
|
+
icon={item.icon}
|
|
125
|
+
isActive={isActive(item.id, activeId)}
|
|
126
|
+
onSelect={() => {
|
|
127
|
+
if (hasChildren) {
|
|
128
|
+
setToggled(!open)
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
select(item)
|
|
132
|
+
}}
|
|
133
|
+
target={item.target}
|
|
134
|
+
>
|
|
135
|
+
{item.label}
|
|
136
|
+
</AnimatedSidebarMenuButton>
|
|
137
|
+
{hasChildren ? (
|
|
138
|
+
<AnimatedSidebarMenuSub open={open}>
|
|
139
|
+
{children.map((child) => (
|
|
140
|
+
<AnimatedSidebarMenuSubItem key={child.id}>
|
|
141
|
+
<AnimatedSidebarMenuSubButton
|
|
142
|
+
disabled={child.disabled}
|
|
143
|
+
href={child.href}
|
|
144
|
+
icon={child.icon}
|
|
145
|
+
isActive={isActive(child.id, activeId)}
|
|
146
|
+
onSelect={() => select(child)}
|
|
147
|
+
target={child.target}
|
|
148
|
+
>
|
|
149
|
+
{child.label}
|
|
150
|
+
</AnimatedSidebarMenuSubButton>
|
|
151
|
+
</AnimatedSidebarMenuSubItem>
|
|
152
|
+
))}
|
|
153
|
+
</AnimatedSidebarMenuSub>
|
|
154
|
+
) : null}
|
|
155
|
+
</AnimatedSidebarMenuItem>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Application sidebar navigation, driven by data rather than composition.
|
|
161
|
+
*
|
|
162
|
+
* This is the navigation panel only — it claims no page layout, so place it
|
|
163
|
+
* beside your own content. It owns its own sidebar context (⌘B toggles it);
|
|
164
|
+
* pass `open`/`onOpenChange` to drive the rail, and `openMobile`/
|
|
165
|
+
* `onOpenMobileChange` to open the drawer from a header button of your own.
|
|
166
|
+
* Compose the underlying `AnimatedSidebar*` parts directly when a screen
|
|
167
|
+
* needs something this API does not cover.
|
|
168
|
+
*/
|
|
169
|
+
export function SidebarBlock({
|
|
170
|
+
sections,
|
|
171
|
+
activeId,
|
|
172
|
+
onNavigate,
|
|
173
|
+
header,
|
|
174
|
+
footer,
|
|
175
|
+
showTrigger = true,
|
|
176
|
+
side = "left",
|
|
177
|
+
variant = "sidebar",
|
|
178
|
+
collapsible = "icon",
|
|
179
|
+
open,
|
|
180
|
+
defaultOpen,
|
|
181
|
+
onOpenChange,
|
|
182
|
+
openMobile,
|
|
183
|
+
defaultOpenMobile,
|
|
184
|
+
onOpenMobileChange,
|
|
185
|
+
width,
|
|
186
|
+
iconWidth,
|
|
187
|
+
mobileWidth,
|
|
188
|
+
ariaLabel = "Main",
|
|
189
|
+
className,
|
|
190
|
+
}: SidebarBlockProps): React.ReactElement {
|
|
191
|
+
return (
|
|
192
|
+
<AnimatedSidebarProvider
|
|
193
|
+
// The provider ships a full-page flex shell; this block is just the
|
|
194
|
+
// panel, so the shell is flattened back to the panel's own box.
|
|
195
|
+
className="h-full min-h-0 w-auto"
|
|
196
|
+
defaultOpen={defaultOpen}
|
|
197
|
+
defaultOpenMobile={defaultOpenMobile}
|
|
198
|
+
onOpenChange={onOpenChange}
|
|
199
|
+
onOpenMobileChange={onOpenMobileChange}
|
|
200
|
+
open={open}
|
|
201
|
+
openMobile={openMobile}
|
|
202
|
+
style={{
|
|
203
|
+
...(width ? { "--sidebar-width": width } : null),
|
|
204
|
+
...(iconWidth ? { "--sidebar-width-icon": iconWidth } : null),
|
|
205
|
+
...(mobileWidth ? { "--sidebar-width-mobile": mobileWidth } : null),
|
|
206
|
+
}}
|
|
207
|
+
>
|
|
208
|
+
<AnimatedSidebar
|
|
209
|
+
ariaLabel={ariaLabel}
|
|
210
|
+
// The panel is built for a full-page shell (`h-svh`, so its footer
|
|
211
|
+
// hangs below any shorter box). Fill the parent instead — in a
|
|
212
|
+
// full-height layout that comes to the same thing.
|
|
213
|
+
className={cn("h-full", className)}
|
|
214
|
+
collapsible={collapsible}
|
|
215
|
+
panelClassName="h-full"
|
|
216
|
+
// The panel is navigation and nothing else, so it names a navigation
|
|
217
|
+
// landmark rather than the complementary one <aside> implies. (The
|
|
218
|
+
// mobile drawer is a dialog and keeps that role.)
|
|
219
|
+
role="navigation"
|
|
220
|
+
side={side}
|
|
221
|
+
variant={variant}
|
|
222
|
+
>
|
|
223
|
+
{header || showTrigger ? (
|
|
224
|
+
<AnimatedSidebarHeader>
|
|
225
|
+
<div className="flex min-w-0 items-center justify-between gap-2">
|
|
226
|
+
{header ? <div className="min-w-0 flex-1">{header}</div> : null}
|
|
227
|
+
{showTrigger ? (
|
|
228
|
+
<AnimatedSidebarTrigger className="text-muted-foreground hover:text-foreground">
|
|
229
|
+
<PanelLeftIcon className="size-4.5" />
|
|
230
|
+
</AnimatedSidebarTrigger>
|
|
231
|
+
) : null}
|
|
232
|
+
</div>
|
|
233
|
+
</AnimatedSidebarHeader>
|
|
234
|
+
) : null}
|
|
235
|
+
<AnimatedSidebarContent>
|
|
236
|
+
{sections.map((section) => (
|
|
237
|
+
<AnimatedSidebarGroup key={section.id}>
|
|
238
|
+
{section.label ? (
|
|
239
|
+
<AnimatedSidebarGroupLabel>
|
|
240
|
+
{section.label}
|
|
241
|
+
</AnimatedSidebarGroupLabel>
|
|
242
|
+
) : null}
|
|
243
|
+
<AnimatedSidebarGroupContent>
|
|
244
|
+
<AnimatedSidebarMenu>
|
|
245
|
+
{section.items.map((item) => (
|
|
246
|
+
<SidebarNavRow
|
|
247
|
+
activeId={activeId}
|
|
248
|
+
item={item}
|
|
249
|
+
key={item.id}
|
|
250
|
+
onNavigate={onNavigate}
|
|
251
|
+
/>
|
|
252
|
+
))}
|
|
253
|
+
</AnimatedSidebarMenu>
|
|
254
|
+
</AnimatedSidebarGroupContent>
|
|
255
|
+
</AnimatedSidebarGroup>
|
|
256
|
+
))}
|
|
257
|
+
</AnimatedSidebarContent>
|
|
258
|
+
{footer ? (
|
|
259
|
+
<AnimatedSidebarFooter>{footer}</AnimatedSidebarFooter>
|
|
260
|
+
) : null}
|
|
261
|
+
</AnimatedSidebar>
|
|
262
|
+
</AnimatedSidebarProvider>
|
|
263
|
+
)
|
|
264
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
|
|
5
|
+
import { ProfileCardBlock } from "../profile-card-block";
|
|
6
|
+
|
|
7
|
+
const USER = { name: "Jenny Hamilton", username: "@jennycodes" };
|
|
8
|
+
|
|
9
|
+
describe("ProfileCardBlock", () => {
|
|
10
|
+
test("names the trigger after the identity it shows", () => {
|
|
11
|
+
render(<ProfileCardBlock user={USER} />);
|
|
12
|
+
|
|
13
|
+
const trigger = screen.getByRole("button", {
|
|
14
|
+
name: "Jenny Hamilton @jennycodes, account menu",
|
|
15
|
+
});
|
|
16
|
+
expect(trigger.textContent).toContain("Jenny Hamilton");
|
|
17
|
+
expect(trigger.textContent).toContain("@jennycodes");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("falls back to initials derived from the name", () => {
|
|
21
|
+
render(<ProfileCardBlock user={{ name: "Jenny Hamilton" }} />);
|
|
22
|
+
expect(screen.getByText("JH")).toBeDefined();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("opens the default menu with a destructive sign-out", async () => {
|
|
26
|
+
const user = userEvent.setup();
|
|
27
|
+
render(<ProfileCardBlock user={USER} />);
|
|
28
|
+
|
|
29
|
+
await user.click(screen.getByRole("button"));
|
|
30
|
+
|
|
31
|
+
expect(await screen.findByRole("menuitem", { name: "Profile" })).toBeDefined();
|
|
32
|
+
expect(screen.getByRole("menuitem", { name: "Settings" })).toBeDefined();
|
|
33
|
+
expect(screen.getByRole("menuitem", { name: "Teams" })).toBeDefined();
|
|
34
|
+
expect(screen.getByRole("menuitem", { name: "Invite" })).toBeDefined();
|
|
35
|
+
expect(
|
|
36
|
+
screen
|
|
37
|
+
.getByRole("menuitem", { name: "Log out" })
|
|
38
|
+
.getAttribute("data-variant"),
|
|
39
|
+
).toBe("destructive");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("opens from the keyboard", async () => {
|
|
43
|
+
const user = userEvent.setup();
|
|
44
|
+
render(<ProfileCardBlock user={USER} />);
|
|
45
|
+
|
|
46
|
+
await user.tab();
|
|
47
|
+
await user.keyboard("{Enter}");
|
|
48
|
+
|
|
49
|
+
expect(await screen.findByRole("menuitem", { name: "Profile" })).toBeDefined();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("fires the handler behind the selected item", async () => {
|
|
53
|
+
const user = userEvent.setup();
|
|
54
|
+
const onSelect = mock(() => {});
|
|
55
|
+
render(
|
|
56
|
+
<ProfileCardBlock
|
|
57
|
+
items={[{ id: "settings", label: "Settings", onSelect }]}
|
|
58
|
+
user={USER}
|
|
59
|
+
/>,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
await user.click(screen.getByRole("button"));
|
|
63
|
+
await user.click(await screen.findByRole("menuitem", { name: "Settings" }));
|
|
64
|
+
|
|
65
|
+
expect(onSelect).toHaveBeenCalled();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("items replace the default menu", async () => {
|
|
69
|
+
const user = userEvent.setup();
|
|
70
|
+
render(
|
|
71
|
+
<ProfileCardBlock
|
|
72
|
+
items={[{ id: "switch", label: "Switch team" }]}
|
|
73
|
+
user={USER}
|
|
74
|
+
/>,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
await user.click(screen.getByRole("button"));
|
|
78
|
+
|
|
79
|
+
expect(
|
|
80
|
+
await screen.findByRole("menuitem", { name: "Switch team" }),
|
|
81
|
+
).toBeDefined();
|
|
82
|
+
expect(screen.queryByRole("menuitem", { name: "Log out" })).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("labels name the group they head, up to the next separator", async () => {
|
|
86
|
+
const user = userEvent.setup();
|
|
87
|
+
render(
|
|
88
|
+
<ProfileCardBlock
|
|
89
|
+
items={[
|
|
90
|
+
{ type: "label", label: "Workspace" },
|
|
91
|
+
{ id: "switch", label: "Switch team" },
|
|
92
|
+
{ type: "separator" },
|
|
93
|
+
{ id: "sign-out", label: "Log out", variant: "destructive" },
|
|
94
|
+
]}
|
|
95
|
+
user={USER}
|
|
96
|
+
/>,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
await user.click(screen.getByRole("button"));
|
|
100
|
+
|
|
101
|
+
const named = await screen.findByRole("group", { name: "Workspace" });
|
|
102
|
+
expect(named.querySelectorAll('[data-slot="menu-item"]')).toHaveLength(1);
|
|
103
|
+
expect(screen.getAllByRole("group")).toHaveLength(2);
|
|
104
|
+
expect(
|
|
105
|
+
screen.getByRole("menuitem", { name: "Log out" }).getAttribute("data-variant"),
|
|
106
|
+
).toBe("destructive");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("reports a rejected action through onError", async () => {
|
|
110
|
+
const user = userEvent.setup();
|
|
111
|
+
const onError = mock(() => {});
|
|
112
|
+
render(
|
|
113
|
+
<ProfileCardBlock
|
|
114
|
+
items={[
|
|
115
|
+
{
|
|
116
|
+
id: "sign-out",
|
|
117
|
+
label: "Log out",
|
|
118
|
+
onSelect: async () => {
|
|
119
|
+
throw new Error("Session already ended.");
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
]}
|
|
123
|
+
onError={onError}
|
|
124
|
+
user={USER}
|
|
125
|
+
/>,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
await user.click(screen.getByRole("button"));
|
|
129
|
+
await user.click(await screen.findByRole("menuitem", { name: "Log out" }));
|
|
130
|
+
|
|
131
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
132
|
+
expect(onError).toHaveBeenCalled();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ChevronsUpDownIcon,
|
|
5
|
+
LogOutIcon,
|
|
6
|
+
SettingsIcon,
|
|
7
|
+
UserIcon,
|
|
8
|
+
UserPlusIcon,
|
|
9
|
+
UsersIcon,
|
|
10
|
+
} from "lucide-react"
|
|
11
|
+
import * as React from "react"
|
|
12
|
+
|
|
13
|
+
import { UserAvatar } from "@/components/composed/user-avatar"
|
|
14
|
+
import { Button } from "@/components/ui/button"
|
|
15
|
+
import {
|
|
16
|
+
DropdownMenu,
|
|
17
|
+
DropdownMenuContent,
|
|
18
|
+
DropdownMenuGroup,
|
|
19
|
+
DropdownMenuItem,
|
|
20
|
+
DropdownMenuLabel,
|
|
21
|
+
DropdownMenuSeparator,
|
|
22
|
+
DropdownMenuShortcut,
|
|
23
|
+
DropdownMenuTrigger,
|
|
24
|
+
type MenuPrimitive,
|
|
25
|
+
} from "@/components/ui/menu"
|
|
26
|
+
import { playCue } from "@/lib/sound"
|
|
27
|
+
import { cn } from "@/lib/utils"
|
|
28
|
+
|
|
29
|
+
/** The identity shown on the card. */
|
|
30
|
+
export interface ProfileCardUser {
|
|
31
|
+
name: string
|
|
32
|
+
/** Secondary line under the name — a handle, an email, a role. */
|
|
33
|
+
username?: string
|
|
34
|
+
/** Avatar image URL. Falls back to the initials when absent or broken. */
|
|
35
|
+
avatar?: string
|
|
36
|
+
/** Fallback initials. Derived from `name` when omitted. */
|
|
37
|
+
initials?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** An actionable row of the menu. */
|
|
41
|
+
export interface ProfileCardAction {
|
|
42
|
+
type?: "item"
|
|
43
|
+
id: string
|
|
44
|
+
label: string
|
|
45
|
+
icon?: React.ReactNode
|
|
46
|
+
/** Right-aligned hint, e.g. "⌘K". Purely decorative — bind the key yourself. */
|
|
47
|
+
shortcut?: string
|
|
48
|
+
disabled?: boolean
|
|
49
|
+
variant?: "default" | "destructive"
|
|
50
|
+
/** Returning a promise puts the card in its loading state until it settles. */
|
|
51
|
+
onSelect?: () => void | Promise<void>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A heading over the rows that follow it, up to the next separator. */
|
|
55
|
+
export interface ProfileCardLabel {
|
|
56
|
+
type: "label"
|
|
57
|
+
label: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A rule between two sections. */
|
|
61
|
+
export interface ProfileCardSeparator {
|
|
62
|
+
type: "separator"
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* One entry of `items`. Authored flat; the block renders each run between
|
|
67
|
+
* separators as its own menu group so a label actually names its section.
|
|
68
|
+
*/
|
|
69
|
+
export type ProfileCardItem =
|
|
70
|
+
ProfileCardAction | ProfileCardLabel | ProfileCardSeparator
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The menu the card ships with. Spread it to keep the shape and attach your
|
|
74
|
+
* own handlers, or ignore it and pass `items` of your own.
|
|
75
|
+
*/
|
|
76
|
+
export const defaultProfileCardItems: ProfileCardItem[] = [
|
|
77
|
+
{ type: "label", label: "Management" },
|
|
78
|
+
{ id: "profile", label: "Profile", icon: <UserIcon aria-hidden="true" /> },
|
|
79
|
+
{
|
|
80
|
+
id: "settings",
|
|
81
|
+
label: "Settings",
|
|
82
|
+
icon: <SettingsIcon aria-hidden="true" />,
|
|
83
|
+
},
|
|
84
|
+
{ type: "separator" },
|
|
85
|
+
{ id: "teams", label: "Teams", icon: <UsersIcon aria-hidden="true" /> },
|
|
86
|
+
{ id: "invite", label: "Invite", icon: <UserPlusIcon aria-hidden="true" /> },
|
|
87
|
+
{ type: "separator" },
|
|
88
|
+
{
|
|
89
|
+
id: "sign-out",
|
|
90
|
+
label: "Log out",
|
|
91
|
+
icon: <LogOutIcon aria-hidden="true" />,
|
|
92
|
+
variant: "destructive",
|
|
93
|
+
},
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
type PopupProps = React.ComponentProps<typeof DropdownMenuContent>
|
|
97
|
+
|
|
98
|
+
export interface ProfileCardBlockProps {
|
|
99
|
+
user: ProfileCardUser
|
|
100
|
+
/**
|
|
101
|
+
* The menu, flat: actions plus `{ type: "separator" }` / `{ type: "label" }`
|
|
102
|
+
* entries. Defaults to `defaultProfileCardItems` (no handlers attached).
|
|
103
|
+
*/
|
|
104
|
+
items?: ProfileCardItem[]
|
|
105
|
+
/** Menu placement relative to the card. */
|
|
106
|
+
align?: PopupProps["align"]
|
|
107
|
+
side?: PopupProps["side"]
|
|
108
|
+
sideOffset?: PopupProps["sideOffset"]
|
|
109
|
+
open?: boolean
|
|
110
|
+
defaultOpen?: boolean
|
|
111
|
+
onOpenChange?: MenuPrimitive.Root.Props["onOpenChange"]
|
|
112
|
+
/**
|
|
113
|
+
* Emit cuelume press/release on the card and play the open/close cues.
|
|
114
|
+
* Inert unless the app opts into interaction sound via `bindSounds()`.
|
|
115
|
+
*/
|
|
116
|
+
sound?: boolean
|
|
117
|
+
/** Called when an action's promise rejects — the card leaves its loading state either way. */
|
|
118
|
+
onError?: (error: unknown) => void
|
|
119
|
+
className?: string
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Split a flat item list into the groups the menu renders. */
|
|
123
|
+
function toGroups(
|
|
124
|
+
items: ProfileCardItem[]
|
|
125
|
+
): { label?: string; actions: ProfileCardAction[] }[] {
|
|
126
|
+
const groups: { label?: string; actions: ProfileCardAction[] }[] = []
|
|
127
|
+
let current: { label?: string; actions: ProfileCardAction[] } = {
|
|
128
|
+
actions: [],
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for (const item of items) {
|
|
132
|
+
if (item.type === "separator") {
|
|
133
|
+
groups.push(current)
|
|
134
|
+
current = { actions: [] }
|
|
135
|
+
} else if (item.type === "label") {
|
|
136
|
+
current.label = item.label
|
|
137
|
+
} else {
|
|
138
|
+
current.actions.push(item)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
groups.push(current)
|
|
142
|
+
|
|
143
|
+
return groups.filter(
|
|
144
|
+
(group) => group.actions.length > 0 || group.label !== undefined
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Avatar card that opens an account menu.
|
|
150
|
+
*
|
|
151
|
+
* The trigger is the rich identity chip (avatar + name over handle) and the
|
|
152
|
+
* popup is a plain action menu. `items` is the whole menu — a flat list of
|
|
153
|
+
* actions, separators and labels — defaulting to `defaultProfileCardItems`.
|
|
154
|
+
*/
|
|
155
|
+
export function ProfileCardBlock({
|
|
156
|
+
user,
|
|
157
|
+
items = defaultProfileCardItems,
|
|
158
|
+
align = "center",
|
|
159
|
+
side = "bottom",
|
|
160
|
+
sideOffset = 8,
|
|
161
|
+
open,
|
|
162
|
+
defaultOpen,
|
|
163
|
+
onOpenChange,
|
|
164
|
+
sound = true,
|
|
165
|
+
onError,
|
|
166
|
+
className,
|
|
167
|
+
}: ProfileCardBlockProps): React.ReactElement {
|
|
168
|
+
const [pending, setPending] = React.useState<string | null>(null)
|
|
169
|
+
// Actions can outlive the card (sign-out unmounts it, typically).
|
|
170
|
+
const mounted = React.useRef(true)
|
|
171
|
+
React.useEffect(() => {
|
|
172
|
+
mounted.current = true
|
|
173
|
+
return () => {
|
|
174
|
+
mounted.current = false
|
|
175
|
+
}
|
|
176
|
+
}, [])
|
|
177
|
+
|
|
178
|
+
const groups = React.useMemo(() => toGroups(items), [items])
|
|
179
|
+
|
|
180
|
+
function select(action: ProfileCardAction) {
|
|
181
|
+
const result = action.onSelect?.()
|
|
182
|
+
if (!(result instanceof Promise)) return
|
|
183
|
+
setPending(action.id)
|
|
184
|
+
result
|
|
185
|
+
.catch((cause: unknown) => onError?.(cause))
|
|
186
|
+
.finally(() => {
|
|
187
|
+
if (mounted.current) setPending(null)
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const busy = pending !== null
|
|
192
|
+
|
|
193
|
+
const handleOpenChange: MenuPrimitive.Root.Props["onOpenChange"] = (
|
|
194
|
+
nextOpen,
|
|
195
|
+
details
|
|
196
|
+
) => {
|
|
197
|
+
if (sound) playCue(nextOpen ? "bloom" : "droplet")
|
|
198
|
+
onOpenChange?.(nextOpen, details)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<DropdownMenu
|
|
203
|
+
defaultOpen={defaultOpen}
|
|
204
|
+
onOpenChange={handleOpenChange}
|
|
205
|
+
open={open}
|
|
206
|
+
>
|
|
207
|
+
<DropdownMenuTrigger
|
|
208
|
+
disabled={busy}
|
|
209
|
+
render={
|
|
210
|
+
<Button
|
|
211
|
+
// Spelled out rather than left to the name-from-contents rule: the
|
|
212
|
+
// avatar sits inside the button and the handle is a second line.
|
|
213
|
+
aria-label={`${user.name}${user.username ? ` ${user.username}` : ""}, account menu`}
|
|
214
|
+
className={cn(
|
|
215
|
+
"h-auto w-full justify-between py-1.5 pl-1.5 sm:h-auto",
|
|
216
|
+
className
|
|
217
|
+
)}
|
|
218
|
+
data-slot="profile-card"
|
|
219
|
+
loading={busy}
|
|
220
|
+
sound={sound}
|
|
221
|
+
variant="outline"
|
|
222
|
+
>
|
|
223
|
+
<span className="flex w-full min-w-0 items-center justify-start gap-2">
|
|
224
|
+
<UserAvatar
|
|
225
|
+
// Decorative: the name beside it already labels the card.
|
|
226
|
+
alt=""
|
|
227
|
+
initials={user.initials}
|
|
228
|
+
name={user.name}
|
|
229
|
+
src={user.avatar}
|
|
230
|
+
/>
|
|
231
|
+
<span className="flex min-w-0 flex-col text-left">
|
|
232
|
+
<span className="truncate font-medium">{user.name}</span>
|
|
233
|
+
{user.username ? (
|
|
234
|
+
<span className="truncate text-xs font-normal text-muted-foreground">
|
|
235
|
+
{user.username}
|
|
236
|
+
</span>
|
|
237
|
+
) : null}
|
|
238
|
+
</span>
|
|
239
|
+
</span>
|
|
240
|
+
<ChevronsUpDownIcon aria-hidden="true" className="size-3.5" />
|
|
241
|
+
</Button>
|
|
242
|
+
}
|
|
243
|
+
/>
|
|
244
|
+
<DropdownMenuContent
|
|
245
|
+
align={align}
|
|
246
|
+
// Content-width menu (not the card's width) so `align` reads: a popup
|
|
247
|
+
// locked to `--anchor-width` lands in the same place for every value.
|
|
248
|
+
// `!` because the popup's own `not-[class*='w-']:min-w-32` compiles to
|
|
249
|
+
// a higher-specificity `:not(:is())` that would otherwise win.
|
|
250
|
+
className="min-w-44! origin-(--transform-origin) transition-[opacity,scale,translate] duration-150 ease-smooth-out data-ending-style:scale-98 data-ending-style:opacity-0 data-ending-style:duration-100 data-starting-style:scale-98 data-starting-style:opacity-0 data-[side=bottom]:data-ending-style:-translate-y-1 data-[side=bottom]:data-starting-style:-translate-y-1 data-[side=top]:data-ending-style:translate-y-1 data-[side=top]:data-starting-style:translate-y-1 motion-reduce:transition-none"
|
|
251
|
+
side={side}
|
|
252
|
+
sideOffset={sideOffset}
|
|
253
|
+
>
|
|
254
|
+
{groups.map((group, index) => (
|
|
255
|
+
<React.Fragment key={group.label ?? `group-${group.actions[0]?.id}`}>
|
|
256
|
+
{index > 0 ? <DropdownMenuSeparator /> : null}
|
|
257
|
+
<DropdownMenuGroup>
|
|
258
|
+
{group.label ? (
|
|
259
|
+
<DropdownMenuLabel>{group.label}</DropdownMenuLabel>
|
|
260
|
+
) : null}
|
|
261
|
+
{group.actions.map((item) => (
|
|
262
|
+
<DropdownMenuItem
|
|
263
|
+
disabled={item.disabled}
|
|
264
|
+
key={item.id}
|
|
265
|
+
onClick={() => select(item)}
|
|
266
|
+
variant={item.variant}
|
|
267
|
+
>
|
|
268
|
+
{item.icon}
|
|
269
|
+
<span>{item.label}</span>
|
|
270
|
+
{item.shortcut ? (
|
|
271
|
+
<DropdownMenuShortcut>{item.shortcut}</DropdownMenuShortcut>
|
|
272
|
+
) : null}
|
|
273
|
+
</DropdownMenuItem>
|
|
274
|
+
))}
|
|
275
|
+
</DropdownMenuGroup>
|
|
276
|
+
</React.Fragment>
|
|
277
|
+
))}
|
|
278
|
+
</DropdownMenuContent>
|
|
279
|
+
</DropdownMenu>
|
|
280
|
+
)
|
|
281
|
+
}
|