@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.
Files changed (34) hide show
  1. package/dist-lib/components/blocks/auth/__tests__/signup-block.test.d.ts +1 -0
  2. package/dist-lib/components/blocks/auth/signup-block.d.ts +16 -1
  3. package/dist-lib/components/blocks/nav/__tests__/sidebar-block.test.d.ts +1 -0
  4. package/dist-lib/components/blocks/nav/sidebar-block.d.ts +67 -0
  5. package/dist-lib/components/blocks/profile/__tests__/profile-card-block.test.d.ts +1 -0
  6. package/dist-lib/components/blocks/profile/profile-card-block.d.ts +77 -0
  7. package/dist-lib/components/composed/__tests__/user-avatar.test.d.ts +1 -0
  8. package/dist-lib/components/composed/user-avatar.d.ts +30 -0
  9. package/dist-lib/components/motion/animated-sidebar.d.ts +98 -0
  10. package/dist-lib/components/motion/shared-layout-bg.d.ts +13 -0
  11. package/dist-lib/components/ui/avatar.d.ts +6 -0
  12. package/dist-lib/components/ui/menu.d.ts +43 -0
  13. package/dist-lib/components/ui/skeleton.d.ts +2 -0
  14. package/dist-lib/index.d.ts +9 -0
  15. package/dist-lib/index.js +1916 -874
  16. package/dist-lib/index.js.map +1 -1
  17. package/dist-lib/lib/ease.d.ts +46 -0
  18. package/package.json +1 -1
  19. package/src/components/blocks/auth/__tests__/signup-block.test.tsx +176 -0
  20. package/src/components/blocks/auth/signup-block.tsx +43 -10
  21. package/src/components/blocks/nav/__tests__/sidebar-block.test.tsx +125 -0
  22. package/src/components/blocks/nav/sidebar-block.tsx +264 -0
  23. package/src/components/blocks/profile/__tests__/profile-card-block.test.tsx +134 -0
  24. package/src/components/blocks/profile/profile-card-block.tsx +281 -0
  25. package/src/components/composed/__tests__/user-avatar.test.tsx +85 -0
  26. package/src/components/composed/user-avatar.tsx +98 -0
  27. package/src/components/motion/animated-sidebar.tsx +1145 -0
  28. package/src/components/motion/shared-layout-bg.tsx +151 -0
  29. package/src/components/ui/avatar.tsx +52 -0
  30. package/src/components/ui/menu.tsx +352 -0
  31. package/src/components/ui/skeleton.tsx +18 -0
  32. package/src/index.css +4 -0
  33. package/src/index.ts +11 -0
  34. 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
+ }