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