- Accordion
- Alert
- Alert Dialog
- Aspect Ratio
- Autocomplete
- Avatar
- Badge
- Breadcrumb
- Button
- Button Group
- Calendar
- Card
- Carousel
- Checkbox
- Collapsible
- Combobox
- Command
- Context Menu
- Data Table
- Date Picker
- Dialog
- Drawer
- Dropdown Menu
- Empty
- Field
- Hover Card
- Input Group
- Input OTP
- Input
- Item
- Kbd
- Label
- Menubar
- Native Select
- Navigation Menu
- Pagination
- Popover
- Progress
- Radio Group
- Resizable
- Scroll Area
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Sonner (Toast)
- Spinner
- Switch
- Table
- Tabs
- Textarea
- Toggle
- Toggle Group
- Tooltip
Signal Forms
Build forms in Angular using Signal Forms.
In this guide, we will take a look at building forms with Signal Forms. We'll cover building forms with the HlmField component, how to handle validation and how to display errors.
Demo
We are going to build the following form. It has a simple text input and a textarea. On submit, we'll validate the form data and display any errors.
Bug Report
Help us improve by reporting bugs you encounter.
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import { form, FormField, FormRoot, maxLength, minLength, required } from '@angular/forms/signals';
import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmCardImports } from '@spartan-ng/helm/card';
import { HlmFieldImports } from '@spartan-ng/helm/field';
import { HlmInputImports } from '@spartan-ng/helm/input';
import { HlmInputGroupImports } from '@spartan-ng/helm/input-group';
@Component({
selector: 'spartan-bug-report-form',
imports: [
FormRoot,
FormField,
HlmCardImports,
HlmFieldImports,
HlmButtonImports,
HlmInputImports,
HlmInputGroupImports,
],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'w-full sm:max-w-md' },
template: `
<hlm-card>
<hlm-card-header>
<h3 hlmCardTitle>Bug Report</h3>
<p hlmCardDescription>Help us improve by reporting bugs you encounter.</p>
</hlm-card-header>
<div hlmCardContent>
<!-- formRoot sets novalidate and handles form submission -->
<form [formRoot]="form" id="form-bug-report">
<hlm-field-group>
<hlm-field>
<label hlmFieldLabel for="title">Bug Title</label>
<input
id="title"
hlmInput
placeholder="Login button not working on mobile"
autoComplete="off"
[formField]="form.title"
/>
@for (error of form.title().errors(); track error) {
<hlm-field-error [validator]="error.kind">
{{ error.message }}
</hlm-field-error>
}
</hlm-field>
<hlm-field>
<label hlmFieldLabel for="description">Description</label>
<hlm-input-group>
<textarea
hlmInputGroupTextarea
id="description"
class="min-h-24"
placeholder="I'm having an issue with the login button on mobile."
rows="6"
[formField]="form.description"
></textarea>
<hlm-input-group-addon align="block-end">
<span hlmInputGroupText>{{ descriptionLength() }}/100 characters</span>
</hlm-input-group-addon>
</hlm-input-group>
<hlm-field-description>
Include steps to reproduce, expected behavior, and what actually happened.
</hlm-field-description>
@for (error of form.description().errors(); track error) {
<hlm-field-error [validator]="error.kind">
{{ error.message }}
</hlm-field-error>
}
</hlm-field>
</hlm-field-group>
</form>
</div>
<hlm-card-footer>
<hlm-field orientation="horizontal">
<button hlmBtn variant="outline" type="button" (click)="reset()">Reset</button>
<button hlmBtn type="submit" form="form-bug-report">Submit</button>
</hlm-field>
</hlm-card-footer>
</hlm-card>
`,
})
export class BugFormsDemo {
protected readonly _model = signal({
title: '',
description: '',
});
public readonly form = form(
this._model,
(schemaPath) => {
required(schemaPath.title, { message: 'Title must be entered.' });
minLength(schemaPath.title, 5, { message: 'Title must be at least 5 characters.' });
maxLength(schemaPath.title, 32, { message: 'Title cannot exceed 32 characters.' });
required(schemaPath.description, { message: 'Description must be entered.' });
minLength(schemaPath.description, 20, {
message: 'Description must be at least 20 characters.',
});
maxLength(schemaPath.description, 100, {
message: 'Description must be at most 100 characters',
});
},
{
// triggers the submission flow by calling `submit()` - marks all fields as touched, revealing validation errors
submission: {
action: async () => {
const model = this._model();
console.log('You submitted the following values:', JSON.stringify(model, null, 2));
// submit to api
},
},
},
);
public readonly descriptionLength = computed(() => this.form.description().value().length);
reset() {
this.form().reset({
title: '',
description: '',
});
}
}Approach
This form leverages Angular Signal Form for flexible form handling. We'll build our form using the HlmField component, which gives you complete flexibility over the markup and styling.
- Uses Angular
signal()to represent the form's data structure andform()for the form state management and validation. [formField]="form.email"binds the form field to the input element.HlmFieldcomponents for building accessible forms.- Use
submit()orFormRootfor form submission and revealing validation errors.
Anatomy
Here's a basic example of a form using the HlmField component and binding it via [formField]="form.title" to the input element.
Notice that HlmFieldLabel does not need a for attribute. When a label and a control are placed inside the same HlmField , the for attribute is automatically set to the control's id .
<hlm-field>
<label hlmFieldLabel>Bug Title</label>
<input
hlmInput
id="title"
placeholder="Login button not working on mobile"
autocomplete="off"
[formField]="form.title"
/>
@for (error of form.title().errors(); track error) {
<hlm-field-error [validator]="error.kind">{{ error.message }}</hlm-field-error>
}
</hlm-field>Form
Create a form model
We'll start by creating the form model with signal() to represent the form's data structure, then wrap it with form() to enable binding to input elements and configure validations.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { form, maxLength, minLength, required } from '@angular/forms/signals';
@Component({
selector: 'spartan-bug-report-form',
imports: [],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `...`,
})
export class BugFormsDemo {
protected readonly _model = signal({
title: '',
description: '',
});
public readonly form = form(this._model, (schemaPath) => {
required(schemaPath.title, { message: 'Title must be entered.' });
minLength(schemaPath.title, 5, { message: 'Title must be at least 5 characters.' });
maxLength(schemaPath.title, 100, { message: 'Title cannot exceed 32 characters.' });
required(schemaPath.description, { message: 'Description must be entered.' });
minLength(schemaPath.description, 20, {
message: 'Description must be at least 20 characters.',
});
maxLength(schemaPath.description, 100, {
message: 'Description must be at most 100 characters',
});
});
}Setup the form
Angular recommends using FormRoot as the most common way to handle form submission. It automatically takes care of three things:
- Sets
novalidateon the form element to disable native browser validation. - Prevents the default submit event, stopping browser navigation on form submission.
- Calls
submit()to mark all interactive fields as touched, run validation, and reveal any errors.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { form, FormRoot, maxLength, minLength, required } from '@angular/forms/signals';
@Component({
selector: 'spartan-bug-report-form',
imports: [FormRoot],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- formRoot sets novalidate and handles form submission -->
<form [formRoot]="form">
<!-- Build the form here -->
</form>
`,
})
export class BugFormsDemo {
protected readonly _model = signal({
title: '',
description: '',
});
public readonly form = form(
this._model,
(schemaPath) => {
required(schemaPath.title, { message: 'Title must be entered.' });
minLength(schemaPath.title, 5, { message: 'Title must be at least 5 characters.' });
maxLength(schemaPath.title, 100, { message: 'Title cannot exceed 32 characters.' });
required(schemaPath.description, { message: 'Description must be entered.' });
minLength(schemaPath.description, 20, {
message: 'Description must be at least 20 characters.',
});
maxLength(schemaPath.description, 100, {
message: 'Description must be at most 100 characters',
});
},
{
// triggers the submission flow by calling `submit()` - marks all fields as touched, revealing validation errors
submission: {
action: async () => {
// submit to api
const model = this._model();
},
},
},
);
} Alternatively, you can call submit() directly for more control over when and how submission is triggered. This is useful for multi-step wizards, auto-save, or triggering submission from outside the form element. When doing so:
- You must manually set
novalidateon the form element. - You must prevent the default browser navigation event yourself.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { form, maxLength, minLength, required, submit } from '@angular/forms/signals';
@Component({
selector: 'spartan-bug-report-form',
imports: [],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- novalidate - disables browser built-in validation -->
<form novalidate (submit)="submitBug($event)">
<!-- Build the form here -->
</form>
`,
})
export class BugFormsDemo {
protected readonly _model = signal({
title: '',
description: '',
});
public readonly form = form(this._model, (schemaPath) => {
required(schemaPath.title, { message: 'Title must be entered.' });
minLength(schemaPath.title, 5, { message: 'Title must be at least 5 characters.' });
maxLength(schemaPath.title, 32, { message: 'Title cannot exceed 32 characters.' });
required(schemaPath.description, { message: 'Description must be entered.' });
minLength(schemaPath.description, 20, {
message: 'Description must be at least 20 characters.',
});
maxLength(schemaPath.description, 100, {
message: 'Description must be at most 100 characters',
});
});
async submitBug(event: Event) {
// stop browser from navigating on form submission
event.preventDefault();
// marks all fields as touched, revealing validation errors
const success = await submit(this.form, async () => {
// submit to api
const model = this._model();
});
}
}Build the form
We can now build the form using HlmField component and bind the form controls using [formField] .
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import { form, FormField, FormRoot, maxLength, minLength, required } from '@angular/forms/signals';
import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmCardImports } from '@spartan-ng/helm/card';
import { HlmFieldImports } from '@spartan-ng/helm/field';
import { HlmInputImports } from '@spartan-ng/helm/input';
import { HlmInputGroupImports } from '@spartan-ng/helm/input-group';
@Component({
selector: 'spartan-bug-report-form',
imports: [
FormRoot,
FormField,
HlmCardImports,
HlmFieldImports,
HlmButtonImports,
HlmInputImports,
HlmInputGroupImports,
],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'w-full sm:max-w-md' },
template: `
<hlm-card>
<hlm-card-header>
<h3 hlmCardTitle>Bug Report</h3>
<p hlmCardDescription>Help us improve by reporting bugs you encounter.</p>
</hlm-card-header>
<div hlmCardContent>
<!-- formRoot sets novalidate and handles form submission -->
<form [formRoot]="form" id="form-bug-report">
<hlm-field-group>
<hlm-field>
<label hlmFieldLabel for="title">Bug Title</label>
<input
id="title"
hlmInput
placeholder="Login button not working on mobile"
autoComplete="off"
[formField]="form.title"
/>
@for (error of form.title().errors(); track error) {
<hlm-field-error [validator]="error.kind">
{{ error.message }}
</hlm-field-error>
}
</hlm-field>
<hlm-field>
<label hlmFieldLabel for="description">Description</label>
<hlm-input-group>
<textarea
hlmInputGroupTextarea
id="description"
class="min-h-24"
placeholder="I'm having an issue with the login button on mobile."
rows="6"
[formField]="form.description"
></textarea>
<hlm-input-group-addon align="block-end">
<span hlmInputGroupText>{{ descriptionLength() }}/100 characters</span>
</hlm-input-group-addon>
</hlm-input-group>
<hlm-field-description>
Include steps to reproduce, expected behavior, and what actually happened.
</hlm-field-description>
@for (error of form.description().errors(); track error) {
<hlm-field-error [validator]="error.kind">
{{ error.message }}
</hlm-field-error>
}
</hlm-field>
</hlm-field-group>
</form>
</div>
<hlm-card-footer>
<hlm-field orientation="horizontal">
<button hlmBtn variant="outline" type="button" (click)="reset()">Reset</button>
<button hlmBtn type="submit" form="form-bug-report">Submit</button>
</hlm-field>
</hlm-card-footer>
</hlm-card>
`,
})
export class BugFormsDemo {
protected readonly _model = signal({
title: '',
description: '',
});
public readonly form = form(
this._model,
(schemaPath) => {
required(schemaPath.title, { message: 'Title must be entered.' });
minLength(schemaPath.title, 5, { message: 'Title must be at least 5 characters.' });
maxLength(schemaPath.title, 32, { message: 'Title cannot exceed 32 characters.' });
required(schemaPath.description, { message: 'Description must be entered.' });
minLength(schemaPath.description, 20, {
message: 'Description must be at least 20 characters.',
});
maxLength(schemaPath.description, 100, {
message: 'Description must be at most 100 characters',
});
},
{
// triggers the submission flow by calling `submit()` - marks all fields as touched, revealing validation errors
submission: {
action: async () => {
const model = this._model();
console.log('You submitted the following values:', JSON.stringify(model, null, 2));
// submit to api
},
},
},
);
public readonly descriptionLength = computed(() => this.form.description().value().length);
reset() {
this.form().reset({
title: '',
description: '',
});
}
}Done
That's it. You now have a fully accessible form with client-side validation.
Calling submit() checks if the form is valid and performs the action. If the form is invalid, it marks all fields as touched and reveals their validation errors.
Validation
Signal Forms provides a powerful validation system . Validator functions support either synchronous or asynchronous validation. You can use built-in validators or create custom ones. Built-in validators are available such as required() or email() . Each validator carries its error message directly alongside the validation logic, keeping them co-located and easy to maintain.
Add one or more validator functions per field inside form() . Use form().valid() or form().invalid() to reactively check the form's overall validity.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { form, maxLength, minLength, required } from '@angular/forms/signals';
@Component({
selector: 'spartan-bug-report-form',
imports: [],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `...`,
})
export class BugFormsDemo {
protected readonly _model = signal({
title: '',
description: '',
});
public readonly form = form(this._model, (schemaPath) => {
required(schemaPath.title, { message: 'Title must be entered.' });
minLength(schemaPath.title, 5, { message: 'Title must be at least 5 characters.' });
maxLength(schemaPath.title, 100, { message: 'Title cannot exceed 32 characters.' });
required(schemaPath.description, { message: 'Description must be entered.' });
minLength(schemaPath.description, 20, {
message: 'Description must be at least 20 characters.',
});
maxLength(schemaPath.description, 100, {
message: 'Description must be at most 100 characters',
});
});
}Displaying Errors
HlmFieldError renders automatically when placed inside an HlmField whose control is invalid. There is no need to wrap it in @if blocks or manually check control state — the field handles visibility for you.
Visibility is driven by the data-matches-spartan-invalid attribute, which spartan sets on the field element. Its value reflects the return value of ErrorStateMatcher.isInvalid() — by default true when the control is invalid and touched. This attribute controls both error styling and the visibility of HlmFieldError messages.
You can change when errors appear by providing a custom ErrorStateMatcher at the component or application level. spartan ships with ShowOnDirtyErrorStateMatcher as a ready-made alternative that shows errors as soon as the control is dirty rather than waiting for a touch event.
When a validator input is specified on HlmFieldError , the message is only shown when the control has an error matching that validator key — letting you place multiple targeted error messages inside a single HlmField , each tied to a specific validation rule.
<hlm-field>
<label hlmFieldLabel>Bug Title</label>
<input
hlmInput
id="title"
placeholder="Login button not working on mobile"
autocomplete="off"
[formField]="form.title"
/>
@for (error of form.title().errors(); track error) {
<hlm-field-error [validator]="error.kind">{{ error.message }}</hlm-field-error>
}
</hlm-field> Set forceShow on HlmFieldError to always display the message, regardless of the control's error state or the active ErrorStateMatcher . This is useful for cross-field validation errors that are not tied to a specific control, such as a password mismatch message.
<hlm-field>
<label hlmFieldLabel for="title">Bug Title</label>
<input hlmInput id="title" [formField]="form.title" />
<hlm-field-error forceShow>This error is always visible.</hlm-field-error>
</hlm-field>Submission state
Whenever submit() is called - whether through FormRoot or directly - form().submitting() will be true for the duration of the submission. Use this to disable the submit button and show a loading indicator.
<button hlmBtn type="submit" [disabled]="form().submitting()">
@if (form().submitting()) {
<hlm-spinner />
}
Submit
</button>Working with Different Field Types
Input
Profile Settings
Update your profile information below.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { form, FormField, maxLength, minLength, pattern, required, submit } from '@angular/forms/signals';
import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmCardImports } from '@spartan-ng/helm/card';
import { HlmFieldImports } from '@spartan-ng/helm/field';
import { HlmInputImports } from '@spartan-ng/helm/input';
@Component({
selector: 'spartan-signal-form-input-demo',
imports: [FormField, HlmCardImports, HlmFieldImports, HlmInputImports, HlmButtonImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'w-full sm:max-w-md' },
template: `
<hlm-card>
<hlm-card-header>
<h3 hlmCardTitle>Profile Settings</h3>
<p hlmCardDescription>Update your profile information below.</p>
</hlm-card-header>
<div hlmCardContent>
<form novalidate id="form-input-demo" (submit)="submit($event)">
<hlm-field-group>
<hlm-field>
<label hlmFieldLabel for="username">Username</label>
<input hlmInput id="username" placeholder="spartan" autoComplete="username" [formField]="form.username" />
<hlm-field-description>
This is your public display name. Must be between 3 and 10 characters. Must only contain letters,
numbers, and underscores.
</hlm-field-description>
@for (error of form.username().errors(); track error) {
<hlm-field-error [validator]="error.kind">
{{ error.message }}
</hlm-field-error>
}
</hlm-field>
</hlm-field-group>
</form>
</div>
<hlm-card-footer>
<hlm-field orientation="horizontal">
<button hlmBtn variant="outline" type="button" (click)="reset()">Reset</button>
<button hlmBtn type="submit" form="form-input-demo">Submit</button>
</hlm-field>
</hlm-card-footer>
</hlm-card>
`,
})
export class SignalFormInputDemo {
protected readonly _model = signal({
username: '',
});
public readonly form = form(this._model, (schemaPath) => {
required(schemaPath.username, { message: 'Username is a required field.' });
minLength(schemaPath.username, 3, { message: 'Username must be at least 3 characters.' });
maxLength(schemaPath.username, 10, { message: 'Username must be at most 10 characters.' });
pattern(schemaPath.username, /^[a-zA-Z0-9_]+$/, {
message: 'Username can only contain letters, numbers, and underscores.',
});
});
async submit(event: Event) {
event.preventDefault();
// marks all fields as touched, revealing validation errors
submit(this.form, async () => {
const model = this._model();
console.log('You submitted the following values:', JSON.stringify(model, null, 2));
});
}
reset() {
this.form().reset({
username: '',
});
}
}Textarea
Personalization
Customize your experience by telling us more about yourself.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { form, FormField, maxLength, minLength, required, submit } from '@angular/forms/signals';
import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmCardImports } from '@spartan-ng/helm/card';
import { HlmFieldImports } from '@spartan-ng/helm/field';
import { HlmTextareaImports } from '@spartan-ng/helm/textarea';
@Component({
selector: 'spartan-signal-form-textarea-demo',
imports: [FormField, HlmCardImports, HlmFieldImports, HlmTextareaImports, HlmButtonImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'w-full sm:max-w-md' },
template: `
<hlm-card>
<hlm-card-header>
<h3 hlmCardTitle>Personalization</h3>
<p hlmCardDescription>Customize your experience by telling us more about yourself.</p>
</hlm-card-header>
<div hlmCardContent>
<form novalidate id="form-textarea-demo" (submit)="submit($event)">
<hlm-field-group>
<hlm-field>
<label hlmFieldLabel for="about">More about you</label>
<textarea
hlmTextarea
id="about"
class="min-h-32"
placeholder="I'm a software engineer..."
[formField]="form.about"
></textarea>
<hlm-field-description>
Tell us more about yourself. This will be used to help us personalize your experience.
</hlm-field-description>
@for (error of form.about().errors(); track error) {
<hlm-field-error [validator]="error.kind">
{{ error.message }}
</hlm-field-error>
}
</hlm-field>
</hlm-field-group>
</form>
</div>
<hlm-card-footer>
<hlm-field orientation="horizontal">
<button hlmBtn variant="outline" type="button" (click)="reset()">Reset</button>
<button hlmBtn type="submit" form="form-textarea-demo">Submit</button>
</hlm-field>
</hlm-card-footer>
</hlm-card>
`,
})
export class SignalFormTextareaDemo {
protected readonly _model = signal({
about: '',
});
public readonly form = form(this._model, (schemaPath) => {
required(schemaPath.about, { message: 'This is a required field.' });
minLength(schemaPath.about, 10, { message: 'Please provide at least 10 characters.' });
maxLength(schemaPath.about, 200, { message: 'Please keep it under 200 characters.' });
});
async submit(event: Event) {
event.preventDefault();
// marks all fields as touched, revealing validation errors
submit(this.form, async () => {
const model = this._model();
console.log('You submitted the following values:', JSON.stringify(model, null, 2));
});
}
reset() {
this.form().reset({
about: '',
});
}
}Select
Language Preferences
Select your preferred spoken language.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { form, FormField, required, submit, validate } from '@angular/forms/signals';
import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmCardImports } from '@spartan-ng/helm/card';
import { HlmFieldImports } from '@spartan-ng/helm/field';
import { HlmSelectImports } from '@spartan-ng/helm/select';
@Component({
selector: 'spartan-signal-form-select-demo',
imports: [FormField, HlmCardImports, HlmFieldImports, HlmSelectImports, HlmButtonImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'w-full sm:max-w-md' },
template: `
<hlm-card>
<hlm-card-header>
<h3 hlmCardTitle>Language Preferences</h3>
<p hlmCardDescription>Select your preferred spoken language.</p>
</hlm-card-header>
<div hlmCardContent>
<form novalidate id="form-select-demo" (submit)="submit($event)">
<hlm-field-group>
<hlm-field orientation="responsive">
<hlm-field-content>
<label hlmFieldLabel for="language">Spoken Language</label>
<hlm-field-description>For best results, select the language you speak.</hlm-field-description>
@for (error of form.language().errors(); track error) {
<hlm-field-error [validator]="error.kind">
{{ error.message }}
</hlm-field-error>
}
</hlm-field-content>
<hlm-select [formField]="form.language" [itemToString]="itemToString">
<hlm-select-trigger buttonId="language">
<hlm-select-value placeholder="Select" />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal>
<hlm-select-group>
<hlm-select-item value="auto">Auto</hlm-select-item>
<div hlmSelectSeparator></div>
@for (language of spokenLanguages; track $index) {
<hlm-select-item [value]="language.value">
{{ language.label }}
</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</hlm-field>
</hlm-field-group>
</form>
</div>
<hlm-card-footer>
<hlm-field orientation="horizontal">
<button hlmBtn variant="outline" type="button" (click)="reset()">Reset</button>
<button hlmBtn type="submit" form="form-select-demo">Submit</button>
</hlm-field>
</hlm-card-footer>
</hlm-card>
`,
})
export class SignalFormSelectDemo {
public spokenLanguages = [
{ label: 'English', value: 'en' },
{ label: 'Spanish', value: 'es' },
{ label: 'French', value: 'fr' },
{ label: 'German', value: 'de' },
{ label: 'Italian', value: 'it' },
{ label: 'Chinese', value: 'zh' },
{ label: 'Japanese', value: 'ja' },
];
public itemToString = (value: string) => {
if (value === 'auto') {
return 'Auto';
}
const language = this.spokenLanguages.find((lang) => lang.value === value);
return language ? language.label : '';
};
protected readonly _model = signal<{
language: string | null;
}>({
language: null,
});
public readonly form = form(this._model, (schemaPath) => {
required(schemaPath.language, { message: 'Language is a required field.' });
validate(schemaPath.language, ({ value }) => {
if (value() === 'auto') {
return {
kind: 'auto-detect',
message: 'Auto-detection is not allowed. Please select a specific language.',
};
}
return null;
});
});
async submit(event: Event) {
event.preventDefault();
// marks all fields as touched, revealing validation errors
submit(this.form, async () => {
const model = this._model();
console.log('You submitted the following values:', JSON.stringify(model, null, 2));
});
}
reset() {
this.form().reset({
language: null,
});
}
}Checkbox
Notifications
Manage your notification preferences.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { disabled, form, FormField, minLength, required, submit } from '@angular/forms/signals';
import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmCardImports } from '@spartan-ng/helm/card';
import { HlmCheckboxImports } from '@spartan-ng/helm/checkbox';
import { HlmFieldImports } from '@spartan-ng/helm/field';
@Component({
selector: 'spartan-signal-form-checkbox-demo',
imports: [FormField, HlmCardImports, HlmFieldImports, HlmCheckboxImports, HlmButtonImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'w-full sm:max-w-md' },
template: `
<hlm-card>
<hlm-card-header>
<h3 hlmCardTitle>Notifications</h3>
<p hlmCardDescription>Manage your notification preferences.</p>
</hlm-card-header>
<div hlmCardContent>
<form novalidate id="form-checkbox-demo" (submit)="submit($event)">
<hlm-field-group>
<fieldset hlmFieldSet>
<legend hlmFieldLegend variant="label">Responses</legend>
<hlm-field-description>
Get notified for requests that take time, like research or image generation.
</hlm-field-description>
<hlm-field-group>
<hlm-field orientation="horizontal">
<hlm-checkbox inputId="responses" [formField]="form.responses" />
<label hlmFieldLabel class="font-normal" for="responses">Push notifications</label>
</hlm-field>
</hlm-field-group>
</fieldset>
<hlm-field-separator />
<hlm-field-group>
<fieldset hlmFieldSet>
<legend hlmFieldLegend variant="label">Tasks</legend>
<hlm-field-description>Get notified when tasks you've created have updates.</hlm-field-description>
<hlm-field-group data-slot="checkbox-group">
@for (task of tasks; track task.id) {
<hlm-field
orientation="horizontal"
[forceInvalid]="form.tasks().invalid() && form.tasks().touched()"
>
<hlm-checkbox
[inputId]="'task-' + task.id"
[forceInvalid]="form.tasks().invalid() && form.tasks().touched()"
[checked]="form.tasks().value().includes(task.id)"
(checkedChange)="handleChange($event, task.id)"
/>
<label hlmFieldLabel class="font-normal" [for]="'task-' + task.id">
{{ task.label }}
</label>
</hlm-field>
}
</hlm-field-group>
</fieldset>
@if (form.tasks().invalid() && form.tasks().touched()) {
@for (error of form.tasks().errors(); track error) {
<hlm-field-error>{{ error.message }}</hlm-field-error>
}
}
</hlm-field-group>
</hlm-field-group>
</form>
</div>
<hlm-card-footer>
<hlm-field orientation="horizontal">
<button hlmBtn variant="outline" type="button" (click)="reset()">Reset</button>
<button hlmBtn type="submit" form="form-checkbox-demo">Submit</button>
</hlm-field>
</hlm-card-footer>
</hlm-card>
`,
})
export class SignalFormCheckboxDemo {
protected readonly _model = signal<{
responses: boolean;
tasks: string[];
}>({
responses: true,
tasks: [],
});
public readonly form = form(this._model, (schemaPath) => {
disabled(schemaPath.responses);
required(schemaPath.tasks, { message: 'Please select at least one notification type.' });
minLength(schemaPath.tasks, 1, { message: 'Please select at least one notification type.' });
});
public tasks = [
{
id: 'push',
label: 'Push notifications',
},
{
id: 'email',
label: 'Email notifications',
},
];
async submit(event: Event) {
event.preventDefault();
// marks all fields as touched, revealing validation errors
submit(this.form, async () => {
const model = this._model();
console.log('You submitted the following values:', JSON.stringify(model, null, 2));
});
}
handleChange(checked: boolean, id: string) {
if (checked) {
this.form.tasks().value.update((tasks) => [...tasks, id]);
} else {
this.form.tasks().value.update((tasks) => tasks.filter((taskId) => taskId !== id));
}
this.form.tasks().markAsTouched();
}
reset() {
this.form().reset({
responses: true,
tasks: [],
});
}
}Radio Group
Subscription Plan
See pricing and features for each plan.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { form, FormField, required, submit } from '@angular/forms/signals';
import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmCardImports } from '@spartan-ng/helm/card';
import { HlmFieldImports } from '@spartan-ng/helm/field';
import { HlmRadioGroupImports } from '@spartan-ng/helm/radio-group';
@Component({
selector: 'spartan-signal-form-radio-group-demo',
imports: [FormField, HlmCardImports, HlmFieldImports, HlmRadioGroupImports, HlmButtonImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'w-full sm:max-w-md' },
template: `
<hlm-card>
<hlm-card-header>
<h3 hlmCardTitle>Subscription Plan</h3>
<p hlmCardDescription>See pricing and features for each plan.</p>
</hlm-card-header>
<div hlmCardContent>
<form novalidate id="form-radio-demo" (submit)="submit($event)">
<fieldset hlmFieldSet>
<legend hlmFieldLegend>Plan</legend>
<hlm-field-description>You can upgrade or downgrade your plan at any time.</hlm-field-description>
<hlm-radio-group [formField]="form.plan">
@for (plan of plans; track plan.id) {
<label hlmFieldLabel [for]="'plan-' + plan.id">
<hlm-field orientation="horizontal">
<hlm-field-content>
<hlm-field-title>{{ plan.title }}</hlm-field-title>
<hlm-field-description>{{ plan.description }}</hlm-field-description>
</hlm-field-content>
<hlm-radio [value]="plan.id" [inputId]="'plan-' + plan.id">
<hlm-radio-indicator indicator />
</hlm-radio>
</hlm-field>
</label>
}
</hlm-radio-group>
@if (form.plan().invalid() && form.plan().touched()) {
@for (error of form.plan().errors(); track error) {
<hlm-field-error>
{{ error.message }}
</hlm-field-error>
}
}
</fieldset>
</form>
</div>
<hlm-card-footer>
<hlm-field orientation="horizontal">
<button hlmBtn variant="outline" type="button" (click)="reset()">Reset</button>
<button hlmBtn type="submit" form="form-radio-demo">Submit</button>
</hlm-field>
</hlm-card-footer>
</hlm-card>
`,
})
export class SignalFormRadioGroupDemo {
protected readonly _model = signal({
plan: '',
});
public readonly form = form(this._model, (schemaPath) => {
required(schemaPath.plan, { message: 'You must select a subscription plan to continue.' });
});
public plans = [
{
id: 'starter',
title: 'Starter (100K tokens/month)',
description: 'For everyday use with basic features.',
},
{
id: 'pro',
title: 'Pro (1M tokens/month)',
description: 'For advanced AI usage with more features.',
},
{
id: 'enterprise',
title: 'Enterprise (Unlimited tokens)',
description: 'For large teams and heavy usage.',
},
];
async submit(event: Event) {
event.preventDefault();
// marks all fields as touched, revealing validation errors
submit(this.form, async () => {
const model = this._model();
console.log('You submitted the following values:', JSON.stringify(model, null, 2));
});
}
reset() {
this.form().reset({
plan: '',
});
}
}Switch
Security Settings
Manage your account security preferences.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { form, FormField, required, submit } from '@angular/forms/signals';
import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmCardImports } from '@spartan-ng/helm/card';
import { HlmFieldImports } from '@spartan-ng/helm/field';
import { HlmSwitchImports } from '@spartan-ng/helm/switch';
@Component({
selector: 'spartan-signal-form-switch-demo',
imports: [FormField, HlmCardImports, HlmFieldImports, HlmSwitchImports, HlmButtonImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'w-full sm:max-w-md' },
template: `
<hlm-card>
<hlm-card-header>
<h3 hlmCardTitle>Security Settings</h3>
<p hlmCardDescription>Manage your account security preferences.</p>
</hlm-card-header>
<div hlmCardContent>
<form novalidate id="form-switch-demo" (submit)="submit($event)">
<hlm-field-group>
<hlm-field orientation="horizontal">
<hlm-field-content>
<label hlmFieldLabel for="two-factor">Multi-factor authentication</label>
<hlm-field-description>
Enable multi-factor authentication to secure your account.
</hlm-field-description>
@for (error of form.twoFactor().errors(); track error) {
<hlm-field-error [validator]="error.kind">
{{ error.message }}
</hlm-field-error>
}
</hlm-field-content>
<hlm-switch inputId="two-factor" [formField]="form.twoFactor" />
</hlm-field>
</hlm-field-group>
</form>
</div>
<hlm-card-footer>
<hlm-field orientation="horizontal">
<button hlmBtn variant="outline" type="button" (click)="reset()">Reset</button>
<button hlmBtn type="submit" form="form-switch-demo">Submit</button>
</hlm-field>
</hlm-card-footer>
</hlm-card>
`,
})
export class SignalFormSwitchDemo {
protected readonly _model = signal({
twoFactor: false,
});
public readonly form = form(this._model, (schemaPath) => {
required(schemaPath.twoFactor, {
message: 'It is highly recommended to enable two-factor authentication.',
});
});
async submit(event: Event) {
event.preventDefault();
// marks all fields as touched, revealing validation errors
submit(this.form, async () => {
const model = this._model();
console.log('You submitted the following values:', JSON.stringify(model, null, 2));
});
}
reset() {
this.form().reset({
twoFactor: false,
});
}
}Complex Forms
You're almost there!
Choose your subscription plan and billing period.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { form, FormField, minLength, required, submit } from '@angular/forms/signals';
import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmCardImports } from '@spartan-ng/helm/card';
import { HlmCheckboxImports } from '@spartan-ng/helm/checkbox';
import { HlmFieldImports } from '@spartan-ng/helm/field';
import { HlmRadioGroupImports } from '@spartan-ng/helm/radio-group';
import { HlmSelectImports } from '@spartan-ng/helm/select';
import { HlmSwitchImports } from '@spartan-ng/helm/switch';
@Component({
selector: 'spartan-signal-form-complex-demo',
imports: [
FormField,
HlmCardImports,
HlmFieldImports,
HlmRadioGroupImports,
HlmCheckboxImports,
HlmSelectImports,
HlmSwitchImports,
HlmButtonImports,
],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'w-full sm:max-w-md' },
template: `
<hlm-card>
<hlm-card-header class="border-b">
<h3 hlmCardTitle>You're almost there!</h3>
<p hlmCardDescription>Choose your subscription plan and billing period.</p>
</hlm-card-header>
<div hlmCardContent>
<form novalidate id="form-complex-demo" (submit)="submit($event)">
<hlm-field-group>
<fieldset hlmFieldSet>
<legend hlmFieldLegend variant="label">Subscription Plan</legend>
<hlm-field-description>Choose your subscription plan.</hlm-field-description>
<hlm-radio-group [formField]="form.plan">
@for (plan of plans; track plan.id) {
<label hlmFieldLabel [for]="'complex-plan-' + plan.id">
<hlm-field orientation="horizontal">
<hlm-field-content>
<hlm-field-title>{{ plan.title }}</hlm-field-title>
<hlm-field-description>{{ plan.description }}</hlm-field-description>
</hlm-field-content>
<hlm-radio [value]="plan.id" [inputId]="'complex-plan-' + plan.id">
<hlm-radio-indicator indicator />
</hlm-radio>
</hlm-field>
</label>
}
</hlm-radio-group>
</fieldset>
<hlm-field-separator />
<hlm-field>
<label hlmFieldLabel>Billing Period</label>
<hlm-select [formField]="form.billingPeriod" [itemToString]="itemToString">
<hlm-select-trigger buttonId="billingPeriod" class="w-full">
<hlm-select-value placeholder="Select" />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal>
<hlm-select-group>
@for (billingPeriod of billingPeriods; track $index) {
<hlm-select-item [value]="billingPeriod.value">
{{ billingPeriod.label }}
</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
<hlm-field-description>Choose how often you want to be billed.</hlm-field-description>
@for (error of form.billingPeriod().errors(); track error) {
<hlm-field-error [validator]="error.kind">
{{ error.message }}
</hlm-field-error>
}
</hlm-field>
<hlm-field-separator />
<fieldset hlmFieldSet>
<legend hlmFieldLegend variant="label">Add-ons</legend>
<hlm-field-description>Select additional features you'd like to include.</hlm-field-description>
<hlm-field-group data-slot="checkbox-group">
@for (addon of addons; track addon.id) {
<hlm-field
orientation="horizontal"
[forceInvalid]="form.addons().invalid() && form.addons().touched()"
>
<hlm-checkbox
[inputId]="'addon-' + addon.id"
[forceInvalid]="form.addons().invalid() && form.addons().touched()"
[checked]="form.addons().value().includes(addon.id)"
(checkedChange)="handleChange($event, addon.id)"
/>
<hlm-field-content>
<label hlmFieldLabel class="font-normal" [for]="'addon-' + addon.id">
{{ addon.title }}
</label>
<hlm-field-description>{{ addon.description }}</hlm-field-description>
</hlm-field-content>
</hlm-field>
}
</hlm-field-group>
</fieldset>
@if (form.addons().invalid() && form.addons().touched()) {
@for (error of form.addons().errors(); track error) {
<hlm-field-error>{{ error.message }}</hlm-field-error>
}
}
<hlm-field-separator />
<hlm-field orientation="horizontal">
<hlm-field-content>
<label hlmFieldLabel for="email-notifications">Email Notifications</label>
<hlm-field-description>Receive email updates about your subscription</hlm-field-description>
@for (error of form.emailNotifications().errors(); track error) {
<hlm-field-error [validator]="error.kind">
{{ error.message }}
</hlm-field-error>
}
</hlm-field-content>
<hlm-switch inputId="email-notifications" [formField]="form.emailNotifications" />
</hlm-field>
</hlm-field-group>
</form>
</div>
<hlm-card-footer>
<hlm-field>
<button hlmBtn type="submit" form="form-complex-demo">Save preferences</button>
<button hlmBtn variant="outline" type="button" (click)="reset()">Reset</button>
</hlm-field>
</hlm-card-footer>
</hlm-card>
`,
})
export class SignalFormComplexDemo {
protected readonly _model = signal<{
plan: string;
billingPeriod: string | null;
addons: string[];
emailNotifications: boolean;
}>({
plan: 'basic',
billingPeriod: null,
addons: [],
emailNotifications: false,
});
public readonly form = form(this._model, (schemaPath) => {
required(schemaPath.plan, { message: 'Please select a subscription plan' });
required(schemaPath.billingPeriod, { message: 'Please select a billing period' });
minLength(schemaPath.addons, 1, { message: 'Please select at least one add-on' });
});
public plans = [
{
id: 'basic',
title: 'Basic',
description: 'For individuals and small teams',
},
{
id: 'pro',
title: 'Pro',
description: 'For businesses with higher demands',
},
];
public billingPeriods = [
{ label: 'Monthly', value: 'monthly' },
{ label: 'Yearly', value: 'yearly' },
];
public itemToString = (value: string) => this.billingPeriods.find((period) => period.value === value)?.label ?? '';
public addons = [
{
id: 'analytics',
title: 'Analytics',
description: 'Advanced analytics and reporting',
},
{
id: 'backup',
title: 'Backup',
description: 'Automated daily backups',
},
{
id: 'support',
title: 'Priority Support',
description: '24/7 premium customer support',
},
];
async submit(event: Event) {
event.preventDefault();
// marks all fields as touched, revealing validation errors
submit(this.form, async () => {
const model = this._model();
console.log('You submitted the following values:', JSON.stringify(model, null, 2));
});
}
handleChange(checked: boolean, id: string) {
if (checked) {
this.form.addons().value.update((addons) => [...addons, id]);
} else {
this.form.addons().value.update((addons) => addons.filter((addonId) => addonId !== id));
}
this.form.addons().markAsTouched();
}
reset() {
this.form().reset({
plan: 'basic',
billingPeriod: null,
addons: [],
emailNotifications: false,
});
}
}Resetting the form
Use form.reset({...}) to reset the form to its default values and mark all controls as pristine and untouched.
<button hlmBtn variant="outline" type="button" (click)="reset()">
Reset
</button>
reset() {
this.form().reset({
// provide default values
responses: true,
tasks: [],
});
}On This Page