-
Notifications
You must be signed in to change notification settings - Fork 0
fix(a11y): auto-generate id/name em Input+Textarea + componente LabeledField #435
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,206 @@ | ||
| /** | ||
| * LabeledField — Compound component that correctly links <Label> to <Input> | ||
| * (or <Textarea>) via a shared `React.useId()` id. | ||
| * | ||
| * This is the canonical fix for the DevTools accessibility warning: | ||
| * "No label associated with a form field" | ||
| * | ||
| * Usage: | ||
| * // Instead of: | ||
| * <Label>Email</Label> | ||
| * <Input type="email" /> | ||
| * | ||
| * // Use: | ||
| * <LabeledField label="Email" name="email" type="email" /> | ||
| * | ||
| * // With error: | ||
| * <LabeledField | ||
| * label="Email" | ||
| * name="email" | ||
| * type="email" | ||
| * required | ||
| * description="Usado para login" | ||
| * error={errors.email?.message} | ||
| * /> | ||
| * | ||
| * // Textarea variant: | ||
| * <LabeledTextarea label="Observações" name="obs" rows={4} /> | ||
| */ | ||
| import * as React from 'react'; | ||
| import { Label } from '@/components/ui/label'; | ||
| import { Input } from '@/components/ui/input'; | ||
| import { Textarea } from '@/components/ui/textarea'; | ||
| import { cn } from '@/lib/utils'; | ||
|
|
||
| // ───────────────────────────────────────────── | ||
| // Shared types | ||
| // ───────────────────────────────────────────── | ||
|
|
||
| interface LabeledFieldBaseProps { | ||
| /** Text shown in the <label>. Required for accessibility. */ | ||
| label: string; | ||
| /** Marks field as required — appends a red asterisk to the label. */ | ||
| required?: boolean; | ||
| /** Optional helper text shown below the field. */ | ||
| description?: string; | ||
| /** Error message shown below the field in destructive colour. */ | ||
| error?: string; | ||
| /** Additional class names for the outer wrapper div. */ | ||
| wrapperClassName?: string; | ||
| /** Additional class names for the label. */ | ||
| labelClassName?: string; | ||
| } | ||
|
|
||
| // ───────────────────────────────────────────── | ||
| // LabeledField (wraps <Input>) | ||
| // ───────────────────────────────────────────── | ||
|
|
||
| export type LabeledFieldProps = LabeledFieldBaseProps & | ||
| Omit<React.ComponentProps<'input'>, 'id'>; | ||
|
|
||
| /** | ||
| * Renders a <Label> + <Input> pair with a shared `id` so the browser | ||
| * correctly associates them. The id is auto-generated when not supplied. | ||
| */ | ||
| export const LabeledField = React.forwardRef<HTMLInputElement, LabeledFieldProps>( | ||
|
|
||
| ( | ||
| { | ||
| label, | ||
| required, | ||
| description, | ||
| error, | ||
| wrapperClassName, | ||
| labelClassName, | ||
| name, | ||
| className, | ||
| ...inputProps | ||
| }, | ||
| ref, | ||
| ) => { | ||
| const autoId = React.useId(); | ||
| // Prefer name-based id for readability in DevTools; fall back to autoId. | ||
| const fieldId = name ? `field-${name}` : autoId; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Deriving Useful? React with 👍 / 👎. |
||
| const descriptionId = description ? `${fieldId}-description` : undefined; | ||
| const errorId = error ? `${fieldId}-error` : undefined; | ||
|
|
||
| const ariaDescribedBy = | ||
| [descriptionId, errorId].filter(Boolean).join(' ') || undefined; | ||
|
Comment on lines
+86
to
+87
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When both Useful? React with 👍 / 👎.
Comment on lines
+83
to
+87
|
||
|
|
||
| return ( | ||
| <div className={cn('space-y-2', wrapperClassName)}> | ||
| <Label | ||
| htmlFor={fieldId} | ||
| className={cn(error && 'text-destructive', labelClassName)} | ||
| > | ||
| {label} | ||
| {required && ( | ||
| <span className="text-destructive ml-1" aria-hidden="true"> | ||
| * | ||
| </span> | ||
| )} | ||
| </Label> | ||
|
|
||
| <Input | ||
| ref={ref} | ||
| id={fieldId} | ||
| name={name} | ||
| aria-describedby={ariaDescribedBy} | ||
| aria-invalid={!!error} | ||
| aria-required={required} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The component consumes Useful? React with 👍 / 👎. |
||
| className={className} | ||
| {...inputProps} | ||
| /> | ||
|
Comment on lines
+103
to
+112
Comment on lines
+103
to
+112
|
||
|
|
||
| {description && !error && ( | ||
| <p id={descriptionId} className="text-xs text-muted-foreground"> | ||
| {description} | ||
| </p> | ||
| )} | ||
|
|
||
| {error && ( | ||
| <p id={errorId} className="text-xs font-medium text-destructive" role="alert"> | ||
| {error} | ||
| </p> | ||
| )} | ||
|
Comment on lines
+114
to
+124
|
||
| </div> | ||
| ); | ||
| }, | ||
| ); | ||
| LabeledField.displayName = 'LabeledField'; | ||
|
|
||
| // ───────────────────────────────────────────── | ||
| // LabeledTextarea (wraps <Textarea>) | ||
| // ───────────────────────────────────────────── | ||
|
|
||
| export type LabeledTextareaProps = LabeledFieldBaseProps & | ||
| Omit<React.ComponentProps<'textarea'>, 'id'>; | ||
|
|
||
| /** | ||
| * Renders a <Label> + <Textarea> pair with a shared `id`. | ||
| */ | ||
| export const LabeledTextarea = React.forwardRef< | ||
|
|
||
| HTMLTextAreaElement, | ||
| LabeledTextareaProps | ||
| >( | ||
| ( | ||
| { | ||
| label, | ||
| required, | ||
| description, | ||
| error, | ||
| wrapperClassName, | ||
| labelClassName, | ||
| name, | ||
| className, | ||
| ...textareaProps | ||
| }, | ||
| ref, | ||
| ) => { | ||
| const autoId = React.useId(); | ||
| const fieldId = name ? `field-${name}` : autoId; | ||
| const descriptionId = description ? `${fieldId}-description` : undefined; | ||
| const errorId = error ? `${fieldId}-error` : undefined; | ||
| const ariaDescribedBy = | ||
| [descriptionId, errorId].filter(Boolean).join(' ') || undefined; | ||
|
|
||
| return ( | ||
| <div className={cn('space-y-2', wrapperClassName)}> | ||
| <Label | ||
| htmlFor={fieldId} | ||
| className={cn(error && 'text-destructive', labelClassName)} | ||
| > | ||
| {label} | ||
| {required && ( | ||
| <span className="text-destructive ml-1" aria-hidden="true"> | ||
| * | ||
| </span> | ||
| )} | ||
| </Label> | ||
|
|
||
| <Textarea | ||
| ref={ref} | ||
| id={fieldId} | ||
| name={name} | ||
| aria-describedby={ariaDescribedBy} | ||
| aria-invalid={!!error} | ||
| aria-required={required} | ||
| className={className} | ||
| {...textareaProps} | ||
| /> | ||
|
Comment on lines
+180
to
+189
Comment on lines
+180
to
+189
|
||
|
|
||
| {description && !error && ( | ||
| <p id={descriptionId} className="text-xs text-muted-foreground"> | ||
| {description} | ||
| </p> | ||
| )} | ||
|
|
||
| {error && ( | ||
| <p id={errorId} className="text-xs font-medium text-destructive" role="alert"> | ||
| {error} | ||
| </p> | ||
| )} | ||
| </div> | ||
| ); | ||
| }, | ||
| ); | ||
| LabeledTextarea.displayName = 'LabeledTextarea'; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,11 +2,35 @@ import * as React from "react"; | |
|
|
||
| import { cn } from "@/lib/utils"; | ||
|
|
||
| /** | ||
| * Input — shadcn/ui base field with accessibility auto-fix. | ||
| * | ||
| * A11y rules enforced: | ||
| * - Every <input> MUST have an `id` attribute (Chrome DevTools: "form field | ||
| * should have an id or name attribute"). | ||
| * - Every <input> MUST have a `name` attribute so the browser can autocomplete | ||
| * and form serialisation works correctly. | ||
| * | ||
| * Resolution order: | ||
| * id → explicit prop → derive from `name` → React.useId() stable fallback | ||
| * name → explicit prop → same value as resolved id | ||
| * | ||
| * This means a bare <Input /> gets a unique, stable id/name pair at no cost | ||
| * to the caller. Callers that already supply id/name are unaffected. | ||
| */ | ||
| const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>( | ||
| ({ className, type, ...props }, ref) => { | ||
| ({ className, type, id, name, ...props }, ref) => { | ||
| const fallbackId = React.useId(); | ||
| // Resolve id: explicit > derived from name > unique fallback | ||
| const resolvedId = id ?? name ?? fallbackId; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Using Useful? React with 👍 / 👎. |
||
| // Resolve name: explicit > same as id so the field is always serialisable | ||
| const resolvedName = name ?? resolvedId; | ||
|
|
||
| return ( | ||
| <input | ||
| type={type} | ||
| id={resolvedId} | ||
| name={resolvedName} | ||
| className={cn( | ||
| "flex h-11 w-full rounded-lg border border-border bg-background px-4 py-2 text-sm ring-offset-background", | ||
| "file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground", | ||
|
|
@@ -25,4 +49,4 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>( | |
| ); | ||
| Input.displayName = "Input"; | ||
|
|
||
| export { Input }; | ||
| export { Input }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,16 +4,31 @@ import { cn } from '@/lib/utils'; | |
|
|
||
| export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>; | ||
|
|
||
| /** | ||
| * Textarea — shadcn/ui base field with accessibility auto-fix. | ||
| * | ||
| * A11y rules enforced (same pattern as Input): | ||
| * - Every <textarea> MUST have an `id` attribute. | ||
| * - Every <textarea> MUST have a `name` attribute. | ||
| * | ||
| * Resolution order: | ||
| * id → explicit prop → derive from `name` → React.useId() stable fallback | ||
| * name → explicit prop → same value as resolved id | ||
| */ | ||
| const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( | ||
| ({ className, ...props }, ref) => { | ||
| ({ className, id, name, ...props }, ref) => { | ||
| const fallbackId = React.useId(); | ||
| const resolvedId = id ?? name ?? fallbackId; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Textarea now mirrors the input fallback Useful? React with 👍 / 👎. |
||
| const resolvedName = name ?? resolvedId; | ||
|
|
||
| return ( | ||
| <textarea | ||
| id={resolvedId} | ||
| name={resolvedName} | ||
| className={cn( | ||
| 'flex min-h-[80px] w-full rounded-lg border border-border bg-background px-4 py-3 text-sm font-medium', | ||
| 'ring-offset-background placeholder:text-muted-foreground/60', | ||
| // Focus: ring laranja com glow | ||
| 'focus-visible:border-primary focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/25', | ||
| // Hover: borda laranja + sombra | ||
| 'hover:border-border-strong hover:shadow-medium', | ||
| 'shadow-soft transition-all duration-300', | ||
| 'disabled:cursor-not-allowed disabled:opacity-50', | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
idin LabeledField prop typesThe prop types explicitly remove
id(Omit<..., 'id'>), so callers cannot provide a deterministic unique id through normal typing even when they need to prevent collisions (for example repeated fields with the samename). This blocks a practical escape hatch and contradicts the component comment that the id is auto-generated only when not supplied.Useful? React with 👍 / 👎.