Field
Preview
Basic
Loading…
Preview
Code
ts
import Field from '@/components/form/Field';src/components/form/Field.tsx
AI prompt
text
Build a form field wrapper (label + control + error, wired together) component in React + TypeScript + Tailwind CSS.
## Look
- Column (`flex flex-col`). Label on top in the field-label style (`mb-1`), laid out `flex items-center gap-1`.
- `required`: a rose-500 `*` after the label text (`aria-hidden`).
- `hint` is NOT a line under the control: it is a small ⓘ icon button beside the label (lucide `Info`, 12px, slate-400 → hover slate-600; dark slate-500 → hover slate-300) that shows the hint in a dark tooltip (slate-900, dark slate-700, white text) on hover or focus. The button calls `preventDefault` + `stopPropagation` so clicking it does not activate the label.
- Error: `<p class="mt-1 text-[11px] text-rose-600 dark:text-rose-400">` under the control.
- Also export three thin wrappers over the native elements, all with the house text-input recipe: `Input`, `Textarea` (adds `resize-y`) and `Select`. When `aria-invalid` is true they switch to `border-rose-400` and `focus:ring-rose-400/40`.
## Behaviour
- The id is generated inside Field with `useId()` and handed to the control through a RENDER PROP, which is what connects `<label htmlFor>`, `aria-describedby` and the error — the step everyone skips by hand.
- `children: (props: { id: string; 'aria-describedby'?: string; 'aria-invalid'?: boolean }) => ReactNode`. `aria-describedby` points at `<id>-error` and `aria-invalid` is true only while there is an error.
- Usage: `<Field label="Email" error={err}>{(p) => <Input {...p} />}</Field>`.
## API
`label: ReactNode`, `hint?: ReactNode`, `error?: string | null`, `required = false`, `className?`, `children` (render prop). Default export Field; named exports Input, Textarea, Select.
## Demo
In a `grid max-w-md gap-3`: "Full name" (required, "Ada Lovelace"); "Email" validated live, seeded "not-an-email", showing "Enter a valid email address." until it contains `@`; "Team" select (Engineering, Research, Design, Support, Operations) with hint "Determines default project access."; "Notes" textarea, 3 rows, placeholder "Optional".
## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.Source
tsx
'use client';
import { useId } from 'react';
import { cn } from '@/lib/cn';
import { InfoTooltip } from '@/components/overlay/Tooltip';
/**
* Label + control + error, wired together.
*
* The id is generated here and handed to the child through a render prop, which
* is what actually connects `<label for>`, `aria-describedby` and the error
* message. Passing them by hand is the step everyone skips, and the result is a
* form that a screen reader reads as a row of unlabelled boxes.
*/
export function Field({
label,
hint,
error,
required = false,
className,
children,
}: {
label: React.ReactNode;
/** Rendered behind an ⓘ beside the label, not under the control. */
hint?: React.ReactNode;
error?: string | null;
required?: boolean;
className?: string;
children: (props: { id: string; 'aria-describedby'?: string; 'aria-invalid'?: boolean }) => React.ReactNode;
}) {
const id = useId();
const errorId = `${id}-error`;
return (
<div className={cn('flex flex-col', className)}>
<label htmlFor={id} className="field-label flex items-center gap-1">
{label}
{required && <span className="text-rose-500" aria-hidden>*</span>}
{hint && <InfoTooltip content={hint} iconClassName="w-3 h-3" />}
</label>
{children({ id, 'aria-describedby': error ? errorId : undefined, 'aria-invalid': !!error })}
{error && (
<p id={errorId} className="mt-1 text-[11px] text-rose-600 dark:text-rose-400">
{error}
</p>
)}
</div>
);
}
export const Input = (props: React.InputHTMLAttributes<HTMLInputElement>) => (
<input {...props} className={cn('field-input', props['aria-invalid'] && 'border-rose-400 focus:ring-rose-400/40', props.className)} />
);
export const Textarea = (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => (
<textarea {...props} className={cn('field-input resize-y', props['aria-invalid'] && 'border-rose-400 focus:ring-rose-400/40', props.className)} />
);
export const Select = (props: React.SelectHTMLAttributes<HTMLSelectElement>) => (
<select {...props} className={cn('field-input', props['aria-invalid'] && 'border-rose-400 focus:ring-rose-400/40', props.className)} />
);
export default Field;
Props
| Prop | Type | Default | Description |
|---|---|---|---|
label* | React.ReactNode | — | |
children* | (props: { id: string; 'aria-describedby'?: string; 'aria-invalid'?: boolean }) => React.ReactNode | — | |
hint | React.ReactNode | — | Rendered behind an ⓘ beside the label, not under the control. |
error | string | null | — | |
required | boolean | false | |
className | string | — |
Also accepts every prop <input> takes — they are spread onto the root element.