@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.
- 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 +1896 -864
- 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/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,46 @@
|
|
|
1
|
+
export declare const EASE_OUT: readonly [0.16, 1, 0.3, 1];
|
|
2
|
+
export declare const EASE_IN_OUT: readonly [0.77, 0, 0.175, 1];
|
|
3
|
+
export declare const EASE_DRAWER: readonly [0.32, 0.72, 0, 1];
|
|
4
|
+
/** CSS string form of EASE_OUT for inline style transitions. */
|
|
5
|
+
export declare const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
|
|
6
|
+
/** Press feedback on buttons and other tappable surfaces. */
|
|
7
|
+
export declare const SPRING_PRESS: {
|
|
8
|
+
readonly type: "spring";
|
|
9
|
+
readonly stiffness: 500;
|
|
10
|
+
readonly damping: 30;
|
|
11
|
+
readonly mass: 0.6;
|
|
12
|
+
};
|
|
13
|
+
/** Content swaps — label/icon slots trading places inside a control. */
|
|
14
|
+
export declare const SPRING_SWAP: {
|
|
15
|
+
readonly type: "spring";
|
|
16
|
+
readonly stiffness: 460;
|
|
17
|
+
readonly damping: 30;
|
|
18
|
+
readonly mass: 0.55;
|
|
19
|
+
};
|
|
20
|
+
/** Overlay panel entrances — modals and sheets summoned by pointer. */
|
|
21
|
+
export declare const SPRING_PANEL: {
|
|
22
|
+
readonly type: "spring";
|
|
23
|
+
readonly stiffness: 420;
|
|
24
|
+
readonly damping: 40;
|
|
25
|
+
readonly mass: 0.5;
|
|
26
|
+
};
|
|
27
|
+
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
|
|
28
|
+
export declare const SPRING_LAYOUT: {
|
|
29
|
+
readonly type: "spring";
|
|
30
|
+
readonly stiffness: 360;
|
|
31
|
+
readonly damping: 32;
|
|
32
|
+
readonly mass: 0.6;
|
|
33
|
+
};
|
|
34
|
+
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
|
|
35
|
+
export declare const SPRING_MOUSE: {
|
|
36
|
+
readonly stiffness: 200;
|
|
37
|
+
readonly damping: 15;
|
|
38
|
+
readonly mass: 0.3;
|
|
39
|
+
};
|
|
40
|
+
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
|
|
41
|
+
* so the value follows the pointer butterily and never rebounds off an end. */
|
|
42
|
+
export declare const SPRING_GLIDE: {
|
|
43
|
+
readonly stiffness: 700;
|
|
44
|
+
readonly damping: 50;
|
|
45
|
+
readonly mass: 0.5;
|
|
46
|
+
};
|
package/package.json
CHANGED
|
@@ -0,0 +1,125 @@
|
|
|
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 { SidebarBlock, type SidebarNavSection } from "../sidebar-block"
|
|
6
|
+
|
|
7
|
+
const SECTIONS: SidebarNavSection[] = [
|
|
8
|
+
{
|
|
9
|
+
id: "work",
|
|
10
|
+
label: "Research",
|
|
11
|
+
items: [
|
|
12
|
+
{ id: "search", label: "Search" },
|
|
13
|
+
{
|
|
14
|
+
id: "library",
|
|
15
|
+
label: "Library",
|
|
16
|
+
items: [
|
|
17
|
+
{ id: "manuscripts", label: "Manuscripts" },
|
|
18
|
+
{ id: "codices", label: "Codices" },
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
{ id: "workspace", items: [{ id: "settings", label: "Settings" }] },
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
describe("SidebarBlock", () => {
|
|
27
|
+
test("renders the sections and their entries", () => {
|
|
28
|
+
render(<SidebarBlock ariaLabel="Main" sections={SECTIONS} />)
|
|
29
|
+
|
|
30
|
+
expect(screen.getByRole("navigation", { name: "Main" })).toBeDefined()
|
|
31
|
+
expect(screen.getByText("Research")).toBeDefined()
|
|
32
|
+
expect(screen.getByRole("button", { name: "Search" })).toBeDefined()
|
|
33
|
+
expect(screen.getByRole("button", { name: "Settings" })).toBeDefined()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test("marks the active entry", () => {
|
|
37
|
+
render(<SidebarBlock activeId="search" sections={SECTIONS} />)
|
|
38
|
+
|
|
39
|
+
expect(
|
|
40
|
+
screen
|
|
41
|
+
.getByRole("button", { name: "Search" })
|
|
42
|
+
.getAttribute("aria-current")
|
|
43
|
+
).toBe("page")
|
|
44
|
+
expect(
|
|
45
|
+
screen
|
|
46
|
+
.getByRole("button", { name: "Settings" })
|
|
47
|
+
.getAttribute("aria-current")
|
|
48
|
+
).toBeNull()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test("reports selections through onNavigate, after the entry's onSelect", async () => {
|
|
52
|
+
const user = userEvent.setup()
|
|
53
|
+
const calls: string[] = []
|
|
54
|
+
render(
|
|
55
|
+
<SidebarBlock
|
|
56
|
+
onNavigate={(item) => calls.push(`navigate:${item.id}`)}
|
|
57
|
+
sections={[
|
|
58
|
+
{
|
|
59
|
+
id: "work",
|
|
60
|
+
items: [
|
|
61
|
+
{
|
|
62
|
+
id: "search",
|
|
63
|
+
label: "Search",
|
|
64
|
+
onSelect: () => calls.push("select:search"),
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
]}
|
|
69
|
+
/>
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
await user.click(screen.getByRole("button", { name: "Search" }))
|
|
73
|
+
|
|
74
|
+
expect(calls).toEqual(["select:search", "navigate:search"])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test("an entry with children expands instead of navigating", async () => {
|
|
78
|
+
const user = userEvent.setup()
|
|
79
|
+
const onNavigate = mock(() => {})
|
|
80
|
+
render(<SidebarBlock onNavigate={onNavigate} sections={SECTIONS} />)
|
|
81
|
+
|
|
82
|
+
const parent = screen.getByRole("button", { name: "Library" })
|
|
83
|
+
expect(parent.getAttribute("aria-expanded")).toBe("false")
|
|
84
|
+
expect(screen.queryByRole("button", { name: "Manuscripts" })).toBeNull()
|
|
85
|
+
|
|
86
|
+
await user.click(parent)
|
|
87
|
+
|
|
88
|
+
expect(
|
|
89
|
+
await screen.findByRole("button", { name: "Manuscripts" })
|
|
90
|
+
).toBeDefined()
|
|
91
|
+
expect(parent.getAttribute("aria-expanded")).toBe("true")
|
|
92
|
+
expect(onNavigate).not.toHaveBeenCalled()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test("starts expanded when one of its children is active", async () => {
|
|
96
|
+
render(<SidebarBlock activeId="codices" sections={SECTIONS} />)
|
|
97
|
+
|
|
98
|
+
const child = await screen.findByRole("button", { name: "Codices" })
|
|
99
|
+
expect(child.getAttribute("aria-current")).toBe("page")
|
|
100
|
+
expect(
|
|
101
|
+
screen
|
|
102
|
+
.getByRole("button", { name: "Library" })
|
|
103
|
+
.getAttribute("aria-expanded")
|
|
104
|
+
).toBe("true")
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test("renders href entries as links", () => {
|
|
108
|
+
render(
|
|
109
|
+
<SidebarBlock
|
|
110
|
+
sections={[
|
|
111
|
+
{
|
|
112
|
+
id: "work",
|
|
113
|
+
items: [
|
|
114
|
+
{ id: "docs", label: "Docs", href: "/docs", target: "_blank" },
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
]}
|
|
118
|
+
/>
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
const link = screen.getByRole("link", { name: "Docs" })
|
|
122
|
+
expect(link.getAttribute("href")).toBe("/docs")
|
|
123
|
+
expect(link.getAttribute("rel")).toBe("noreferrer noopener")
|
|
124
|
+
})
|
|
125
|
+
})
|
|
@@ -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
|
+
});
|