fix: associate Input/Textarea labels and wire error aria-describedby
Uses useId() for stable IDs so label htmlFor matches input id, and error spans are referenced via aria-describedby.
This commit is contained in:
@@ -25,6 +25,27 @@ describe('Input', () => {
|
||||
expect(screen.queryByText('Required')).toBeNull()
|
||||
})
|
||||
})
|
||||
describe('accessibility', () => {
|
||||
it('label is associated with input via htmlFor/id', () => {
|
||||
render(<Input label="Email" />)
|
||||
expect(screen.getByLabelText('Email')).toBeInTheDocument()
|
||||
})
|
||||
it('error span is referenced by aria-describedby', () => {
|
||||
render(<Input error="Required" />)
|
||||
const input = screen.getByRole('textbox')
|
||||
const errorId = input.getAttribute('aria-describedby')
|
||||
expect(errorId).toBeTruthy()
|
||||
expect(document.getElementById(errorId!)).toHaveTextContent('Required')
|
||||
})
|
||||
it('no aria-describedby when no error', () => {
|
||||
render(<Input />)
|
||||
expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby')
|
||||
})
|
||||
it('uses provided id prop', () => {
|
||||
render(<Input id="my-input" label="Email" />)
|
||||
expect(screen.getByLabelText('Email')).toHaveAttribute('id', 'my-input')
|
||||
})
|
||||
})
|
||||
describe('styling', () => {
|
||||
it('has brutal-border class', () => {
|
||||
render(<Input />)
|
||||
@@ -69,4 +90,21 @@ describe('Textarea', () => {
|
||||
expect(screen.getByRole('textbox')).toHaveAttribute('rows', '8')
|
||||
})
|
||||
})
|
||||
describe('accessibility', () => {
|
||||
it('label is associated with textarea via htmlFor/id', () => {
|
||||
render(<Textarea label="Message" />)
|
||||
expect(screen.getByLabelText('Message')).toBeInTheDocument()
|
||||
})
|
||||
it('error span is referenced by aria-describedby', () => {
|
||||
render(<Textarea error="Too short" />)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
const errorId = textarea.getAttribute('aria-describedby')
|
||||
expect(errorId).toBeTruthy()
|
||||
expect(document.getElementById(errorId!)).toHaveTextContent('Too short')
|
||||
})
|
||||
it('no aria-describedby when no error', () => {
|
||||
render(<Textarea />)
|
||||
expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { InputHTMLAttributes, TextareaHTMLAttributes } from 'react'
|
||||
import { useId, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react'
|
||||
import { cn } from '$shared/lib'
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
@@ -17,12 +17,21 @@ const INPUT_BASE = 'brutal-border bg-white px-4 py-3 text-carbon-black focus:out
|
||||
/**
|
||||
* Text input with optional label and error state.
|
||||
*/
|
||||
export function Input({ label, error, className, ...props }: InputProps) {
|
||||
export function Input({ label, error, className, id, ...props }: InputProps) {
|
||||
const generatedId = useId()
|
||||
const inputId = id ?? generatedId
|
||||
const errorId = `${inputId}-error`
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{label && <label className="text-carbon-black">{label}</label>}
|
||||
<input className={cn(INPUT_BASE, className)} {...props} />
|
||||
{error && <span className="text-sm text-burnt-oxide">{error}</span>}
|
||||
{label && <label htmlFor={inputId} className="text-carbon-black">{label}</label>}
|
||||
<input
|
||||
id={inputId}
|
||||
className={cn(INPUT_BASE, className)}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
{...props}
|
||||
/>
|
||||
{error && <span id={errorId} className="text-sm text-burnt-oxide">{error}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -46,12 +55,22 @@ interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
/**
|
||||
* Multiline textarea with optional label and error state.
|
||||
*/
|
||||
export function Textarea({ label, error, rows = 4, className, ...props }: TextareaProps) {
|
||||
export function Textarea({ label, error, rows = 4, className, id, ...props }: TextareaProps) {
|
||||
const generatedId = useId()
|
||||
const textareaId = id ?? generatedId
|
||||
const errorId = `${textareaId}-error`
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{label && <label className="text-carbon-black">{label}</label>}
|
||||
<textarea rows={rows} className={cn(INPUT_BASE, 'resize-none', className)} {...props} />
|
||||
{error && <span className="text-sm text-burnt-oxide">{error}</span>}
|
||||
{label && <label htmlFor={textareaId} className="text-carbon-black">{label}</label>}
|
||||
<textarea
|
||||
id={textareaId}
|
||||
rows={rows}
|
||||
className={cn(INPUT_BASE, 'resize-none', className)}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
{...props}
|
||||
/>
|
||||
{error && <span id={errorId} className="text-sm text-burnt-oxide">{error}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user