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
66 changes: 66 additions & 0 deletions packages/ui/src/components/checkbox.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from "vitest"
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { axe } from "vitest-axe"
import { Checkbox } from "./checkbox"

describe("Checkbox accessibility", () => {
it("has no accessibility violations", async () => {
const { container } = render(
<label className="flex items-center gap-2">
<Checkbox id="terms" />
Accept terms
</label>
)
const results = await axe(container)
expect(results).toHaveNoViolations()
})
})

describe("Checkbox interaction", () => {
it("toggles checked state on click", async () => {
const user = userEvent.setup()
const onCheckedChange = vi.fn()
render(<Checkbox onCheckedChange={onCheckedChange} />)

const checkbox = screen.getByRole("checkbox")
expect(checkbox).toHaveAttribute("aria-checked", "false")

await user.click(checkbox)
expect(onCheckedChange).toHaveBeenCalledWith(true, expect.anything())
expect(checkbox).toHaveAttribute("aria-checked", "true")

await user.click(checkbox)
expect(onCheckedChange).toHaveBeenCalledWith(false, expect.anything())
})

it("toggles via keyboard (Space)", async () => {
const user = userEvent.setup()
render(<Checkbox />)

const checkbox = screen.getByRole("checkbox")
checkbox.focus()
await user.keyboard(" ")

expect(checkbox).toHaveAttribute("aria-checked", "true")
})

it("renders an indeterminate state", () => {
render(<Checkbox indeterminate checked={false} />)
const checkbox = screen.getByRole("checkbox")
expect(checkbox).toHaveAttribute("data-indeterminate")
expect(checkbox).toHaveAttribute("aria-checked", "mixed")
})

it("does not toggle when disabled", async () => {
const user = userEvent.setup()
const onCheckedChange = vi.fn()
render(<Checkbox disabled onCheckedChange={onCheckedChange} />)

const checkbox = screen.getByRole("checkbox")
expect(checkbox).toHaveAttribute("aria-disabled", "true")

await user.click(checkbox)
expect(onCheckedChange).not.toHaveBeenCalled()
})
})
42 changes: 42 additions & 0 deletions packages/ui/src/components/checkbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"use client"

import * as React from "react"
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"

import { MinusSignIcon, Tick02Icon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { cn } from "@workspace/ui/lib/utils"

function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer inline-flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input bg-input/20 text-primary-foreground shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 data-indeterminate:border-primary data-indeterminate:bg-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current"
>
{props.indeterminate ? (
<HugeiconsIcon
icon={MinusSignIcon}
strokeWidth={2.5}
className="size-3"
/>
) : (
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2.5}
className="size-3"
/>
)}
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}

export { Checkbox }
75 changes: 75 additions & 0 deletions packages/ui/src/components/radio-group.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from "vitest"
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { axe } from "vitest-axe"
import { RadioGroup, RadioGroupItem } from "./radio-group"

function BasicRadioGroup(
props: Partial<React.ComponentProps<typeof RadioGroup>> = {}
) {
return (
<RadioGroup aria-label="Plan" {...props}>
<label className="flex items-center gap-2">
<RadioGroupItem value="free" />
Free
</label>
<label className="flex items-center gap-2">
<RadioGroupItem value="pro" />
Pro
</label>
<label className="flex items-center gap-2">
<RadioGroupItem value="enterprise" disabled />
Enterprise
</label>
</RadioGroup>
)
}

describe("RadioGroup accessibility", () => {
it("has no accessibility violations", async () => {
const { container } = render(<BasicRadioGroup />)
const results = await axe(container)
expect(results).toHaveNoViolations()
})
})

describe("RadioGroup interaction", () => {
it("selects an item on click", async () => {
const user = userEvent.setup()
const onValueChange = vi.fn()
render(<BasicRadioGroup onValueChange={onValueChange} />)

const free = screen.getByRole("radio", { name: "Free" })
await user.click(free)

expect(onValueChange).toHaveBeenCalledWith("free", expect.anything())
expect(free).toHaveAttribute("aria-checked", "true")
})

it("supports keyboard navigation with arrow keys", async () => {
const user = userEvent.setup()
render(<BasicRadioGroup defaultValue="free" />)

const free = screen.getByRole("radio", { name: "Free" })
const pro = screen.getByRole("radio", { name: "Pro" })

free.focus()
await user.keyboard("{ArrowDown}")

expect(pro).toHaveFocus()
expect(pro).toHaveAttribute("aria-checked", "true")
expect(free).toHaveAttribute("aria-checked", "false")
})

it("does not select a disabled item", async () => {
const user = userEvent.setup()
const onValueChange = vi.fn()
render(<BasicRadioGroup onValueChange={onValueChange} />)

const enterprise = screen.getByRole("radio", { name: "Enterprise" })
expect(enterprise).toHaveAttribute("aria-disabled", "true")

await user.click(enterprise)
expect(onValueChange).not.toHaveBeenCalled()
})
})
37 changes: 37 additions & 0 deletions packages/ui/src/components/radio-group.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"use client"

import * as React from "react"
import { Radio as RadioPrimitive } from "@base-ui/react/radio"
import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group"

import { cn } from "@workspace/ui/lib/utils"

function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {
return (
<RadioGroupPrimitive
data-slot="radio-group"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}

function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
return (
<RadioPrimitive.Root
data-slot="radio-group-item"
className={cn(
"inline-flex size-4 shrink-0 items-center justify-center rounded-full border border-input bg-input/20 shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary",
className
)}
{...props}
>
<RadioPrimitive.Indicator
data-slot="radio-group-item-indicator"
className="flex items-center justify-center after:size-2 after:rounded-full after:bg-primary"
/>
</RadioPrimitive.Root>
)
}

export { RadioGroup, RadioGroupItem }
158 changes: 158 additions & 0 deletions packages/ui/src/components/select.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { describe, expect, it, vi } from "vitest"
import { render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { axe } from "vitest-axe"
import {
Select,
SelectContent,
SelectGroup,
SelectGroupLabel,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "./select"

function BasicSelect(
props: Partial<React.ComponentProps<typeof Select<string>>> = {}
) {
return (
<Select {...props}>
<SelectTrigger aria-label="Fruit">
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectGroupLabel>Fruits</SelectGroupLabel>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
<SelectItem value="cherry" disabled>
Cherry
</SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectItem value="date">Date</SelectItem>
</SelectContent>
</Select>
)
}

describe("Select accessibility", () => {
it("has no accessibility violations when closed", async () => {
const { container } = render(<BasicSelect />)
const results = await axe(container)
expect(results).toHaveNoViolations()
})

it("has no accessibility violations when open", async () => {
const { container } = render(<BasicSelect defaultOpen />)
await waitFor(() => {
expect(screen.getByRole("option", { name: "Apple" })).toBeInTheDocument()
})
const results = await axe(container)
expect(results).toHaveNoViolations()
})
})

describe("Select selection", () => {
it("selects an item on click and updates the trigger value", async () => {
const user = userEvent.setup()
render(<BasicSelect />)

const trigger = screen.getByRole("combobox", { name: "Fruit" })
await user.click(trigger)

const banana = await screen.findByRole("option", { name: "Banana" })
await user.click(banana)

await waitFor(() => {
expect(trigger).toHaveTextContent("banana")
})
})

it("calls onValueChange with the selected value", async () => {
const user = userEvent.setup()
const onValueChange = vi.fn()
render(<BasicSelect onValueChange={onValueChange} />)

await user.click(screen.getByRole("combobox", { name: "Fruit" }))
const apple = await screen.findByRole("option", { name: "Apple" })
await user.click(apple)

expect(onValueChange).toHaveBeenCalledWith("apple", expect.anything())
})

it("does not select a disabled item", async () => {
const user = userEvent.setup()
const onValueChange = vi.fn()
render(<BasicSelect onValueChange={onValueChange} />)

await user.click(screen.getByRole("combobox", { name: "Fruit" }))
const cherry = await screen.findByRole("option", { name: "Cherry" })
expect(cherry).toHaveAttribute("aria-disabled", "true")

await user.click(cherry)
expect(onValueChange).not.toHaveBeenCalled()
})
})

describe("Select keyboard navigation", () => {
it("opens with Enter and closes with Escape", async () => {
const user = userEvent.setup()
render(<BasicSelect />)

const trigger = screen.getByRole("combobox", { name: "Fruit" })
trigger.focus()
await user.keyboard("{Enter}")

await waitFor(() => {
expect(screen.getByRole("option", { name: "Apple" })).toBeInTheDocument()
})

await user.keyboard("{Escape}")

await waitFor(() => {
expect(
screen.queryByRole("option", { name: "Apple" })
).not.toBeInTheDocument()
})
})

it("navigates items with arrow keys and selects with Enter", async () => {
const user = userEvent.setup()
const onValueChange = vi.fn()
render(<BasicSelect onValueChange={onValueChange} />)

const trigger = screen.getByRole("combobox", { name: "Fruit" })
trigger.focus()
await user.keyboard("{Enter}")

await waitFor(() => {
expect(screen.getByRole("option", { name: "Apple" })).toBeInTheDocument()
})

await user.keyboard("{ArrowDown}")
await user.keyboard("{Enter}")

expect(onValueChange).toHaveBeenCalledWith("banana", expect.anything())
})

it("supports typeahead to jump to a matching item", async () => {
const user = userEvent.setup()
const onValueChange = vi.fn()
render(<BasicSelect onValueChange={onValueChange} />)

const trigger = screen.getByRole("combobox", { name: "Fruit" })
trigger.focus()
await user.keyboard("{Enter}")

await waitFor(() => {
expect(screen.getByRole("option", { name: "Apple" })).toBeInTheDocument()
})

await user.keyboard("d")
await user.keyboard("{Enter}")

expect(onValueChange).toHaveBeenCalledWith("date", expect.anything())
})
})
Loading