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