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()
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user