Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 206 additions & 0 deletions src/components/ui/LabeledField.tsx
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'>;
Comment on lines +58 to +59
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Permit explicit id in LabeledField prop types

The 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 same name). This blocks a practical escape hatch and contradicts the component comment that the id is auto-generated only when not supplied.

Useful? React with 👍 / 👎.


/**
* 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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Generate unique ids even when fields share the same name

Deriving fieldId directly from name makes every instance with the same name produce the same DOM id (e.g. repeated sections or same logical field in multiple forms). Duplicate ids break label targeting because htmlFor resolves to the first matching element, so later labels can focus/announce the wrong control; include a per-instance unique suffix or allow caller-provided ids to avoid collisions.

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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Build aria-describedby only from rendered helper text

When both description and error are set, aria-describedby includes both IDs, but the description element is not rendered (description && !error). That produces a broken ARIA ID reference at runtime and can trigger accessibility audit failures or inconsistent announcements in screen readers; the described-by list should match the elements actually present in the DOM.

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}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Forward required to the underlying form control

The component consumes required for label styling but never passes the native required attribute to <Input> (and the textarea variant follows the same pattern), so <LabeledField required> does not trigger built-in browser constraint validation. aria-required only exposes semantics to assistive tech and does not enforce submission rules, which can let empty required fields through unless every caller adds separate validation.

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';
28 changes: 26 additions & 2 deletions src/components/ui/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid reusing name as fallback id in base input primitives

Using name as the default id means controls that intentionally share a name (most notably radio groups) will also share the same id when callers omit id. This creates duplicate ids and breaks explicit <label for=...> associations, since multiple options in one group need the same name but distinct ids.

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",
Expand All @@ -25,4 +49,4 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
);
Input.displayName = "Input";

export { Input };
export { Input };
21 changes: 18 additions & 3 deletions src/components/ui/textarea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid reusing name as fallback id in textarea primitive

Textarea now mirrors the input fallback id ?? name ?? useId, which causes duplicate DOM ids whenever multiple textareas intentionally share the same name (e.g. repeated groups or array-style fields). Duplicate ids break label targeting and ARIA references, so id generation should remain unique per element even when names repeat.

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',
Expand Down