Form Field

Form-field composition primitive that links label/control/description semantics.

Label, control, and help

Named label and help slots around a Looma input. The label's for and the input's id link them before JavaScript runs; the field links the help text.

Help text

Code

<link
  rel="component"
  href="@threadlabs/looma/components/ui-form-field/ui-form-field.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-input/ui-input.html"
>

<ui-form-field>
  <label
    slot="label"
    for="form-field-label"
  >Label</label>
  <ui-input
    id="form-field-label"
  > </ui-input>
  <small
    slot="help"
  >Help text</small>
</ui-form-field>
<script setup>
  import { UiFormField, UiInput } from "@threadlabs/looma";
</script>

<template>
  <UiFormField>
    <template #label>
      <label
        for="form-field-label"
      >Label</label>
    </template>
    <UiInput
      id="form-field-label"
    > </UiInput>
    <template #help>
      <small>Help text</small>
    </template>
  </UiFormField>
</template>
import { UiFormField, UiInput } from "@threadlabs/looma";

export default function Example() {
  return <UiFormField
    slots={{
      label: <label
        htmlFor="form-field-label"
      >Label</label>,
      help: <small>Help text</small>,
    }}
  >
    <UiInput
      id="form-field-label"
    > </UiInput>
  </UiFormField>;
}
/** @jsxImportSource preact */
import { UiFormField, UiInput } from "@threadlabs/looma";

export default function Example() {
  return <UiFormField
    slots={{
      label: <label
        htmlFor="form-field-label"
      >Label</label>,
      help: <small>Help text</small>,
    }}
  >
    <UiInput
      id="form-field-label"
    > </UiInput>
  </UiFormField>;
}
<script>
  import { UiFormField, UiInput } from "@threadlabs/looma";
</script>

{#snippet label()}
  <label
    for="form-field-label"
  >Label</label>
{/snippet}

{#snippet help()}
  <small>Help text</small>
{/snippet}

<UiFormField
  slots={{ label, help }}
>
  <UiInput
    id="form-field-label"
  > </UiInput>
</UiFormField>
import { Component } from "@angular/core";
import { UiFormField, UiInput } from "@threadlabs/looma";

@Component({
  selector: "example-view",
  standalone: true,
  imports: [UiFormField, UiInput],
  template: `
    <div
      ui-form-field
    >
      <ng-container ngProjectAs="[slot='label']">
        <label
          for="form-field-label"
        >Label</label>
      </ng-container>
      <input
        ui-input
        id="form-field-label"
      >
      <ng-container ngProjectAs="[slot='help']">
        <small>Help text</small>
      </ng-container>
    </div>
  `,
})
export default class Example {}

required and invalid

Required and invalid attach state to the label, native control, and error message as one field.

Error message

Code

<link
  rel="component"
  href="@threadlabs/looma/components/ui-form-field/ui-form-field.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-input/ui-input.html"
>

<ui-form-field
  required
  invalid
>
  <label
    slot="label"
    for="form-field-required"
  >Label</label>
  <ui-input
    id="form-field-required"
  > </ui-input>
  <small
    slot="error"
  >Error message</small>
</ui-form-field>
<script setup>
  import { UiFormField, UiInput } from "@threadlabs/looma";
</script>

<template>
  <UiFormField
    :required="true"
    :invalid="true"
  >
    <template #label>
      <label
        for="form-field-required"
      >Label</label>
    </template>
    <UiInput
      id="form-field-required"
    > </UiInput>
    <template #error>
      <small>Error message</small>
    </template>
  </UiFormField>
</template>
import { UiFormField, UiInput } from "@threadlabs/looma";

export default function Example() {
  return <UiFormField
    required
    invalid
    slots={{
      label: <label
        htmlFor="form-field-required"
      >Label</label>,
      error: <small>Error message</small>,
    }}
  >
    <UiInput
      id="form-field-required"
    > </UiInput>
  </UiFormField>;
}
/** @jsxImportSource preact */
import { UiFormField, UiInput } from "@threadlabs/looma";

export default function Example() {
  return <UiFormField
    required
    invalid
    slots={{
      label: <label
        htmlFor="form-field-required"
      >Label</label>,
      error: <small>Error message</small>,
    }}
  >
    <UiInput
      id="form-field-required"
    > </UiInput>
  </UiFormField>;
}
<script>
  import { UiFormField, UiInput } from "@threadlabs/looma";
</script>

{#snippet label()}
  <label
    for="form-field-required"
  >Label</label>
{/snippet}

{#snippet error()}
  <small>Error message</small>
{/snippet}

<UiFormField
  required
  invalid
  slots={{ label, error }}
>
  <UiInput
    id="form-field-required"
  > </UiInput>
</UiFormField>
import { Component } from "@angular/core";
import { UiFormField, UiInput } from "@threadlabs/looma";

@Component({
  selector: "example-view",
  standalone: true,
  imports: [UiFormField, UiInput],
  template: `
    <div
      ui-form-field
      [required]="true"
      [invalid]="true"
    >
      <ng-container ngProjectAs="[slot='label']">
        <label
          for="form-field-required"
        >Label</label>
      </ng-container>
      <input
        ui-input
        id="form-field-required"
      >
      <ng-container ngProjectAs="[slot='error']">
        <small>Error message</small>
      </ng-container>
    </div>
  `,
})
export default class Example {}

Label action

A small help button in the labelAction slot sits after the label, centred on its first line. It never makes the label row taller, so fields beside it in a grid keep the same label-to-control gap.

Code

<link
  rel="component"
  href="@threadlabs/looma/components/ui-grid/ui-grid.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-form-field/ui-form-field.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-input/ui-input.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-icon-button/ui-icon-button.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-icon/ui-icon.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-tooltip/ui-tooltip.html"
>

<ui-grid>
  <ui-form-field>
    <label
      slot="label"
      for="form-field-name"
    >Name</label>
    <ui-input
      id="form-field-name"
    > </ui-input>
  </ui-form-field>
  <ui-form-field>
    <label
      slot="label"
      for="form-field-price"
    >Price</label>
    <ui-icon-button
      slot="labelAction"
      id="form-field-price-help"
      label="Help for Price"
      variant="ghost"
      size="sm"
      round
    >
      <ui-icon
        name="help"
      ></ui-icon>
    </ui-icon-button>
    <ui-input
      id="form-field-price"
    > </ui-input>
  </ui-form-field>
</ui-grid>
<ui-tooltip
  for="form-field-price-help"
  trigger="click"
>The amount before tax.</ui-tooltip>
<script setup>
  import { UiFormField, UiGrid, UiIcon, UiIconButton, UiInput, UiTooltip } from "@threadlabs/looma";
</script>

<template>
  <UiGrid>
    <UiFormField>
      <template #label>
        <label
          for="form-field-name"
        >Name</label>
      </template>
      <UiInput
        id="form-field-name"
      > </UiInput>
    </UiFormField>
    <UiFormField>
      <template #label>
        <label
          for="form-field-price"
        >Price</label>
      </template>
      <template #labelAction>
        <UiIconButton
          id="form-field-price-help"
          label="Help for Price"
          variant="ghost"
          size="sm"
          :round="true"
        >
          <UiIcon
            name="help"
          />
        </UiIconButton>
      </template>
      <UiInput
        id="form-field-price"
      > </UiInput>
    </UiFormField>
  </UiGrid>
  <UiTooltip
    for="form-field-price-help"
    trigger="click"
  >The amount before tax.</UiTooltip>
</template>
import { UiFormField, UiGrid, UiIcon, UiIconButton, UiInput, UiTooltip } from "@threadlabs/looma";

export default function Example() {
  return <>
    <UiGrid>
      <UiFormField
        slots={{
          label: <label
            htmlFor="form-field-name"
          >Name</label>,
        }}
      >
        <UiInput
          id="form-field-name"
        > </UiInput>
      </UiFormField>
      <UiFormField
        slots={{
          label: <label
            htmlFor="form-field-price"
          >Price</label>,
          labelAction: <UiIconButton
            id="form-field-price-help"
            label="Help for Price"
            variant="ghost"
            size="sm"
            round
          >
            <UiIcon
              name="help"
            />
          </UiIconButton>,
        }}
      >
        <UiInput
          id="form-field-price"
        > </UiInput>
      </UiFormField>
    </UiGrid>
    <UiTooltip
      htmlFor="form-field-price-help"
      trigger="click"
    >The amount before tax.</UiTooltip>
  </>;
}
/** @jsxImportSource preact */
import { UiFormField, UiGrid, UiIcon, UiIconButton, UiInput, UiTooltip } from "@threadlabs/looma";

export default function Example() {
  return <>
    <UiGrid>
      <UiFormField
        slots={{
          label: <label
            htmlFor="form-field-name"
          >Name</label>,
        }}
      >
        <UiInput
          id="form-field-name"
        > </UiInput>
      </UiFormField>
      <UiFormField
        slots={{
          label: <label
            htmlFor="form-field-price"
          >Price</label>,
          labelAction: <UiIconButton
            id="form-field-price-help"
            label="Help for Price"
            variant="ghost"
            size="sm"
            round
          >
            <UiIcon
              name="help"
            />
          </UiIconButton>,
        }}
      >
        <UiInput
          id="form-field-price"
        > </UiInput>
      </UiFormField>
    </UiGrid>
    <UiTooltip
      htmlFor="form-field-price-help"
      trigger="click"
    >The amount before tax.</UiTooltip>
  </>;
}
<script>
  import { UiFormField, UiGrid, UiIcon, UiIconButton, UiInput, UiTooltip } from "@threadlabs/looma";
</script>

{#snippet label()}
  <label
    for="form-field-name"
  >Name</label>
{/snippet}

{#snippet formFieldLabel()}
  <label
    for="form-field-price"
  >Price</label>
{/snippet}

{#snippet labelAction()}
  <UiIconButton
    id="form-field-price-help"
    label="Help for Price"
    variant="ghost"
    size="sm"
    round
  >
    <UiIcon
      name="help"
    />
  </UiIconButton>
{/snippet}

<UiGrid>
  <UiFormField
    slots={{ label }}
  >
    <UiInput
      id="form-field-name"
    > </UiInput>
  </UiFormField>
  <UiFormField
    slots={{ label: formFieldLabel, labelAction }}
  >
    <UiInput
      id="form-field-price"
    > </UiInput>
  </UiFormField>
</UiGrid>
<UiTooltip
  for="form-field-price-help"
  trigger="click"
>The amount before tax.</UiTooltip>
import { Component } from "@angular/core";
import { UiFormField, UiGrid, UiIcon, UiIconButton, UiInput, UiTooltip } from "@threadlabs/looma";

@Component({
  selector: "example-view",
  standalone: true,
  imports: [UiGrid, UiFormField, UiInput, UiIconButton, UiIcon, UiTooltip],
  template: `
    <div
      ui-grid
    >
      <div
        ui-form-field
      >
        <ng-container ngProjectAs="[slot='label']">
          <label
            for="form-field-name"
          >Name</label>
        </ng-container>
        <input
          ui-input
          id="form-field-name"
        >
      </div>
      <div
        ui-form-field
      >
        <ng-container ngProjectAs="[slot='label']">
          <label
            for="form-field-price"
          >Price</label>
        </ng-container>
        <ng-container ngProjectAs="[slot='labelAction']">
          <button
            ui-icon-button
            id="form-field-price-help"
            [label]="'Help for Price'"
            [variant]="'ghost'"
            [size]="'sm'"
            [round]="true"
          >
            <span
              ui-icon
              name="help"
            ></span>
          </button>
        </ng-container>
        <input
          ui-input
          id="form-field-price"
        >
      </div>
    </div>
    <span
      ui-tooltip
      [for]="'form-field-price-help'"
      [trigger]="'click'"
    >The amount before tax.</span>
  `,
})
export default class Example {}

Validation summary

After validation, focus the summary and link each error to its native input. Keep the same message beside the field. The application supplies errors and decides when to validate; Callout, List and Form Field own the presentation.

Enter your name.
Enter a valid email address.

Code

<link
  rel="component"
  href="@threadlabs/looma/components/ui-stack/ui-stack.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-callout/ui-callout.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-list/ui-list.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-list-item/ui-list-item.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-form-field/ui-form-field.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-input/ui-input.html"
>

<ui-stack>
  <section
    data-error-summary
    tabindex="-1"
    aria-label="Check these two fields"
  >
    <ui-callout
      tone="danger"
      heading="Check these two fields"
    >
      <ui-list
        density="compact"
      >
        <ui-list-item
          wrap
        ><a
            href="#summary-name"
          >Enter your name.</a></ui-list-item>
        <ui-list-item
          wrap
        ><a
            href="#summary-email"
          >Enter a valid email address.</a></ui-list-item>
      </ui-list>
    </ui-callout>
  </section>
  <ui-form-field
    required
    invalid
  >
    <label
      slot="label"
      for="summary-name"
    >Name</label>
    <ui-input
      id="summary-name"
      name="name"
      autocomplete="name"
    ></ui-input>
    <small
      slot="error"
    >Enter your name.</small>
  </ui-form-field>
  <ui-form-field
    required
    invalid
  >
    <label
      slot="label"
      for="summary-email"
    >Email</label>
    <ui-input
      id="summary-email"
      name="email"
      type="email"
      value="incomplete"
      autocomplete="email"
    ></ui-input>
    <small
      slot="error"
    >Enter a valid email address.</small>
  </ui-form-field>
</ui-stack>
<script setup>
  import { UiCallout, UiFormField, UiInput, UiList, UiListItem, UiStack } from "@threadlabs/looma";
</script>

<template>
  <UiStack>
    <section
      data-error-summary=""
      tabindex="-1"
      aria-label="Check these two fields"
    >
      <UiCallout
        tone="danger"
        heading="Check these two fields"
      >
        <UiList
          density="compact"
        >
          <UiListItem
            :wrap="true"
          ><a
              href="#summary-name"
            >Enter your name.</a></UiListItem>
          <UiListItem
            :wrap="true"
          ><a
              href="#summary-email"
            >Enter a valid email address.</a></UiListItem>
        </UiList>
      </UiCallout>
    </section>
    <UiFormField
      :required="true"
      :invalid="true"
    >
      <template #label>
        <label
          for="summary-name"
        >Name</label>
      </template>
      <UiInput
        id="summary-name"
        name="name"
        autocomplete="name"
      />
      <template #error>
        <small>Enter your name.</small>
      </template>
    </UiFormField>
    <UiFormField
      :required="true"
      :invalid="true"
    >
      <template #label>
        <label
          for="summary-email"
        >Email</label>
      </template>
      <UiInput
        id="summary-email"
        name="email"
        type="email"
        value="incomplete"
        autocomplete="email"
      />
      <template #error>
        <small>Enter a valid email address.</small>
      </template>
    </UiFormField>
  </UiStack>
</template>
import { UiCallout, UiFormField, UiInput, UiList, UiListItem, UiStack } from "@threadlabs/looma";

export default function Example() {
  return <UiStack>
    <section
      data-error-summary=""
      tabIndex={-1}
      aria-label="Check these two fields"
    >
      <UiCallout
        tone="danger"
        heading="Check these two fields"
      >
        <UiList
          density="compact"
        >
          <UiListItem
            wrap
          ><a
              href="#summary-name"
            >Enter your name.</a></UiListItem>
          <UiListItem
            wrap
          ><a
              href="#summary-email"
            >Enter a valid email address.</a></UiListItem>
        </UiList>
      </UiCallout>
    </section>
    <UiFormField
      required
      invalid
      slots={{
        label: <label
          htmlFor="summary-name"
        >Name</label>,
        error: <small>Enter your name.</small>,
      }}
    >
      <UiInput
        id="summary-name"
        name="name"
        autocomplete="name"
      />
    </UiFormField>
    <UiFormField
      required
      invalid
      slots={{
        label: <label
          htmlFor="summary-email"
        >Email</label>,
        error: <small>Enter a valid email address.</small>,
      }}
    >
      <UiInput
        id="summary-email"
        name="email"
        type="email"
        value="incomplete"
        autocomplete="email"
      />
    </UiFormField>
  </UiStack>;
}
/** @jsxImportSource preact */
import { UiCallout, UiFormField, UiInput, UiList, UiListItem, UiStack } from "@threadlabs/looma";

export default function Example() {
  return <UiStack>
    <section
      data-error-summary=""
      tabIndex={-1}
      aria-label="Check these two fields"
    >
      <UiCallout
        tone="danger"
        heading="Check these two fields"
      >
        <UiList
          density="compact"
        >
          <UiListItem
            wrap
          ><a
              href="#summary-name"
            >Enter your name.</a></UiListItem>
          <UiListItem
            wrap
          ><a
              href="#summary-email"
            >Enter a valid email address.</a></UiListItem>
        </UiList>
      </UiCallout>
    </section>
    <UiFormField
      required
      invalid
      slots={{
        label: <label
          htmlFor="summary-name"
        >Name</label>,
        error: <small>Enter your name.</small>,
      }}
    >
      <UiInput
        id="summary-name"
        name="name"
        autocomplete="name"
      />
    </UiFormField>
    <UiFormField
      required
      invalid
      slots={{
        label: <label
          htmlFor="summary-email"
        >Email</label>,
        error: <small>Enter a valid email address.</small>,
      }}
    >
      <UiInput
        id="summary-email"
        name="email"
        type="email"
        value="incomplete"
        autocomplete="email"
      />
    </UiFormField>
  </UiStack>;
}
<script>
  import { UiCallout, UiFormField, UiInput, UiList, UiListItem, UiStack } from "@threadlabs/looma";
</script>

{#snippet label()}
  <label
    for="summary-name"
  >Name</label>
{/snippet}

{#snippet error()}
  <small>Enter your name.</small>
{/snippet}

{#snippet formFieldLabel()}
  <label
    for="summary-email"
  >Email</label>
{/snippet}

{#snippet formFieldError()}
  <small>Enter a valid email address.</small>
{/snippet}

<UiStack>
  <section
    data-error-summary=""
    tabindex="-1"
    aria-label="Check these two fields"
  >
    <UiCallout
      tone="danger"
      heading="Check these two fields"
    >
      <UiList
        density="compact"
      >
        <UiListItem
          wrap
        ><a
            href="#summary-name"
          >Enter your name.</a></UiListItem>
        <UiListItem
          wrap
        ><a
            href="#summary-email"
          >Enter a valid email address.</a></UiListItem>
      </UiList>
    </UiCallout>
  </section>
  <UiFormField
    required
    invalid
    slots={{ label, error }}
  >
    <UiInput
      id="summary-name"
      name="name"
      autocomplete="name"
    />
  </UiFormField>
  <UiFormField
    required
    invalid
    slots={{ label: formFieldLabel, error: formFieldError }}
  >
    <UiInput
      id="summary-email"
      name="email"
      type="email"
      value="incomplete"
      autocomplete="email"
    />
  </UiFormField>
</UiStack>
import { Component } from "@angular/core";
import { UiCallout, UiFormField, UiInput, UiList, UiListItem, UiStack } from "@threadlabs/looma";

@Component({
  selector: "example-view",
  standalone: true,
  imports: [UiStack, UiCallout, UiList, UiListItem, UiFormField, UiInput],
  template: `
    <div
      ui-stack
    >
      <section
        data-error-summary=""
        tabindex="-1"
        aria-label="Check these two fields"
      >
        <div
          ui-callout
          [tone]="'danger'"
          [heading]="'Check these two fields'"
        >
          <ng-template
            ui-list
            [density]="'compact'"
          >
            <li
              ui-list-item
              [wrap]="true"
            ><a
                href="#summary-name"
              >Enter your name.</a></li>
            <li
              ui-list-item
              [wrap]="true"
            ><a
                href="#summary-email"
              >Enter a valid email address.</a></li>
          </ng-template>
        </div>
      </section>
      <div
        ui-form-field
        [required]="true"
        [invalid]="true"
      >
        <ng-container ngProjectAs="[slot='label']">
          <label
            for="summary-name"
          >Name</label>
        </ng-container>
        <input
          ui-input
          id="summary-name"
          name="name"
          autocomplete="name"
        >
        <ng-container ngProjectAs="[slot='error']">
          <small>Enter your name.</small>
        </ng-container>
      </div>
      <div
        ui-form-field
        [required]="true"
        [invalid]="true"
      >
        <ng-container ngProjectAs="[slot='label']">
          <label
            for="summary-email"
          >Email</label>
        </ng-container>
        <input
          ui-input
          id="summary-email"
          name="email"
          [type]="'email'"
          [value]="'incomplete'"
          autocomplete="email"
        >
        <ng-container ngProjectAs="[slot='error']">
          <small>Enter a valid email address.</small>
        </ng-container>
      </div>
    </div>
  `,
})
export default class Example {}

API

Package: @threadlabs/looma · Native root: div

Attributes

NameDescriptionPropertyTypeDefaultAllowed values
disabledDisables the control named by the label's for attribute, or the first field control inside it. Composite controls supply their focus target and form value. The field writes this to the control whenever it updates, so set it here rather than on the control.disabledbooleanfalse—
invalidMarks the field's control invalid by setting aria-invalid on it. Only while invalid, the error slot appears and becomes part of the control's description.invalidbooleanfalse—
requiredMarks the field's control required, for native form validation. Like disabled, it is written to the control, so set it here rather than on the control. It adds no visual marker; show one in the label if the form needs it.requiredbooleanfalse—

Framework props

NameTypeDefaultAllowed values
disabledbooleanfalse—
invalidbooleanfalse—
requiredbooleanfalse—

Events

No events.

Slots

NamePurpose
labelNamed content for the label region.
labelActionNamed content for the labelAction region.
defaultDefault child content.
helpNamed content for the help region.
errorNamed content for the error region.

Design tokens

Set shared theme properties on a theme boundary. Component hooks apply only to their owning component.

Component tokens

TokenDeclared valuesFallback
--ui-form-field-danger—var(--ui-field-danger, var(--ui-danger))
--ui-form-field-help-font-size—var(--ui-font-size-sm)
--ui-form-field-help-min-block-size—0
--ui-form-field-help-text—var(--ui-text-muted)
--ui-form-field-label-font-size—var(--ui-field-label-font-size, var(--ui-font-size-md))
--ui-form-field-label-text—var(--ui-text-secondary)
--ui-form-field-min-inline-size—0

Shared tokens

TokenFallback
--ui-danger—
--ui-field-dangervar(--ui-danger)
--ui-field-label-font-sizevar(--ui-font-size-md)
--ui-font-size-md—
--ui-font-size-sm—
--ui-line-height-md—
--ui-space-1—
--ui-space-2—
--ui-text-muted—
--ui-text-primary—
--ui-text-secondary—