feat: add Input and Textarea components to shared/ui

This commit is contained in:
Ilia Mashkov
2026-04-19 08:20:29 +03:00
parent 26860b27e5
commit dc3bedeeec
3 changed files with 130 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
export { Input, Textarea } from './ui/Input'
+72
View File
@@ -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')
})
})
})
+57
View File
@@ -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>
)
}