feat: add FormField primitive

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-18 12:41:01 +01:00
parent ab5b792648
commit ae72c399e9
3 changed files with 153 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
.wrapper {
display: flex;
flex-direction: column;
gap: 6px;
font-family: var(--font-body);
}
.hint {
font-size: 11px;
color: var(--text-muted);
margin-top: 4px;
}
.error {
font-size: 11px;
color: var(--error);
margin-top: 4px;
}

View File

@@ -0,0 +1,88 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { FormField } from './FormField'
describe('FormField', () => {
it('renders label and children', () => {
render(
<FormField label="Username" htmlFor="username">
<input id="username" type="text" />
</FormField>,
)
expect(screen.getByText('Username')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('renders hint text when no error', () => {
render(
<FormField label="Email" hint="We will never share your email" htmlFor="email">
<input id="email" type="email" />
</FormField>,
)
expect(screen.getByText('We will never share your email')).toBeInTheDocument()
})
it('renders error instead of hint when error is provided', () => {
render(
<FormField
label="Email"
hint="We will never share your email"
error="Invalid email address"
htmlFor="email"
>
<input id="email" type="email" />
</FormField>,
)
expect(screen.getByText('Invalid email address')).toBeInTheDocument()
expect(screen.queryByText('We will never share your email')).not.toBeInTheDocument()
})
it('shows required asterisk via Label when required is true', () => {
render(
<FormField label="Password" required htmlFor="password">
<input id="password" type="password" />
</FormField>,
)
expect(screen.getByText('*')).toBeInTheDocument()
})
it('adds error class to wrapper when error is present', () => {
const { container } = render(
<FormField label="Name" error="Name is required" htmlFor="name">
<input id="name" type="text" />
</FormField>,
)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toMatch(/error/)
})
it('does not add error class when no error', () => {
const { container } = render(
<FormField label="Name" htmlFor="name">
<input id="name" type="text" />
</FormField>,
)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).not.toMatch(/error/)
})
it('renders children without label when label prop is omitted', () => {
render(
<FormField>
<input type="text" aria-label="standalone input" />
</FormField>,
)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.queryByRole('label')).not.toBeInTheDocument()
})
it('associates label with input via htmlFor', () => {
render(
<FormField label="Search" htmlFor="search-input">
<input id="search-input" type="text" />
</FormField>,
)
const label = screen.getByText('Search').closest('label')
expect(label).toHaveAttribute('for', 'search-input')
})
})

View File

@@ -0,0 +1,47 @@
import type { ReactNode } from 'react'
import { Label } from '../Label/Label'
import styles from './FormField.module.css'
interface FormFieldProps {
label?: string
htmlFor?: string
required?: boolean
error?: string
hint?: string
children: ReactNode
className?: string
}
export function FormField({
label,
htmlFor,
required,
error,
hint,
children,
className,
}: FormFieldProps) {
const wrapperClass = [styles.wrapper, error ? styles.error : '', className ?? '']
.filter(Boolean)
.join(' ')
return (
<div className={wrapperClass}>
{label && (
<Label htmlFor={htmlFor} required={required}>
{label}
</Label>
)}
{children}
{error ? (
<span className={styles.error} role="alert">
{error}
</span>
) : hint ? (
<span className={styles.hint}>{hint}</span>
) : null}
</div>
)
}
FormField.displayName = 'FormField'