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