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.
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.
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.
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
| Name | Description | Property | Type | Default | Allowed values |
|---|---|---|---|---|---|
disabled | Disables 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. | disabled | boolean | false | — |
invalid | Marks 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. | invalid | boolean | false | — |
required | Marks 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. | required | boolean | false | — |
Framework props
| Name | Type | Default | Allowed values |
|---|---|---|---|
disabled | boolean | false | — |
invalid | boolean | false | — |
required | boolean | false | — |
Events
No events.
Slots
| Name | Purpose |
|---|---|
label | Named content for the label region. |
labelAction | Named content for the labelAction region. |
default | Default child content. |
help | Named content for the help region. |
error | Named 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
| Token | Declared values | Fallback |
|---|---|---|
--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
| Token | Fallback |
|---|---|
--ui-danger | — |
--ui-field-danger | var(--ui-danger) |
--ui-field-label-font-size | var(--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 | — |