import { describe, it, expect, vi } from "vitest"
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { SegmentedControl, type SegmentedControlOption } from "./segmented-control"
const options: SegmentedControlOption[] = [
{ value: "all", label: "All" },
{ value: "personal", label: "Personal" },
{ value: "team", label: "Team" },
]
describe("SegmentedControl", () => {
it("renders all options as radio buttons", () => {
render(
,
)
const radios = screen.getAllByRole("radio")
expect(radios).toHaveLength(3)
expect(screen.getByText("All")).toBeInTheDocument()
expect(screen.getByText("Personal")).toBeInTheDocument()
expect(screen.getByText("Team")).toBeInTheDocument()
})
it("marks the active option with aria-checked", () => {
render(
,
)
const personalRadio = screen.getByRole("radio", { name: "Personal" })
const allRadio = screen.getByRole("radio", { name: "All" })
expect(personalRadio).toHaveAttribute("aria-checked", "true")
expect(allRadio).toHaveAttribute("aria-checked", "false")
})
it("sets tabIndex 0 on active option and -1 on inactive options", () => {
render(
,
)
const radios = screen.getAllByRole("radio")
expect(radios[0]).toHaveAttribute("tabindex", "0")
expect(radios[1]).toHaveAttribute("tabindex", "-1")
expect(radios[2]).toHaveAttribute("tabindex", "-1")
})
it("calls onValueChange when clicking an inactive option", async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
,
)
await user.click(screen.getByRole("radio", { name: "Team" }))
expect(onChange).toHaveBeenCalledWith("team")
})
it("does not call onValueChange when clicking the already-active option", async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
,
)
await user.click(screen.getByRole("radio", { name: "All" }))
expect(onChange).not.toHaveBeenCalled()
})
it("renders adornment when provided", () => {
const withAdornment: SegmentedControlOption[] = [
{ value: "all", label: "All", adornment: 42 },
{ value: "personal", label: "Personal" },
]
render(
,
)
expect(screen.getByTestId("count")).toBeInTheDocument()
expect(screen.getByText("42")).toBeInTheDocument()
})
// --- Keyboard navigation ---
it("navigates to next option on ArrowRight", async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
,
)
screen.getByRole("radio", { name: "All" }).focus()
await user.keyboard("{ArrowRight}")
expect(onChange).toHaveBeenCalledWith("personal")
})
it("moves DOM focus to the target option on arrow key", async () => {
const user = userEvent.setup()
render(
,
)
const personalRadio = screen.getByRole("radio", { name: "Personal" })
screen.getByRole("radio", { name: "All" }).focus()
await user.keyboard("{ArrowRight}")
expect(document.activeElement).toBe(personalRadio)
})
it("navigates to previous option on ArrowLeft", async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
,
)
screen.getByRole("radio", { name: "Personal" }).focus()
await user.keyboard("{ArrowLeft}")
expect(onChange).toHaveBeenCalledWith("all")
})
it("wraps around on ArrowRight from last option", async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
,
)
screen.getByRole("radio", { name: "Team" }).focus()
await user.keyboard("{ArrowRight}")
expect(onChange).toHaveBeenCalledWith("all")
})
it("wraps around on ArrowLeft from first option", async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
,
)
screen.getByRole("radio", { name: "All" }).focus()
await user.keyboard("{ArrowLeft}")
expect(onChange).toHaveBeenCalledWith("team")
})
it("navigates to first option on Home", async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
,
)
screen.getByRole("radio", { name: "Team" }).focus()
await user.keyboard("{Home}")
expect(onChange).toHaveBeenCalledWith("all")
})
it("navigates to last option on End", async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
,
)
screen.getByRole("radio", { name: "All" }).focus()
await user.keyboard("{End}")
expect(onChange).toHaveBeenCalledWith("team")
})
it("does not fire onValueChange when Home is pressed on first option", async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
,
)
screen.getByRole("radio", { name: "All" }).focus()
await user.keyboard("{Home}")
expect(onChange).not.toHaveBeenCalled()
})
it("does not fire onValueChange when End is pressed on last option", async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
,
)
screen.getByRole("radio", { name: "Team" }).focus()
await user.keyboard("{End}")
expect(onChange).not.toHaveBeenCalled()
})
// --- Edge cases ---
it("does not crash on keyboard input when options array is empty", async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const { container } = render(
,
)
const radiogroup = container.querySelector("[role='radiogroup']") as HTMLElement
radiogroup.focus()
await user.keyboard("{ArrowRight}")
await user.keyboard("{Home}")
await user.keyboard("{End}")
expect(onChange).not.toHaveBeenCalled()
})
it("gives first option tabIndex 0 when value does not match any option", () => {
render(
,
)
const radios = screen.getAllByRole("radio")
expect(radios[0]).toHaveAttribute("tabindex", "0")
expect(radios[1]).toHaveAttribute("tabindex", "-1")
expect(radios[2]).toHaveAttribute("tabindex", "-1")
})
it("navigates from first option when value does not match any option", async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
,
)
screen.getByRole("radio", { name: "All" }).focus()
await user.keyboard("{ArrowRight}")
expect(onChange).toHaveBeenCalledWith("personal")
})
it("supports ArrowDown as alias for ArrowRight", async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
,
)
screen.getByRole("radio", { name: "All" }).focus()
await user.keyboard("{ArrowDown}")
expect(onChange).toHaveBeenCalledWith("personal")
})
it("supports ArrowUp as alias for ArrowLeft", async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
,
)
screen.getByRole("radio", { name: "Personal" }).focus()
await user.keyboard("{ArrowUp}")
expect(onChange).toHaveBeenCalledWith("all")
})
// --- Variants ---
it("applies row variant classes by default", () => {
render(
,
)
const radiogroup = screen.getByRole("radiogroup")
expect(radiogroup.className).toContain("flex-wrap")
expect(radiogroup.className).toContain("rounded-lg")
expect(radiogroup.className).toContain("bg-card")
})
it("applies tabs variant classes with nowrap", () => {
render(
,
)
const radiogroup = screen.getByRole("radiogroup")
expect(radiogroup.className).toContain("flex-nowrap")
expect(radiogroup.className).toContain("overflow-x-auto")
expect(radiogroup.className).toContain("border-b")
expect(radiogroup.className).not.toContain("bg-card")
})
it("passes aria-label to the radiogroup", () => {
render(
,
)
expect(screen.getByRole("radiogroup")).toHaveAttribute("aria-label", "Scope filter")
})
it("passes aria-labelledby to the radiogroup", () => {
render(
,
)
expect(screen.getByRole("radiogroup")).toHaveAttribute("aria-labelledby", "heading-id")
})
it("passes id to the radiogroup", () => {
render(
,
)
expect(screen.getByRole("radiogroup")).toHaveAttribute("id", "scope-control")
})
})