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:
148
admin-web/components/ui/select.test.tsx
Normal file
148
admin-web/components/ui/select.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
100
admin-web/components/ui/select.tsx
Normal file
100
admin-web/components/ui/select.tsx
Normal 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 }
|
||||
121
admin-web/components/ui/switch.test.tsx
Normal file
121
admin-web/components/ui/switch.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
52
admin-web/components/ui/switch.tsx
Normal file
52
admin-web/components/ui/switch.tsx
Normal 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 }
|
||||
26
admin-web/components/ui/textarea.tsx
Normal file
26
admin-web/components/ui/textarea.tsx
Normal 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 }
|
||||
Reference in New Issue
Block a user