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:
Ilia Mashkov
2026-04-19 08:25:07 +03:00
parent dc3bedeeec
commit 9c52889b72
2 changed files with 66 additions and 9 deletions
+38
View File
@@ -25,6 +25,27 @@ describe('Input', () => {
expect(screen.queryByText('Required')).toBeNull() 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', () => { describe('styling', () => {
it('has brutal-border class', () => { it('has brutal-border class', () => {
render(<Input />) render(<Input />)
@@ -69,4 +90,21 @@ describe('Textarea', () => {
expect(screen.getByRole('textbox')).toHaveAttribute('rows', '8') 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')
})
})
}) })
+28 -9
View File
@@ -1,4 +1,4 @@
import type { InputHTMLAttributes, TextareaHTMLAttributes } from 'react' import { useId, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react'
import { cn } from '$shared/lib' import { cn } from '$shared/lib'
interface InputProps extends InputHTMLAttributes<HTMLInputElement> { 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. * 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 ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{label && <label className="text-carbon-black">{label}</label>} {label && <label htmlFor={inputId} className="text-carbon-black">{label}</label>}
<input className={cn(INPUT_BASE, className)} {...props} /> <input
{error && <span className="text-sm text-burnt-oxide">{error}</span>} 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> </div>
) )
} }
@@ -46,12 +55,22 @@ interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
/** /**
* Multiline textarea with optional label and error state. * 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 ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{label && <label className="text-carbon-black">{label}</label>} {label && <label htmlFor={textareaId} className="text-carbon-black">{label}</label>}
<textarea rows={rows} className={cn(INPUT_BASE, 'resize-none', className)} {...props} /> <textarea
{error && <span className="text-sm text-burnt-oxide">{error}</span>} 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> </div>
) )
} }