feat: add Input and Textarea components to shared/ui
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export { Input, Textarea } from './ui/Input'
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Input, Textarea } from './Input'
|
||||
|
||||
describe('Input', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders an input element', () => {
|
||||
render(<Input />)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
it('renders label when provided', () => {
|
||||
render(<Input label="Email" />)
|
||||
expect(screen.getByText('Email')).toBeInTheDocument()
|
||||
})
|
||||
it('does not render label when omitted', () => {
|
||||
const { container } = render(<Input />)
|
||||
expect(container.querySelector('label')).toBeNull()
|
||||
})
|
||||
it('renders error message when provided', () => {
|
||||
render(<Input error="Required" />)
|
||||
expect(screen.getByText('Required')).toBeInTheDocument()
|
||||
})
|
||||
it('does not render error when omitted', () => {
|
||||
render(<Input />)
|
||||
expect(screen.queryByText('Required')).toBeNull()
|
||||
})
|
||||
})
|
||||
describe('styling', () => {
|
||||
it('has brutal-border class', () => {
|
||||
render(<Input />)
|
||||
expect(screen.getByRole('textbox')).toHaveClass('brutal-border')
|
||||
})
|
||||
it('applies custom className', () => {
|
||||
render(<Input className="w-full" />)
|
||||
expect(screen.getByRole('textbox')).toHaveClass('w-full')
|
||||
})
|
||||
})
|
||||
describe('forwarded props', () => {
|
||||
it('passes placeholder to input', () => {
|
||||
render(<Input placeholder="Enter email" />)
|
||||
expect(screen.getByPlaceholderText('Enter email')).toBeInTheDocument()
|
||||
})
|
||||
it('passes type to input', () => {
|
||||
render(<Input type="email" />)
|
||||
expect(screen.getByRole('textbox')).toHaveAttribute('type', 'email')
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('Textarea', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders a textarea element', () => {
|
||||
render(<Textarea />)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
it('renders label when provided', () => {
|
||||
render(<Textarea label="Message" />)
|
||||
expect(screen.getByText('Message')).toBeInTheDocument()
|
||||
})
|
||||
it('renders error when provided', () => {
|
||||
render(<Textarea error="Too short" />)
|
||||
expect(screen.getByText('Too short')).toBeInTheDocument()
|
||||
})
|
||||
it('defaults to 4 rows', () => {
|
||||
render(<Textarea />)
|
||||
expect(screen.getByRole('textbox')).toHaveAttribute('rows', '4')
|
||||
})
|
||||
it('accepts custom rows', () => {
|
||||
render(<Textarea rows={8} />)
|
||||
expect(screen.getByRole('textbox')).toHaveAttribute('rows', '8')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { InputHTMLAttributes, TextareaHTMLAttributes } from 'react'
|
||||
import { cn } from '$shared/lib'
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
/**
|
||||
* Visible label rendered above the input
|
||||
*/
|
||||
label?: string
|
||||
/**
|
||||
* Validation error shown below the input
|
||||
*/
|
||||
error?: string
|
||||
}
|
||||
|
||||
const INPUT_BASE = 'brutal-border bg-white px-4 py-3 text-carbon-black focus:outline-none focus:ring-2 focus:ring-burnt-oxide focus:ring-offset-2 focus:ring-offset-ochre-clay transition-all'
|
||||
|
||||
/**
|
||||
* Text input with optional label and error state.
|
||||
*/
|
||||
export function Input({ label, error, className, ...props }: InputProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{label && <label className="text-carbon-black">{label}</label>}
|
||||
<input className={cn(INPUT_BASE, className)} {...props} />
|
||||
{error && <span className="text-sm text-burnt-oxide">{error}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
/**
|
||||
* Visible label rendered above the textarea
|
||||
*/
|
||||
label?: string
|
||||
/**
|
||||
* Validation error shown below the textarea
|
||||
*/
|
||||
error?: string
|
||||
/**
|
||||
* Number of visible rows
|
||||
* @default 4
|
||||
*/
|
||||
rows?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiline textarea with optional label and error state.
|
||||
*/
|
||||
export function Textarea({ label, error, rows = 4, className, ...props }: TextareaProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{label && <label className="text-carbon-black">{label}</label>}
|
||||
<textarea rows={rows} className={cn(INPUT_BASE, 'resize-none', className)} {...props} />
|
||||
{error && <span className="text-sm text-burnt-oxide">{error}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user