Skip to content

Commit d13b742

Browse files
feat(frontend): add Avatar, Breadcrumb, Toggle, and Progress UI components (#1168)
- Add Avatar component with image/initials fallback, size/shape variants, and status indicator - Add Breadcrumb component with chevron/slash separators and responsive collapsing - Add Toggle component with controlled/uncontrolled state, keyboard accessibility, and label support - Add Progress component with size/variant options, label formatters, and ARIA support - All components include TypeScript types, follow design system tokens, and have Jest/RTL unit tests Closes #1094 Closes #1095 Closes #1096 Closes #1097 Co-authored-by: openhands <openhands@all-hands.dev>
1 parent fc52d03 commit d13b742

9 files changed

Lines changed: 866 additions & 1 deletion

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import React from "react"
2+
import { render, screen, fireEvent } from "@testing-library/react"
3+
import { Avatar } from "@/components/ui/avatar"
4+
5+
describe("Avatar Component", () => {
6+
it("renders with fallback initials from alt text", () => {
7+
render(<Avatar alt="John Doe" data-testid="avatar" />)
8+
expect(screen.getByTestId("avatar")).toBeInTheDocument()
9+
expect(screen.getByText("J")).toBeInTheDocument()
10+
})
11+
12+
it("renders with custom fallback prop", () => {
13+
render(<Avatar fallback="JD" data-testid="avatar" />)
14+
expect(screen.getByText("JD")).toBeInTheDocument()
15+
})
16+
17+
it("renders image when src is provided", () => {
18+
render(<Avatar src="/test.jpg" alt="User" data-testid="avatar" />)
19+
const img = screen.getByRole("img")
20+
expect(img).toHaveAttribute("src", "/test.jpg")
21+
expect(img).toHaveAttribute("alt", "User")
22+
})
23+
24+
it("falls back to initials on image error", () => {
25+
render(<Avatar src="/broken.jpg" alt="Jane" data-testid="avatar" />)
26+
const img = screen.getByRole("img")
27+
fireEvent.error(img)
28+
expect(screen.getByText("J")).toBeInTheDocument()
29+
expect(screen.queryByRole("img")).not.toBeInTheDocument()
30+
})
31+
32+
it("renders size variants with correct classes", () => {
33+
const { rerender } = render(<Avatar size="xs" data-testid="avatar" />)
34+
expect(screen.getByTestId("avatar")).toHaveClass("h-6", "w-6", "text-[10px]")
35+
36+
rerender(<Avatar size="xl" data-testid="avatar" />)
37+
expect(screen.getByTestId("avatar")).toHaveClass("h-16", "w-16", "text-lg")
38+
39+
rerender(<Avatar size="2xl" data-testid="avatar" />)
40+
expect(screen.getByTestId("avatar")).toHaveClass("h-24", "w-24", "text-2xl")
41+
})
42+
43+
it("renders square shape variant", () => {
44+
render(<Avatar shape="square" data-testid="avatar" />)
45+
expect(screen.getByTestId("avatar")).toHaveClass("rounded-lg")
46+
})
47+
48+
it("renders circle shape by default", () => {
49+
render(<Avatar data-testid="avatar" />)
50+
expect(screen.getByTestId("avatar")).toHaveClass("rounded-full")
51+
})
52+
53+
it("renders status indicator when provided", () => {
54+
render(<Avatar status="online" data-testid="avatar" />)
55+
const indicator = screen.getByTestId("avatar-status-indicator")
56+
expect(indicator).toBeInTheDocument()
57+
expect(indicator).toHaveClass("bg-green-500")
58+
expect(indicator).toHaveAttribute("aria-label", "online")
59+
})
60+
61+
it("renders all status variants", () => {
62+
const { rerender } = render(<Avatar status="online" data-testid="avatar" />)
63+
expect(screen.getByTestId("avatar-status-indicator")).toHaveClass("bg-green-500")
64+
65+
rerender(<Avatar status="away" data-testid="avatar" />)
66+
expect(screen.getByTestId("avatar-status-indicator")).toHaveClass("bg-yellow-500")
67+
68+
rerender(<Avatar status="busy" data-testid="avatar" />)
69+
expect(screen.getByTestId("avatar-status-indicator")).toHaveClass("bg-red-500")
70+
71+
rerender(<Avatar status="offline" data-testid="avatar" />)
72+
expect(screen.getByTestId("avatar-status-indicator")).toHaveClass("bg-gray-400")
73+
})
74+
75+
it("does not render status indicator when status is not provided", () => {
76+
render(<Avatar data-testid="avatar" />)
77+
expect(screen.queryByTestId("avatar-status-indicator")).not.toBeInTheDocument()
78+
})
79+
80+
it("applies custom className", () => {
81+
render(<Avatar className="custom-class" data-testid="avatar" />)
82+
expect(screen.getByTestId("avatar")).toHaveClass("custom-class")
83+
})
84+
85+
it("forwards ref to the root div", () => {
86+
const ref = React.createRef<HTMLDivElement>()
87+
render(<Avatar ref={ref} />)
88+
expect(ref.current).not.toBeNull()
89+
expect(ref.current?.tagName).toBe("DIV")
90+
})
91+
92+
it("has data-slot attribute", () => {
93+
render(<Avatar data-testid="avatar" />)
94+
expect(screen.getByTestId("avatar")).toHaveAttribute("data-slot", "avatar")
95+
})
96+
})
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import React from "react"
2+
import { render, screen } from "@testing-library/react"
3+
import { Breadcrumb } from "@/components/ui/breadcrumb"
4+
5+
const defaultItems = [
6+
{ label: "Home", href: "/" },
7+
{ label: "Dashboard", href: "/dashboard" },
8+
{ label: "Events" },
9+
]
10+
11+
describe("Breadcrumb Component", () => {
12+
it("renders all items", () => {
13+
render(<Breadcrumb items={defaultItems} data-testid="breadcrumb" />)
14+
expect(screen.getByText("Home")).toBeInTheDocument()
15+
expect(screen.getByText("Dashboard")).toBeInTheDocument()
16+
expect(screen.getByText("Events")).toBeInTheDocument()
17+
})
18+
19+
it("renders links for non-last items with href", () => {
20+
render(<Breadcrumb items={defaultItems} />)
21+
const homeLink = screen.getByText("Home").closest("a")
22+
expect(homeLink).toHaveAttribute("href", "/")
23+
const dashLink = screen.getByText("Dashboard").closest("a")
24+
expect(dashLink).toHaveAttribute("href", "/dashboard")
25+
})
26+
27+
it("renders last item as plain text with aria-current", () => {
28+
render(<Breadcrumb items={defaultItems} />)
29+
const lastItem = screen.getByText("Events")
30+
expect(lastItem).toHaveAttribute("aria-current", "page")
31+
expect(lastItem).toHaveClass("font-medium")
32+
})
33+
34+
it("renders chevron separators by default", () => {
35+
const { container } = render(<Breadcrumb items={defaultItems} />)
36+
const chevrons = container.querySelectorAll("svg.lucide-chevron-right")
37+
expect(chevrons).toHaveLength(2)
38+
})
39+
40+
it("renders slash separators when specified", () => {
41+
const { container } = render(<Breadcrumb items={defaultItems} separator="slash" />)
42+
const slashes = container.querySelectorAll("svg.lucide-slash")
43+
expect(slashes).toHaveLength(2)
44+
})
45+
46+
it("has correct aria-label", () => {
47+
render(<Breadcrumb items={defaultItems} />)
48+
expect(screen.getByLabelText("Breadcrumb")).toBeInTheDocument()
49+
})
50+
51+
it("collapses items when maxItems is set", () => {
52+
const manyItems = [
53+
{ label: "Home", href: "/" },
54+
{ label: "Section", href: "/section" },
55+
{ label: "Category", href: "/category" },
56+
{ label: "Subcategory", href: "/subcategory" },
57+
{ label: "Item" },
58+
]
59+
render(<Breadcrumb items={manyItems} maxItems={3} />)
60+
expect(screen.getByText("Home")).toBeInTheDocument()
61+
expect(screen.getByText("...")).toBeInTheDocument()
62+
expect(screen.getByText("Item")).toBeInTheDocument()
63+
})
64+
65+
it("applies custom className", () => {
66+
render(<Breadcrumb items={defaultItems} className="custom-class" />)
67+
expect(screen.getByLabelText("Breadcrumb")).toHaveClass("custom-class")
68+
})
69+
70+
it("forwards ref", () => {
71+
const ref = React.createRef<HTMLElement>()
72+
render(<Breadcrumb items={defaultItems} ref={ref} />)
73+
expect(ref.current).not.toBeNull()
74+
expect(ref.current?.tagName).toBe("NAV")
75+
})
76+
77+
it("has data-slot attribute", () => {
78+
render(<Breadcrumb items={defaultItems} data-testid="breadcrumb" />)
79+
expect(screen.getByTestId("breadcrumb")).toHaveAttribute("data-slot", "breadcrumb")
80+
})
81+
})
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import React from "react"
2+
import { render, screen } from "@testing-library/react"
3+
import { Progress } from "@/components/ui/progress"
4+
5+
describe("Progress Component", () => {
6+
it("renders with role progressbar", () => {
7+
render(<Progress value={50} data-testid="progress" />)
8+
const el = screen.getByTestId("progress")
9+
expect(el).toHaveAttribute("role", "progressbar")
10+
})
11+
12+
it("sets aria-valuenow correctly", () => {
13+
render(<Progress value={50} data-testid="progress" />)
14+
expect(screen.getByTestId("progress")).toHaveAttribute("aria-valuenow", "50")
15+
})
16+
17+
it("clamps value to min of 0", () => {
18+
render(<Progress value={-10} data-testid="progress" />)
19+
expect(screen.getByTestId("progress")).toHaveAttribute("aria-valuenow", "0")
20+
})
21+
22+
it("clamps value to max", () => {
23+
render(<Progress value={150} max={100} data-testid="progress" />)
24+
expect(screen.getByTestId("progress")).toHaveAttribute("aria-valuenow", "100")
25+
})
26+
27+
it("renders with custom max value", () => {
28+
render(<Progress value={3} max={10} data-testid="progress" />)
29+
expect(screen.getByTestId("progress")).toHaveAttribute("aria-valuemax", "10")
30+
expect(screen.getByTestId("progress")).toHaveAttribute("aria-valuenow", "3")
31+
})
32+
33+
it("sets data-value attribute with percentage", () => {
34+
render(<Progress value={25} data-testid="progress" />)
35+
expect(screen.getByTestId("progress")).toHaveAttribute("data-value", "25")
36+
})
37+
38+
it("renders size variants", () => {
39+
const { rerender } = render(<Progress value={50} size="sm" data-testid="progress" />)
40+
expect(screen.getByTestId("progress")).toHaveClass("h-1.5")
41+
42+
rerender(<Progress value={50} size="lg" data-testid="progress" />)
43+
expect(screen.getByTestId("progress")).toHaveClass("h-4")
44+
})
45+
46+
it("renders variant colors on inner bar", () => {
47+
const { container } = render(<Progress value={50} variant="success" data-testid="progress" />)
48+
const bar = container.querySelector('[class*="bg-green-500"]')
49+
expect(bar).toBeInTheDocument()
50+
})
51+
52+
it("renders terminal variant", () => {
53+
const { container } = render(<Progress value={50} variant="terminal" data-testid="progress" />)
54+
const bar = container.querySelector('[class*="bg-terminal-green"]')
55+
expect(bar).toBeInTheDocument()
56+
})
57+
58+
it("shows outside label when showLabel is true", () => {
59+
render(<Progress value={75} showLabel data-testid="progress" />)
60+
expect(screen.getByText("75%")).toBeInTheDocument()
61+
})
62+
63+
it("uses custom label formatter", () => {
64+
render(
65+
<Progress
66+
value={3}
67+
max={10}
68+
showLabel
69+
labelFormatter={(v, m) => `${v}/${m} done`}
70+
data-testid="progress"
71+
/>
72+
)
73+
expect(screen.getByText("3/10 done")).toBeInTheDocument()
74+
})
75+
76+
it("does not show label by default", () => {
77+
render(<Progress value={50} data-testid="progress" />)
78+
expect(screen.queryByText("50%")).not.toBeInTheDocument()
79+
})
80+
81+
it("applies custom className", () => {
82+
render(<Progress value={50} className="custom-class" data-testid="progress" />)
83+
expect(screen.getByTestId("progress")).toHaveClass("custom-class")
84+
})
85+
86+
it("forwards ref", () => {
87+
const ref = React.createRef<HTMLDivElement>()
88+
render(<Progress value={50} ref={ref} />)
89+
expect(ref.current).not.toBeNull()
90+
expect(ref.current?.getAttribute("role")).toBe("progressbar")
91+
})
92+
93+
it("has data-slot attribute", () => {
94+
render(<Progress value={50} data-testid="progress" />)
95+
expect(screen.getByTestId("progress")).toHaveAttribute("data-slot", "progress")
96+
})
97+
98+
it("applies correct width percentage to inner bar", () => {
99+
const { container } = render(<Progress value={42} data-testid="progress" />)
100+
const track = screen.getByTestId("progress")
101+
const bar = track.firstChild as HTMLElement
102+
expect(bar).toHaveStyle({ width: "42%" })
103+
})
104+
})
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import React from "react"
2+
import { render, screen, fireEvent } from "@testing-library/react"
3+
import { Toggle } from "@/components/ui/toggle"
4+
5+
describe("Toggle Component", () => {
6+
it("renders as a switch role", () => {
7+
render(<Toggle data-testid="toggle" />)
8+
const toggle = screen.getByTestId("toggle")
9+
expect(toggle).toHaveAttribute("role", "switch")
10+
expect(toggle).toHaveAttribute("aria-checked", "false")
11+
})
12+
13+
it("toggles on click", () => {
14+
render(<Toggle data-testid="toggle" />)
15+
const toggle = screen.getByTestId("toggle")
16+
fireEvent.click(toggle)
17+
expect(toggle).toHaveAttribute("aria-checked", "true")
18+
fireEvent.click(toggle)
19+
expect(toggle).toHaveAttribute("aria-checked", "false")
20+
})
21+
22+
it("toggles on Space key", () => {
23+
render(<Toggle data-testid="toggle" />)
24+
const toggle = screen.getByTestId("toggle")
25+
fireEvent.keyDown(toggle, { key: " " })
26+
expect(toggle).toHaveAttribute("aria-checked", "true")
27+
})
28+
29+
it("toggles on Enter key", () => {
30+
render(<Toggle data-testid="toggle" />)
31+
const toggle = screen.getByTestId("toggle")
32+
fireEvent.keyDown(toggle, { key: "Enter" })
33+
expect(toggle).toHaveAttribute("aria-checked", "true")
34+
})
35+
36+
it("calls onCheckedChange when toggled", () => {
37+
const onChange = jest.fn()
38+
render(<Toggle onCheckedChange={onChange} data-testid="toggle" />)
39+
fireEvent.click(screen.getByTestId("toggle"))
40+
expect(onChange).toHaveBeenCalledWith(true)
41+
fireEvent.click(screen.getByTestId("toggle"))
42+
expect(onChange).toHaveBeenCalledWith(false)
43+
})
44+
45+
it("respects controlled checked prop", () => {
46+
const { rerender } = render(<Toggle checked={true} data-testid="toggle" />)
47+
expect(screen.getByTestId("toggle")).toHaveAttribute("aria-checked", "true")
48+
49+
rerender(<Toggle checked={false} data-testid="toggle" />)
50+
expect(screen.getByTestId("toggle")).toHaveAttribute("aria-checked", "false")
51+
})
52+
53+
it("does not toggle when disabled", () => {
54+
render(<Toggle disabled data-testid="toggle" />)
55+
const toggle = screen.getByTestId("toggle")
56+
fireEvent.click(toggle)
57+
expect(toggle).toHaveAttribute("aria-checked", "false")
58+
expect(toggle).toBeDisabled()
59+
})
60+
61+
it("renders with label", () => {
62+
render(<Toggle label="Enable notifications" />)
63+
expect(screen.getByText("Enable notifications")).toBeInTheDocument()
64+
})
65+
66+
it("renders label as sr-only when hideLabel is true", () => {
67+
render(<Toggle label="Enable notifications" hideLabel data-testid="toggle" />)
68+
const label = screen.getByText("Enable notifications")
69+
expect(label).toHaveClass("sr-only")
70+
expect(screen.getByTestId("toggle")).toHaveAttribute("aria-label", "Enable notifications")
71+
})
72+
73+
it("renders size variants", () => {
74+
const { rerender } = render(<Toggle size="sm" data-testid="toggle" />)
75+
expect(screen.getByTestId("toggle")).toHaveClass("h-5", "w-9")
76+
77+
rerender(<Toggle size="lg" data-testid="toggle" />)
78+
expect(screen.getByTestId("toggle")).toHaveClass("h-7", "w-14")
79+
})
80+
81+
it("shows checked state styling", () => {
82+
render(<Toggle checked={true} data-testid="toggle" />)
83+
expect(screen.getByTestId("toggle")).toHaveClass("bg-primary")
84+
})
85+
86+
it("shows unchecked state styling", () => {
87+
render(<Toggle checked={false} data-testid="toggle" />)
88+
expect(screen.getByTestId("toggle")).toHaveClass("bg-input")
89+
})
90+
91+
it("applies custom className", () => {
92+
render(<Toggle className="custom-class" data-testid="toggle" />)
93+
expect(screen.getByTestId("toggle")).toHaveClass("custom-class")
94+
})
95+
96+
it("forwards ref", () => {
97+
const ref = React.createRef<HTMLButtonElement>()
98+
render(<Toggle ref={ref} />)
99+
expect(ref.current).not.toBeNull()
100+
expect(ref.current?.tagName).toBe("BUTTON")
101+
})
102+
103+
it("has data-slot and data-state attributes", () => {
104+
render(<Toggle data-testid="toggle" />)
105+
const toggle = screen.getByTestId("toggle")
106+
expect(toggle).toHaveAttribute("data-slot", "toggle")
107+
expect(toggle).toHaveAttribute("data-state", "unchecked")
108+
109+
fireEvent.click(toggle)
110+
expect(toggle).toHaveAttribute("data-state", "checked")
111+
})
112+
})

0 commit comments

Comments
 (0)