Skip to content
Ndop UI

Form Field

Lays out a form control with its label, description and error, and links them for assistive technology.

We'll never share your email.

At least 12 characters.

Or
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 [email protected].' });
    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-field

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.

Shipping address

Where should we send your order?

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.

Add-ons
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-slotfield-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-slotfield-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-slotfield-group

Field

[ndField]role group

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.

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.
booleanfalse
Data attribute Values
data-slotfield
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-slotfield-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-slotfield-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-slotfield-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-slotfield-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-slotfield-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-slotfield-separator-content

FieldError

[ndFieldError]role alert

A 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-slotfield-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 [email protected]".
  • 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.