feat: add reusable UI components with tests

- Add Select component with test coverage
- Add Switch component with test coverage
- Add Textarea component
- All components follow design system conventions
This commit is contained in:
Millian Lamiaux
2026-03-14 20:42:30 +01:00
parent 592d04e170
commit 71e9a9bdb5
5 changed files with 447 additions and 0 deletions

View File

@@ -0,0 +1,148 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Select } from './select'
describe('Select', () => {
const mockOptions = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
{ value: 'option3', label: 'Option 3' },
]
const mockOnChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('should render with placeholder', () => {
render(
<Select
options={mockOptions}
onValueChange={mockOnChange}
placeholder="Select an option"
/>
)
expect(screen.getByText('Select an option')).toBeInTheDocument()
})
it('should open dropdown on click', async () => {
const user = userEvent.setup()
render(
<Select
options={mockOptions}
onValueChange={mockOnChange}
/>
)
const trigger = screen.getByRole('button')
await user.click(trigger)
expect(screen.getByText('Option 1')).toBeInTheDocument()
expect(screen.getByText('Option 2')).toBeInTheDocument()
expect(screen.getByText('Option 3')).toBeInTheDocument()
})
it('should select option and close dropdown', async () => {
const user = userEvent.setup()
render(
<Select
options={mockOptions}
onValueChange={mockOnChange}
/>
)
const trigger = screen.getByRole('button')
await user.click(trigger)
await user.click(screen.getByText('Option 2'))
expect(mockOnChange).toHaveBeenCalledWith('option2')
})
it('should display selected value', async () => {
const user = userEvent.setup()
render(
<Select
options={mockOptions}
value="option2"
onValueChange={mockOnChange}
/>
)
expect(screen.getByText('Option 2')).toBeInTheDocument()
})
it('should close dropdown on outside click', async () => {
const user = userEvent.setup()
render(
<div>
<Select
options={mockOptions}
onValueChange={mockOnChange}
/>
<div data-testid="outside">Outside</div>
</div>
)
const trigger = screen.getByRole('button')
await user.click(trigger)
expect(screen.getByText('Option 1')).toBeInTheDocument()
// Click outside
await user.click(screen.getByTestId('outside'))
// Dropdown should be closed
expect(screen.queryByText('Option 1')).not.toBeInTheDocument()
})
it('should be disabled when disabled prop is true', () => {
render(
<Select
options={mockOptions}
onValueChange={mockOnChange}
disabled
/>
)
expect(screen.getByRole('button')).toBeDisabled()
})
it('should highlight selected option in dropdown', async () => {
const user = userEvent.setup()
const { container } = render(
<Select
options={mockOptions}
value="option2"
onValueChange={mockOnChange}
/>
)
const trigger = screen.getByRole('button')
await user.click(trigger)
// Find the dropdown option with the selected styling
const options = container.querySelectorAll('[class*="bg-orange-500/20"]')
expect(options.length).toBeGreaterThan(0)
expect(options[0].textContent).toBe('Option 2')
})
it('should apply custom className', () => {
render(
<Select
options={mockOptions}
onValueChange={mockOnChange}
className="custom-class"
/>
)
expect(screen.getByRole('button').parentElement).toHaveClass('custom-class')
})
})

View File

@@ -0,0 +1,100 @@
"use client"
import * as React from "react"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
interface SelectOption<T = string> {
value: T
label: string
}
interface SelectProps<T = string> {
value?: T
onValueChange?: (value: T) => void
options: SelectOption<T>[]
placeholder?: string
disabled?: boolean
className?: string
id?: string
}
function Select({
value,
onValueChange,
options,
placeholder = "Select an option",
disabled = false,
className,
id,
}: SelectProps) {
const [isOpen, setIsOpen] = React.useState(false)
const containerRef = React.useRef<HTMLDivElement>(null)
const selectedOption = options.find((option) => option.value === value)
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [])
const handleSelect = (optionValue: string) => {
onValueChange?.(optionValue)
setIsOpen(false)
}
return (
<div ref={containerRef} className={cn("relative", className)}>
<button
type="button"
id={id}
onClick={() => !disabled && setIsOpen(!isOpen)}
disabled={disabled}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-neutral-700 bg-neutral-900 px-3 py-2 text-sm text-white ring-offset-background placeholder:text-neutral-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
isOpen && "border-orange-500 ring-2 ring-orange-500 ring-offset-2"
)}
>
<span className={cn(!selectedOption && "text-neutral-500")}>
{selectedOption?.label || placeholder}
</span>
<ChevronDown
className={cn(
"h-4 w-4 text-neutral-500 transition-transform",
isOpen && "rotate-180"
)}
/>
</button>
{isOpen && (
<div className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-neutral-700 bg-neutral-900 py-1 shadow-lg">
{options.map((option) => (
<div
key={option.value}
onClick={() => handleSelect(option.value)}
className={cn(
"relative flex cursor-pointer select-none items-center px-3 py-2 text-sm text-white hover:bg-neutral-800",
option.value === value && "bg-orange-500/20 text-orange-500"
)}
>
{option.label}
</div>
))}
</div>
)}
</div>
)
}
export { Select }
export type { SelectOption }

View File

@@ -0,0 +1,121 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Switch } from './switch'
describe('Switch', () => {
const mockOnChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('should render unchecked by default', () => {
render(
<Switch
checked={false}
onCheckedChange={mockOnChange}
/>
)
const switchButton = screen.getByRole('switch')
expect(switchButton).toHaveAttribute('aria-checked', 'false')
})
it('should render checked when checked prop is true', () => {
render(
<Switch
checked={true}
onCheckedChange={mockOnChange}
/>
)
const switchButton = screen.getByRole('switch')
expect(switchButton).toHaveAttribute('aria-checked', 'true')
})
it('should toggle on click', async () => {
const user = userEvent.setup()
render(
<Switch
checked={false}
onCheckedChange={mockOnChange}
/>
)
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
expect(mockOnChange).toHaveBeenCalledWith(true)
})
it('should toggle off when clicked while checked', async () => {
const user = userEvent.setup()
render(
<Switch
checked={true}
onCheckedChange={mockOnChange}
/>
)
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
expect(mockOnChange).toHaveBeenCalledWith(false)
})
it('should be disabled when disabled prop is true', () => {
render(
<Switch
checked={false}
onCheckedChange={mockOnChange}
disabled
/>
)
expect(screen.getByRole('switch')).toBeDisabled()
})
it('should not call onChange when disabled', async () => {
const user = userEvent.setup()
render(
<Switch
checked={false}
onCheckedChange={mockOnChange}
disabled
/>
)
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
expect(mockOnChange).not.toHaveBeenCalled()
})
it('should have correct id attribute', () => {
render(
<Switch
id="test-switch"
checked={false}
onCheckedChange={mockOnChange}
/>
)
expect(screen.getByRole('switch')).toHaveAttribute('id', 'test-switch')
})
it('should apply custom className', () => {
render(
<Switch
checked={false}
onCheckedChange={mockOnChange}
className="custom-class"
/>
)
expect(screen.getByRole('switch')).toHaveClass('custom-class')
})
})

View File

@@ -0,0 +1,52 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
interface SwitchProps {
checked?: boolean
onCheckedChange?: (checked: boolean) => void
disabled?: boolean
className?: string
id?: string
}
function Switch({
checked = false,
onCheckedChange,
disabled = false,
className,
id,
}: SwitchProps) {
const handleClick = () => {
if (!disabled) {
onCheckedChange?.(!checked)
}
}
return (
<button
type="button"
role="switch"
id={id}
aria-checked={checked}
onClick={handleClick}
disabled={disabled}
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-500 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
checked ? "bg-orange-500" : "bg-neutral-700",
className
)}
>
<span
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-white shadow-lg ring-0 transition-transform",
checked ? "translate-x-5" : "translate-x-0"
)}
/>
</button>
)
}
export { Switch }

View File

@@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-neutral-700 bg-neutral-900 px-3 py-2 text-sm text-white ring-offset-background placeholder:text-neutral-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }