feat: add Textarea primitive
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
21
src/design-system/primitives/Textarea/Textarea.module.css
Normal file
21
src/design-system/primitives/Textarea/Textarea.module.css
Normal 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; }
|
||||||
79
src/design-system/primitives/Textarea/Textarea.test.tsx
Normal file
79
src/design-system/primitives/Textarea/Textarea.test.tsx
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
31
src/design-system/primitives/Textarea/Textarea.tsx
Normal file
31
src/design-system/primitives/Textarea/Textarea.tsx
Normal 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'
|
||||||
Reference in New Issue
Block a user