Form Field
Lays out a form control with its label, description and error, and links them for assistive technology.
- v1.0.0
- WAI-ARIA: `aria-describedby`
- SSR
- Zoneless
- 2.9 kB gzip
import { Component, signal } from '@angular/core';
import {
email,
form,
FormField,
minLength,
required,
} from '@angular/forms/signals';
import { Button } from '@/ui/button/button';
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldSeparator,
FieldSeparatorContent,
} from '@/ui/form-field/form-field';
import { Input } from '@/ui/input/input';
/**
* A sign-up form in Signal Forms. Each field's input is described by its
* help text and, once it's invalid and has been left, by its errors, which
* appear and are announced then.
*/
@Component({
selector: 'example-form-field-demo',
imports: [
Button,
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldSeparator,
FieldSeparatorContent,
FormField,
Input,
],
template: `
<form class="w-full max-w-sm" (submit)="submit($event)">
<div ndFieldGroup>
<div ndField>
<label ndFieldLabel for="form-field-demo-email">Email</label>
<input
ndInput
id="form-field-demo-email"
type="email"
autocomplete="email"
[formField]="signup.email"
/>
<p ndFieldDescription>We'll never share your email.</p>
<div ndFieldError>
@for (error of signup.email().errors(); track error.kind) {
<p>{{ error.message }}</p>
}
</div>
</div>
<div ndField>
<label ndFieldLabel for="form-field-demo-password">Password</label>
<input
ndInput
id="form-field-demo-password"
type="password"
autocomplete="new-password"
[formField]="signup.password"
/>
<p ndFieldDescription>At least 12 characters.</p>
<div ndFieldError>
@for (error of signup.password().errors(); track error.kind) {
<p>{{ error.message }}</p>
}
</div>
</div>
<button ndButton type="submit">Create account</button>
<div ndFieldSeparator>
<span ndFieldSeparatorContent>Or</span>
</div>
<button ndButton type="button" variant="outline">
Continue with a passkey
</button>
</div>
</form>
`,
})
export class FormFieldDemoExample {
protected readonly model = signal({ email: '', password: '' });
protected readonly signup = form(this.model, (path) => {
required(path.email, { message: 'Enter your email.' });
email(path.email, { message: 'Enter an email like ada@example.com.' });
required(path.password, { message: 'Choose a password.' });
minLength(path.password, 12, {
message: 'Use at least 12 characters.',
});
});
/**
* Marks the fields touched, so errors show, instead of reloading.
*
* @param event - The form's submit event.
*/
protected submit(event: Event): void {
event.preventDefault();
this.signup().markAsTouched();
}
}
A field lays out one form control with its label, description and error, and links them for screen readers: the control's description lists the help text and, once the control is invalid and has been touched, the error, which then appears and is announced. The field turns destructive with it. Fields stack the label above the control, sit side by side for checkboxes and switches, or switch between the two by width; field sets and groups space related fields.
Installation
npx @ndop/cli add form-fieldpnpm dlx @ndop/cli add form-fieldyarn dlx @ndop/cli add form-fieldbunx @ndop/cli add form-fieldInstall the dependencies
npm install @ndop/primitives @ndop/corepnpm add @ndop/primitives @ndop/coreyarn add @ndop/primitives @ndop/corebun add @ndop/primitives @ndop/coreCopy the code into your project
At the paths your
ndop.jsonaliases point to; these use the defaults.src/app/ui/form-field/form-field.tsimport { computed, Directive, input } from '@angular/core'; import { NdField, NdFieldDescription, NdFieldError, } from '@ndop/primitives/form-field'; import { NdLabel } from '@ndop/primitives/label'; import { cn, variants, type VariantOf } from '@/lib/utils'; /** * A set of related fields, on a native `fieldset`, titled by a * {@link FieldLegend}. * * @example * ```html * <fieldset ndFieldSet> * <legend ndFieldLegend>Address</legend> * <div ndFieldGroup>…</div> * </fieldset> * ``` */ @Directive({ selector: 'fieldset[ndFieldSet]', host: { 'data-slot': 'field-set', '[class]': 'classes()' }, }) export class FieldSet { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex flex-col gap-6', 'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3', this.class(), ), ); } /** * The title of a {@link FieldSet}, on a native `legend`: `legend` size by * default, or `label` size for a set that reads as one field. * * @example * ```html * <legend ndFieldLegend variant="label">Notifications</legend> * ``` */ @Directive({ selector: 'legend[ndFieldLegend]', host: { 'data-slot': 'field-legend', '[attr.data-variant]': 'variant()', '[class]': 'classes()', }, }) export class FieldLegend { /** Text size: `legend` or `label`. */ readonly variant = input<'legend' | 'label'>('legend'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'mb-3 font-medium', 'data-[variant=legend]:text-base', 'data-[variant=label]:text-sm', this.class(), ), ); } /** * Stacks fields with even spacing; responsive fields inside switch to a * row when the group is wide enough. * * @example * ```html * <div ndFieldGroup>…</div> * ``` */ @Directive({ selector: '[ndFieldGroup]', host: { 'data-slot': 'field-group', '[class]': 'classes()' }, }) export class FieldGroup { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4', this.class(), ), ); } /** Class table for {@link Field}. */ export const fieldVariants = variants({ base: 'group/field flex w-full gap-3 data-invalid:text-destructive', variants: { orientation: { vertical: 'flex-col [&>*]:w-full [&>.sr-only]:w-auto', horizontal: cn( 'flex-row items-center', '[&>[data-slot=field-label]]:flex-auto', 'has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px', ), responsive: cn( 'flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto', '@md/field-group:[&>[data-slot=field-label]]:flex-auto', '@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px', ), }, }, defaults: { orientation: 'vertical' }, }); /** Layout of a {@link Field}. */ export type FieldOrientation = VariantOf<typeof fieldVariants, 'orientation'>; /** * One form field (`@ndop/primitives/form-field`): a control with its * label, description and error. Ndop controls inside it list the * description and, once invalid, the error in their `aria-describedby`; * the error shows only then. The field turns destructive while its control * is invalid. * * @example * ```html * <div ndField> * <label ndFieldLabel for="email">Email</label> * <input ndInput id="email" type="email" [formField]="form.email" /> * <p ndFieldDescription>We'll never share your email.</p> * <div ndFieldError>Enter a valid email address.</div> * </div> * ``` */ @Directive({ selector: '[ndField]', hostDirectives: [{ directive: NdField, inputs: ['invalid'] }], host: { 'data-slot': 'field', '[attr.data-orientation]': 'orientation()', '[class]': 'classes()', }, }) export class Field { /** * `vertical` stacks the label above the control; `horizontal` puts them * side by side, as for a checkbox; `responsive` switches from one to the * other when its `ndFieldGroup` is wide enough. */ readonly orientation = input<FieldOrientation>('vertical'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn(fieldVariants({ orientation: this.orientation() }), this.class()), ); } /** * Groups a field's label and description beside its control, as for a * checkbox with help text. * * @example * ```html * <div ndFieldContent> * <label ndFieldLabel for="news">Newsletter</label> * <p ndFieldDescription>One email a month.</p> * </div> * ``` */ @Directive({ selector: '[ndFieldContent]', host: { 'data-slot': 'field-content', '[class]': 'classes()' }, }) export class FieldContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'group/field-content flex flex-1 flex-col gap-1.5 leading-snug', this.class(), ), ); } /** * A field's label, on a native `label` (`@ndop/primitives/label`). Wrapping * a whole {@link Field} in it makes a selectable card, highlighted while * the checkbox or radio inside is checked; its descriptions darken then, * to keep their contrast on the tint. * * @example * ```html * <label ndFieldLabel for="email">Email</label> * ``` */ @Directive({ selector: 'label[ndFieldLabel]', hostDirectives: [{ directive: NdLabel, inputs: ['for'] }], host: { 'data-slot': 'field-label', '[class]': 'classes()' }, }) export class FieldLabel { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50', 'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-disabled/field:opacity-50', 'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4', 'has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 dark:has-data-[state=checked]:bg-primary/10', 'has-data-[state=checked]:[&_[data-slot=field-description]]:text-foreground/80', this.class(), ), ); } /** * A field's title when it isn't a label, such as the heading of a * selectable card. * * @example * ```html * <div ndFieldTitle>Pro plan</div> * ``` */ @Directive({ selector: '[ndFieldTitle]', host: { 'data-slot': 'field-label', '[class]': 'classes()' }, }) export class FieldTitle { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-disabled/field:opacity-50', this.class(), ), ); } /** * Help text for a {@link Field}'s control, which lists it in * `aria-describedby`. * * @example * ```html * <p ndFieldDescription>We'll never share your email.</p> * ``` */ @Directive({ selector: '[ndFieldDescription]', hostDirectives: [NdFieldDescription], host: { 'data-slot': 'field-description', '[class]': 'classes()' }, }) export class FieldDescription { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'text-sm leading-normal font-normal text-muted-foreground group-has-[[data-orientation=horizontal]]/field:text-balance', 'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5', '[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary', this.class(), ), ); } /** * A line between fields, with optional text on it, such as "Or". * * @example * ```html * <div ndFieldSeparator> * <span ndFieldSeparatorContent>Or continue with</span> * </div> * ``` */ @Directive({ selector: '[ndFieldSeparator]', host: { 'data-slot': 'field-separator', '[class]': 'classes()' }, }) export class FieldSeparator { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2', 'before:absolute before:inset-x-0 before:top-1/2 before:h-px before:bg-border', this.class(), ), ); } /** * The text on a {@link FieldSeparator}'s line. * * @example * ```html * <span ndFieldSeparatorContent>Or continue with</span> * ``` */ @Directive({ selector: '[ndFieldSeparatorContent]', host: { 'data-slot': 'field-separator-content', '[class]': 'classes()' }, }) export class FieldSeparatorContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'relative mx-auto block w-fit bg-background px-2 text-muted-foreground', this.class(), ), ); } /** * A {@link Field}'s error, shown and announced once its control is invalid * and touched. Put the messages inside: one as text, several as a list. * * @example * ```html * <div ndFieldError> * @for (error of form.email().errors(); track error.kind) { * <p>{{ error.message }}</p> * } * </div> * ``` */ @Directive({ selector: '[ndFieldError]', hostDirectives: [NdFieldError], host: { 'data-slot': 'field-error', '[class]': 'classes()' }, }) export class FieldError { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('text-sm font-normal text-destructive', this.class()), ); }src/app/lib/utils.ts/** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core';Update the import paths to match your project
The files import each other through the
@/uiand@/libaliases.
Usage
import { Button } from '@/ui/button/button';
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldSeparator,
FieldSeparatorContent,
} from '@/ui/form-field/form-field';
import { Input } from '@/ui/input/input';<form class="w-full max-w-sm" (submit)="submit($event)">
<div ndFieldGroup>
<div ndField>
<label ndFieldLabel for="form-field-demo-email">Email</label>
<input
ndInput
id="form-field-demo-email"
type="email"
autocomplete="email"
[formField]="signup.email"
/>
<p ndFieldDescription>We'll never share your email.</p>
<div ndFieldError>
@for (error of signup.email().errors(); track error.kind) {
<p>{{ error.message }}</p>
}
</div>
</div>
<div ndField>
<label ndFieldLabel for="form-field-demo-password">Password</label>
<input
ndInput
id="form-field-demo-password"
type="password"
autocomplete="new-password"
[formField]="signup.password"
/>
<p ndFieldDescription>At least 12 characters.</p>
<div ndFieldError>
@for (error of signup.password().errors(); track error.kind) {
<p>{{ error.message }}</p>
}
</div>
</div>
<button ndButton type="submit">Create account</button>
<div ndFieldSeparator>
<span ndFieldSeparatorContent>Or</span>
</div>
<button ndButton type="button" variant="outline">
Continue with a passkey
</button>
</div>
</form>Examples
Form Field fieldset
An address in a field set with a legend, its fields side by side when there's room.
import { Component } from '@angular/core';
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from '@/ui/form-field/form-field';
import { Input } from '@/ui/input/input';
/**
* An address in a field set with a legend. The responsive fields put
* their label beside the input when the group is wide enough.
*/
@Component({
selector: 'example-form-field-fieldset',
imports: [
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
Input,
],
template: `
<fieldset ndFieldSet class="w-full max-w-lg">
<legend ndFieldLegend>Shipping address</legend>
<p ndFieldDescription>Where should we send your order?</p>
<div ndFieldGroup>
<div ndField orientation="responsive">
<label ndFieldLabel for="form-field-fieldset-street">Street</label>
<input
ndInput
id="form-field-fieldset-street"
autocomplete="street-address"
/>
</div>
<div ndField orientation="responsive">
<label ndFieldLabel for="form-field-fieldset-city">City</label>
<input
ndInput
id="form-field-fieldset-city"
autocomplete="address-level2"
/>
</div>
<div ndField orientation="responsive">
<label ndFieldLabel for="form-field-fieldset-postcode">
Postal code
</label>
<input
ndInput
id="form-field-fieldset-postcode"
autocomplete="postal-code"
/>
</div>
</div>
</fieldset>
`,
})
export class FormFieldFieldsetExample {}
Form Field horizontal
Checkbox and switch fields with their labels and descriptions beside the control.
Your settings follow you to every device you sign in on.
Ask for a code from your phone when you sign in.
import { Component } from '@angular/core';
import { NdIcon } from '@ndop/core';
import { Checkbox, CheckboxIndicator } from '@/ui/checkbox/checkbox';
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
} from '@/ui/form-field/form-field';
import { Switch, SwitchThumb } from '@/ui/switch/switch';
/**
* Checkbox and switch fields with their labels and descriptions beside
* the control, which each control's description lists.
*/
@Component({
selector: 'example-form-field-horizontal',
imports: [
Checkbox,
CheckboxIndicator,
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
NdIcon,
Switch,
SwitchThumb,
],
template: `
<div ndFieldGroup class="w-full max-w-md">
<div ndField orientation="horizontal">
<button
ndCheckbox
id="form-field-horizontal-sync"
[defaultChecked]="true"
>
<span ndCheckboxIndicator><svg ndIcon="check"></svg></span>
</button>
<div ndFieldContent>
<label ndFieldLabel for="form-field-horizontal-sync">
Sync across devices
</label>
<p ndFieldDescription>
Your settings follow you to every device you sign in on.
</p>
</div>
</div>
<div ndField orientation="horizontal">
<div ndFieldContent>
<label ndFieldLabel for="form-field-horizontal-2fa">
Two-factor authentication
</label>
<p ndFieldDescription>
Ask for a code from your phone when you sign in.
</p>
</div>
<button ndSwitch id="form-field-horizontal-2fa">
<span ndSwitchThumb></span>
</button>
</div>
</div>
`,
})
export class FormFieldHorizontalExample {}
Form Field choice card
Options as cards: each whole field is the label of its radio.
import { Component } from '@angular/core';
import { NdIcon } from '@ndop/core';
import { Checkbox, CheckboxIndicator } from '@/ui/checkbox/checkbox';
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldLegend,
FieldSet,
FieldTitle,
} from '@/ui/form-field/form-field';
/**
* Add-ons as cards: each whole field is the label of its checkbox, and a
* checked card is highlighted.
*/
@Component({
selector: 'example-form-field-choice-card',
imports: [
Checkbox,
CheckboxIndicator,
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldLegend,
FieldSet,
FieldTitle,
NdIcon,
],
template: `
<fieldset ndFieldSet class="w-full max-w-sm">
<legend ndFieldLegend variant="label">Add-ons</legend>
@for (addOn of addOns; track addOn.id) {
<label ndFieldLabel [for]="'form-field-choice-card-' + addOn.id">
<div ndField orientation="horizontal">
<button
ndCheckbox
[id]="'form-field-choice-card-' + addOn.id"
[attr.aria-label]="addOn.title"
[defaultChecked]="addOn.checked"
>
<span ndCheckboxIndicator><svg ndIcon="check"></svg></span>
</button>
<div ndFieldContent>
<div ndFieldTitle>{{ addOn.title }}</div>
<p ndFieldDescription>{{ addOn.description }}</p>
</div>
</div>
</label>
}
</fieldset>
`,
})
export class FormFieldChoiceCardExample {
protected readonly addOns = [
{
id: 'backups',
title: 'Daily backups',
description: 'Restore any day from the last 30.',
checked: true,
},
{
id: 'domain',
title: 'Custom domain',
description: 'Serve your site from your own address.',
checked: false,
},
];
}
Accessibility
Implements the `aria-describedby` pattern of the WAI-ARIA Authoring Practices.
Focus
- A field never moves focus. An error appearing doesn't take focus; it's announced.
- Clicking the label moves focus to the associated control (native behavior, unchanged).
Right-to-left
Nothing mirrors. Nothing mirrors.
API reference
FieldSet
fieldset[ndFieldSet]A set of related fields, on a native fieldset, titled by a
FieldLegend.
| FieldSet binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | field-set |
FieldLegend
legend[ndFieldLegend]The title of a FieldSet, on a native legend: legend size by
default, or label size for a set that reads as one field.
| FieldLegend binding | Type | Default |
|---|---|---|
[variant]Text size: legend or label. | 'legend' | 'label' | 'legend' |
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | field-legend |
data-variant | — |
FieldGroup
[ndFieldGroup]Stacks fields with even spacing; responsive fields inside switch to a row when the group is wide enough.
| FieldGroup binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | field-group |
Field
[ndField]role groupOne form field (@ndop/primitives/form-field): a control with its
label, description and error. Ndop controls inside it list the
description and, once invalid, the error in their aria-describedby;
the error shows only then. The field turns destructive while its control
is invalid.
| Field binding | Type | Default |
|---|---|---|
[orientation]vertical stacks the label above the control; horizontal puts them
side by side, as for a checkbox; responsive switches from one to the
other when its ndFieldGroup is wide enough. | 'horizontal' | 'vertical' | 'responsive' | 'vertical' |
[class]Extra classes, merged last so they win. | string | '' |
[invalid]Marks the field invalid by hand, for errors no control reports. | boolean | false |
| Data attribute | Values |
|---|---|
data-slot | field |
data-orientation | — |
data-invalid | — |
data-disabled | — |
FieldContent
[ndFieldContent]Groups a field's label and description beside its control, as for a checkbox with help text.
| FieldContent binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | field-content |
FieldLabel
label[ndFieldLabel]A field's label, on a native label (@ndop/primitives/label). Wrapping
a whole Field in it makes a selectable card, highlighted while
the checkbox or radio inside is checked; its descriptions darken then,
to keep their contrast on the tint.
| FieldLabel binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
[for]Id of the labelled control. | string | undefined | — |
| Data attribute | Values |
|---|---|
data-slot | field-label |
data-disabled | — |
data-invalid | — |
FieldTitle
[ndFieldTitle]A field's title when it isn't a label, such as the heading of a selectable card.
| FieldTitle binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | field-label |
FieldDescription
[ndFieldDescription]Help text for a Field's control, which lists it in
aria-describedby.
| FieldDescription binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | field-description |
FieldSeparator
[ndFieldSeparator]A line between fields, with optional text on it, such as "Or".
| FieldSeparator binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | field-separator |
FieldSeparatorContent
[ndFieldSeparatorContent]The text on a FieldSeparator's line.
| FieldSeparatorContent binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | field-separator-content |
FieldError
[ndFieldError]role alertA Field's error, shown and announced once its control is invalid
and touched. Put the messages inside: one as text, several as a list.
| FieldError binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | field-error |
Agent notes
Do
- Put each control in its own field, with a label.
- Write errors that say how to fix the problem, such as "Enter an email address like ada@example.com".
- Group related fields in a field set with a legend, such as an address.
Don't
- Don't show errors before people have left the field.
- Don't put more than one text control in a field; give each its own.