@exegia/corpora-ui 0.8.0 → 0.10.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/linked-accounts-block.d.ts +7 -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 +1887 -855
- 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__/linked-accounts-block.test.tsx +18 -0
- package/src/components/blocks/auth/linked-accounts-block.tsx +10 -2
- 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,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
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { render, screen } from "@testing-library/react"
|
|
3
|
+
|
|
4
|
+
import { UserAvatar, initialsFrom } from "../user-avatar"
|
|
5
|
+
|
|
6
|
+
describe("initialsFrom", () => {
|
|
7
|
+
test("takes the first and last word", () => {
|
|
8
|
+
expect(initialsFrom("Jenny Hamilton")).toBe("JH")
|
|
9
|
+
expect(initialsFrom("ada b lovelace")).toBe("AL")
|
|
10
|
+
expect(initialsFrom("luna")).toBe("L")
|
|
11
|
+
expect(initialsFrom(" ")).toBe("")
|
|
12
|
+
})
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
describe("UserAvatar", () => {
|
|
16
|
+
test("shows initials right away when there is no image", () => {
|
|
17
|
+
const { container } = render(<UserAvatar name="Luna Wyen" />)
|
|
18
|
+
|
|
19
|
+
expect(screen.getByText("LW")).toBeDefined()
|
|
20
|
+
expect(container.querySelector('[data-slot="avatar-skeleton"]')).toBeNull()
|
|
21
|
+
expect(container.querySelector("img")).toBeNull()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test("holds a skeleton while a passed image is still loading", () => {
|
|
25
|
+
// happy-dom resolves every Image synchronously (complete, naturalWidth 0),
|
|
26
|
+
// so stand in a request that never answers.
|
|
27
|
+
const RealImage = window.Image
|
|
28
|
+
window.Image = class {
|
|
29
|
+
complete = false
|
|
30
|
+
naturalWidth = 0
|
|
31
|
+
onload: (() => void) | null = null
|
|
32
|
+
onerror: (() => void) | null = null
|
|
33
|
+
src = ""
|
|
34
|
+
} as unknown as typeof window.Image
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const { container } = render(
|
|
38
|
+
<UserAvatar name="Jenny Hamilton" src="https://example.com/j.jpg" />
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
expect(
|
|
42
|
+
container.querySelector('[data-slot="avatar-skeleton"]')
|
|
43
|
+
).not.toBeNull()
|
|
44
|
+
expect(screen.queryByText("JH")).toBeNull()
|
|
45
|
+
expect(
|
|
46
|
+
container
|
|
47
|
+
.querySelector('[data-slot="user-avatar"]')
|
|
48
|
+
?.hasAttribute("data-loading")
|
|
49
|
+
).toBe(true)
|
|
50
|
+
} finally {
|
|
51
|
+
window.Image = RealImage
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test("settles on the initials when the image fails", () => {
|
|
56
|
+
// The stubbed DOM never fetches, so every src lands in the error branch.
|
|
57
|
+
const { container } = render(
|
|
58
|
+
<UserAvatar name="Jenny Hamilton" src="https://example.com/missing.jpg" />
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
expect(screen.getByText("JH")).toBeDefined()
|
|
62
|
+
expect(container.querySelector('[data-slot="avatar-skeleton"]')).toBeNull()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test("loading forces the skeleton over a resolvable image", () => {
|
|
66
|
+
const { container } = render(
|
|
67
|
+
<UserAvatar
|
|
68
|
+
loading
|
|
69
|
+
name="Jenny Hamilton"
|
|
70
|
+
src="https://example.com/j.jpg"
|
|
71
|
+
/>
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
expect(
|
|
75
|
+
container.querySelector('[data-slot="avatar-skeleton"]')
|
|
76
|
+
).not.toBeNull()
|
|
77
|
+
expect(container.querySelector("img")).toBeNull()
|
|
78
|
+
expect(screen.queryByText("JH")).toBeNull()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test("explicit initials win over the derived ones", () => {
|
|
82
|
+
render(<UserAvatar initials="EX" name="Jenny Hamilton" />)
|
|
83
|
+
expect(screen.getByText("EX")).toBeDefined()
|
|
84
|
+
})
|
|
85
|
+
})
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
Avatar,
|
|
7
|
+
AvatarFallback,
|
|
8
|
+
AvatarImage,
|
|
9
|
+
type AvatarPrimitive,
|
|
10
|
+
} from "@/components/ui/avatar"
|
|
11
|
+
import { Skeleton } from "@/components/ui/skeleton"
|
|
12
|
+
import { cn } from "@/lib/utils"
|
|
13
|
+
|
|
14
|
+
type ImageStatus = Parameters<
|
|
15
|
+
NonNullable<AvatarPrimitive.Image.Props["onLoadingStatusChange"]>
|
|
16
|
+
>[0]
|
|
17
|
+
|
|
18
|
+
export interface UserAvatarProps extends Omit<
|
|
19
|
+
AvatarPrimitive.Root.Props,
|
|
20
|
+
"children"
|
|
21
|
+
> {
|
|
22
|
+
/** Image URL. Without one the initials show immediately — no skeleton. */
|
|
23
|
+
src?: string
|
|
24
|
+
/** Drives the initials, and the alt text unless `alt` overrides it. */
|
|
25
|
+
name?: string
|
|
26
|
+
/** Overrides the initials derived from `name`. */
|
|
27
|
+
initials?: string
|
|
28
|
+
/**
|
|
29
|
+
* Alt text for the image. Pass "" when adjacent text already names the
|
|
30
|
+
* person — the image is then decorative.
|
|
31
|
+
*/
|
|
32
|
+
alt?: string
|
|
33
|
+
/**
|
|
34
|
+
* Force the skeleton, for when the identity itself is still being fetched.
|
|
35
|
+
* Omitted, it follows the image: a passed `src` skeletons until it resolves.
|
|
36
|
+
*/
|
|
37
|
+
loading?: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** "Jenny Hamilton" → "JH", "luna" → "L". */
|
|
41
|
+
export function initialsFrom(name: string): string {
|
|
42
|
+
const parts = name.trim().split(/\s+/).filter(Boolean)
|
|
43
|
+
if (parts.length === 0) return ""
|
|
44
|
+
const first = parts[0]?.[0] ?? ""
|
|
45
|
+
const last = parts.length > 1 ? (parts.at(-1)?.[0] ?? "") : ""
|
|
46
|
+
return (first + last).toUpperCase()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Identity avatar: an image when one is given, initials otherwise.
|
|
51
|
+
*
|
|
52
|
+
* A passed `src` is assumed to be remote, so the slot holds a skeleton until
|
|
53
|
+
* the image resolves rather than flashing initials that are about to be
|
|
54
|
+
* replaced. A failed load settles on the initials.
|
|
55
|
+
*/
|
|
56
|
+
export function UserAvatar({
|
|
57
|
+
src,
|
|
58
|
+
name = "",
|
|
59
|
+
initials,
|
|
60
|
+
alt,
|
|
61
|
+
loading: loadingProp,
|
|
62
|
+
className,
|
|
63
|
+
...props
|
|
64
|
+
}: UserAvatarProps): React.ReactElement {
|
|
65
|
+
const [status, setStatus] = React.useState<ImageStatus>("idle")
|
|
66
|
+
const loading =
|
|
67
|
+
loadingProp ??
|
|
68
|
+
(src !== undefined && (status === "idle" || status === "loading"))
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<Avatar
|
|
72
|
+
className={cn("size-8", className)}
|
|
73
|
+
data-loading={loading || undefined}
|
|
74
|
+
data-slot="user-avatar"
|
|
75
|
+
{...props}
|
|
76
|
+
>
|
|
77
|
+
{/* Held back while forced-loading: a loaded image would hide the
|
|
78
|
+
fallback the skeleton lives in. */}
|
|
79
|
+
{src === undefined || loadingProp === true ? null : (
|
|
80
|
+
<AvatarImage
|
|
81
|
+
alt={alt ?? name}
|
|
82
|
+
onLoadingStatusChange={setStatus}
|
|
83
|
+
src={src}
|
|
84
|
+
/>
|
|
85
|
+
)}
|
|
86
|
+
<AvatarFallback>
|
|
87
|
+
{loading ? (
|
|
88
|
+
<Skeleton
|
|
89
|
+
className="size-full rounded-full"
|
|
90
|
+
data-slot="avatar-skeleton"
|
|
91
|
+
/>
|
|
92
|
+
) : (
|
|
93
|
+
(initials ?? initialsFrom(name))
|
|
94
|
+
)}
|
|
95
|
+
</AvatarFallback>
|
|
96
|
+
</Avatar>
|
|
97
|
+
)
|
|
98
|
+
}
|