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