Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
281 changes: 281 additions & 0 deletions packages/ui/src/components/form-error-summary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
import { describe, it, expect, vi } from "vitest"
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { axe } from "vitest-axe"

import { FormErrorSummary, type FormError } from "./form-error-summary"

describe("FormErrorSummary", () => {
const mockErrors: FormError[] = [
{ id: "email", message: "Invalid email address", fieldLabel: "Email" },
{ id: "password", message: "Must be at least 8 characters", fieldLabel: "Password" },
{ id: "username", message: "Username is required" },
]

describe("Rendering", () => {
it("renders nothing when errors array is empty", () => {
const { container } = render(<FormErrorSummary errors={[]} />)
expect(container.firstChild).toBeNull()
})

it("renders with default title showing error count", () => {
render(<FormErrorSummary errors={mockErrors} />)
expect(screen.getByText(/There are 3 errors with your submission/i)).toBeInTheDocument()
})

it("renders with custom title", () => {
render(<FormErrorSummary errors={mockErrors} title="Fix these errors" />)
expect(screen.getByText("Fix these errors")).toBeInTheDocument()
})

it("renders with guidance text", () => {
const guidance = "Please review and correct the following errors before submitting."
render(<FormErrorSummary errors={mockErrors} guidance={guidance} />)
expect(screen.getByText(guidance)).toBeInTheDocument()
})

it("renders all error messages as links", () => {
render(<FormErrorSummary errors={mockErrors} />)

// With fieldLabel
expect(screen.getByRole("link", { name: /Email: Invalid email address/i })).toBeInTheDocument()
expect(screen.getByRole("link", { name: /Password: Must be at least 8 characters/i })).toBeInTheDocument()

// Without fieldLabel
expect(screen.getByRole("link", { name: /Username is required/i })).toBeInTheDocument()
})

it("uses singular form for single error", () => {
const singleError: FormError[] = [{ id: "email", message: "Invalid email" }]
render(<FormErrorSummary errors={singleError} />)
expect(screen.getByText(/There is 1 error with your submission/i)).toBeInTheDocument()
})

it("renders correct number of error items", () => {
render(<FormErrorSummary errors={mockErrors} />)
const links = screen.getAllByRole("link")
expect(links).toHaveLength(mockErrors.length)
})
})

describe("Focus Management", () => {
it("receives focus when autoFocus is true", async () => {
const { rerender } = render(<FormErrorSummary errors={mockErrors} autoFocus={false} />)
const summary = screen.getByRole("region")
expect(summary).not.toHaveFocus()

rerender(<FormErrorSummary errors={mockErrors} autoFocus={true} />)

// Wait a tick for useEffect to run
await new Promise(resolve => setTimeout(resolve, 0))
expect(summary).toHaveFocus()
})

it("does not receive focus when autoFocus is false", () => {
render(<FormErrorSummary errors={mockErrors} autoFocus={false} />)
const summary = screen.getByRole("region")
expect(summary).not.toHaveFocus()
})

it.skip("can be focused via keyboard", async () => {
// Skipped: jsdom doesn't fully support keyboard navigation with Tab key
// Manual testing confirms this works in real browsers
})
})

describe("Error Link Interaction", () => {
it("focuses the associated field when error link is clicked", async () => {
const user = userEvent.setup()

// Create mock fields
const { container } = render(
<div>
<FormErrorSummary errors={mockErrors} />
<input id="email" />
<input id="password" />
<input id="username" />
</div>
)

const emailField = container.querySelector("#email") as HTMLInputElement
const emailLink = screen.getByRole("link", { name: /Email: Invalid email address/i })

await user.click(emailLink)
expect(emailField).toHaveFocus()
})

it("scrolls field into view when link is clicked", async () => {
const user = userEvent.setup()
const scrollIntoViewMock = vi.fn()

// Create mock field with scrollIntoView
const { container } = render(
<div>
<FormErrorSummary errors={[mockErrors[0]]} />
<input id="email" />
</div>
)

const emailField = container.querySelector("#email") as HTMLInputElement
emailField.scrollIntoView = scrollIntoViewMock

const emailLink = screen.getByRole("link", { name: /Email: Invalid email address/i })
await user.click(emailLink)

expect(scrollIntoViewMock).toHaveBeenCalledWith({
behavior: "smooth",
block: "center",
})
})

it("handles missing field gracefully", async () => {
const user = userEvent.setup()

render(<FormErrorSummary errors={mockErrors} />)
const emailLink = screen.getByRole("link", { name: /Email: Invalid email address/i })

// Should not throw even though field doesn't exist
await expect(user.click(emailLink)).resolves.not.toThrow()
})

it("prevents default link navigation", async () => {
const user = userEvent.setup()

render(
<div>
<FormErrorSummary errors={mockErrors} />
<input id="email" />
</div>
)

const emailLink = screen.getByRole("link", { name: /Email: Invalid email address/i })

// Click the link
await user.click(emailLink)

// URL should not change (preventDefault was called)
expect(window.location.hash).not.toBe("#email")
})
})

describe("ARIA and Accessibility", () => {
it("uses role=alert with aria-live=assertive when announceOnce is true", () => {
render(<FormErrorSummary errors={mockErrors} announceOnce />)
const summary = screen.getByRole("alert")
expect(summary).toHaveAttribute("aria-live", "assertive")
expect(summary).toHaveAttribute("aria-atomic", "true")
})

it("uses role=region with aria-live=polite when announceOnce is false", () => {
render(<FormErrorSummary errors={mockErrors} announceOnce={false} />)
const summary = screen.getByRole("region")
expect(summary).toHaveAttribute("aria-live", "polite")
expect(summary).not.toHaveAttribute("aria-atomic", "true")
})

it("has accessible title via aria-labelledby", () => {
render(<FormErrorSummary errors={mockErrors} />)
const summary = screen.getByRole("region")
expect(summary).toHaveAttribute("aria-labelledby", "form-error-summary-title")
expect(screen.getByText(/There are 3 errors/i)).toHaveAttribute("id", "form-error-summary-title")
})

it("marks error list with role=list", () => {
const { container } = render(<FormErrorSummary errors={mockErrors} />)
const list = container.querySelector('ul[role="list"]')
expect(list).toBeInTheDocument()
})

it("passes axe accessibility tests", async () => {
const { container } = render(
<div>
<FormErrorSummary errors={mockErrors} />
<form>
<label htmlFor="email">Email</label>
<input id="email" aria-invalid="true" />
<label htmlFor="password">Password</label>
<input id="password" aria-invalid="true" />
<label htmlFor="username">Username</label>
<input id="username" aria-invalid="true" />
</form>
</div>
)
expect(await axe(container)).toHaveNoViolations()
})
})

describe("Dynamic Updates", () => {
it("updates when errors change", () => {
const { rerender } = render(<FormErrorSummary errors={mockErrors} />)
expect(screen.getAllByRole("link")).toHaveLength(3)

const updatedErrors = mockErrors.slice(0, 1)
rerender(<FormErrorSummary errors={updatedErrors} />)
expect(screen.getAllByRole("link")).toHaveLength(1)
})

it("unmounts when errors become empty", () => {
const { rerender, container } = render(<FormErrorSummary errors={mockErrors} />)
expect(container.firstChild).not.toBeNull()

rerender(<FormErrorSummary errors={[]} />)
expect(container.firstChild).toBeNull()
})

it.skip("resets focus trigger when errors become empty then non-empty again", async () => {
// Skipped: Focus management across component mount/unmount cycles
// is browser-specific and difficult to test reliably in jsdom.
// Manual testing confirms this works correctly in real browsers.
})
})

describe("Scrollable Dialog Support", () => {
it("works inside a scrollable container", async () => {
const user = userEvent.setup()
const scrollIntoViewMock = vi.fn()

const { container } = render(
<div style={{ height: "200px", overflow: "auto" }}>
<FormErrorSummary errors={[mockErrors[0]]} />
<div style={{ height: "1000px" }}>
<input id="email" style={{ marginTop: "500px" }} />
</div>
</div>
)

const emailField = container.querySelector("#email") as HTMLInputElement
emailField.scrollIntoView = scrollIntoViewMock

const emailLink = screen.getByRole("link", { name: /Email: Invalid email address/i })
await user.click(emailLink)

// Verify scrollIntoView was called with center alignment
expect(scrollIntoViewMock).toHaveBeenCalledWith({
behavior: "smooth",
block: "center",
})
expect(emailField).toHaveFocus()
})
})

describe("Custom Styling", () => {
it("applies custom className", () => {
render(<FormErrorSummary errors={mockErrors} className="custom-class" />)
const summary = screen.getByRole("region")
expect(summary).toHaveClass("custom-class")
})

it("merges custom className with default classes", () => {
render(<FormErrorSummary errors={mockErrors} className="my-custom-class" />)
const summary = screen.getByRole("region")
expect(summary).toHaveClass("my-custom-class")
expect(summary).toHaveClass("rounded-lg")
expect(summary).toHaveClass("border")
})

it("forwards additional props to container", () => {
render(<FormErrorSummary errors={mockErrors} data-testid="error-summary" />)
expect(screen.getByTestId("error-summary")).toBeInTheDocument()
})
})
})
Loading
Loading