feat: add Textarea primitive

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-18 11:54:11 +01:00
parent 4d420dfa6d
commit afe1abf7a1
3 changed files with 131 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
.textarea {
width: 100%;
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-raised);
color: var(--text-primary);
font-family: var(--font-body);
font-size: 12px;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
resize: vertical;
}
.textarea::placeholder { color: var(--text-faint); }
.textarea:focus { border-color: var(--amber); box-shadow: 0 0 0 3px var(--amber-bg); }
.resizeVertical { resize: vertical; }
.resizeHorizontal { resize: horizontal; }
.resizeNone { resize: none; }
.resizeBoth { resize: both; }

View File

@@ -0,0 +1,79 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createRef } from 'react'
import { Textarea } from './Textarea'
describe('Textarea', () => {
it('renders a textarea element', () => {
render(<Textarea placeholder="Enter text" />)
expect(screen.getByPlaceholderText('Enter text')).toBeInTheDocument()
})
it('renders with default rows of 3', () => {
render(<Textarea />)
const el = screen.getByRole('textbox')
expect(el).toHaveAttribute('rows', '3')
})
it('accepts a custom rows value', () => {
render(<Textarea rows={5} />)
expect(screen.getByRole('textbox')).toHaveAttribute('rows', '5')
})
it('accepts user input', async () => {
const user = userEvent.setup()
render(<Textarea />)
const el = screen.getByRole('textbox')
await user.type(el, 'hello world')
expect(el).toHaveValue('hello world')
})
it('calls onChange when value changes', async () => {
const onChange = vi.fn()
const user = userEvent.setup()
render(<Textarea onChange={onChange} />)
await user.type(screen.getByRole('textbox'), 'a')
expect(onChange).toHaveBeenCalled()
})
it('applies resize class for vertical (default)', () => {
render(<Textarea />)
const el = screen.getByRole('textbox')
expect(el.className).toContain('resizeVertical')
})
it('applies resize class for none', () => {
render(<Textarea resize="none" />)
const el = screen.getByRole('textbox')
expect(el.className).toContain('resizeNone')
})
it('applies resize class for horizontal', () => {
render(<Textarea resize="horizontal" />)
const el = screen.getByRole('textbox')
expect(el.className).toContain('resizeHorizontal')
})
it('applies resize class for both', () => {
render(<Textarea resize="both" />)
const el = screen.getByRole('textbox')
expect(el.className).toContain('resizeBoth')
})
it('forwards ref to the textarea element', () => {
const ref = createRef<HTMLTextAreaElement>()
render(<Textarea ref={ref} />)
expect(ref.current).toBeInstanceOf(HTMLTextAreaElement)
})
it('passes additional props to the textarea', () => {
render(<Textarea disabled data-testid="ta" />)
expect(screen.getByTestId('ta')).toBeDisabled()
})
it('applies custom className', () => {
render(<Textarea className="custom" />)
expect(screen.getByRole('textbox').className).toContain('custom')
})
})

View File

@@ -0,0 +1,31 @@
import styles from './Textarea.module.css'
import { forwardRef, type TextareaHTMLAttributes } from 'react'
type ResizeProp = 'vertical' | 'horizontal' | 'none' | 'both'
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
resize?: ResizeProp
}
const resizeClassMap: Record<ResizeProp, string> = {
vertical: styles.resizeVertical,
horizontal: styles.resizeHorizontal,
none: styles.resizeNone,
both: styles.resizeBoth,
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ resize = 'vertical', className, rows = 3, ...rest }, ref) => {
const resizeClass = resizeClassMap[resize]
return (
<textarea
ref={ref}
rows={rows}
className={`${styles.textarea} ${resizeClass} ${className ?? ''}`}
{...rest}
/>
)
},
)
Textarea.displayName = 'Textarea'