diff --git a/src/design-system/primitives/FormField/FormField.module.css b/src/design-system/primitives/FormField/FormField.module.css new file mode 100644 index 0000000..b0524e9 --- /dev/null +++ b/src/design-system/primitives/FormField/FormField.module.css @@ -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; +} diff --git a/src/design-system/primitives/FormField/FormField.test.tsx b/src/design-system/primitives/FormField/FormField.test.tsx new file mode 100644 index 0000000..333b468 --- /dev/null +++ b/src/design-system/primitives/FormField/FormField.test.tsx @@ -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( + + + , + ) + expect(screen.getByText('Username')).toBeInTheDocument() + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + + it('renders hint text when no error', () => { + render( + + + , + ) + expect(screen.getByText('We will never share your email')).toBeInTheDocument() + }) + + it('renders error instead of hint when error is provided', () => { + render( + + + , + ) + 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( + + + , + ) + expect(screen.getByText('*')).toBeInTheDocument() + }) + + it('adds error class to wrapper when error is present', () => { + const { container } = render( + + + , + ) + const wrapper = container.firstChild as HTMLElement + expect(wrapper.className).toMatch(/error/) + }) + + it('does not add error class when no error', () => { + const { container } = render( + + + , + ) + const wrapper = container.firstChild as HTMLElement + expect(wrapper.className).not.toMatch(/error/) + }) + + it('renders children without label when label prop is omitted', () => { + render( + + + , + ) + expect(screen.getByRole('textbox')).toBeInTheDocument() + expect(screen.queryByRole('label')).not.toBeInTheDocument() + }) + + it('associates label with input via htmlFor', () => { + render( + + + , + ) + const label = screen.getByText('Search').closest('label') + expect(label).toHaveAttribute('for', 'search-input') + }) +}) diff --git a/src/design-system/primitives/FormField/FormField.tsx b/src/design-system/primitives/FormField/FormField.tsx new file mode 100644 index 0000000..84d3893 --- /dev/null +++ b/src/design-system/primitives/FormField/FormField.tsx @@ -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 ( +
+ {label && ( + + )} + {children} + {error ? ( + + {error} + + ) : hint ? ( + {hint} + ) : null} +
+ ) +} + +FormField.displayName = 'FormField'