docs: add essential signal forms guide (#64665)

PR Close #64665
This commit is contained in:
Ben Hong
2025-10-29 16:06:15 -04:00
committed by Andrew Scott
parent 7dcf14dacd
commit 2123cc3a97
13 changed files with 479 additions and 0 deletions
@@ -64,6 +64,11 @@ const DOCS_SUB_NAVIGATION_DATA: NavigationItem[] = [
path: 'essentials/templates',
contentPath: 'introduction/essentials/templates',
},
{
label: 'Forms with signals',
path: 'essentials/signal-forms',
contentPath: 'introduction/essentials/signal-forms',
},
{
label: 'Modular design with dependency injection',
path: 'essentials/dependency-injection',
@@ -0,0 +1,27 @@
form {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 400px;
padding: 1rem;
font-family: Inter, system-ui, -apple-system, sans-serif;
}
label {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
input {
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1rem;
font-family: inherit;
}
p {
margin: 0.5rem 0;
color: #666;
}
@@ -0,0 +1,14 @@
<form>
<label>
Email:
<input type="email" [field]="loginForm.email" />
</label>
<label>
Password:
<input type="password" [field]="loginForm.password" />
</label>
<p>Hello {{ loginForm.email().value() }}!</p>
<p>Password length: {{ loginForm.password().value().length }}</p>
</form>
@@ -0,0 +1,20 @@
import {Component, signal} from '@angular/core';
import {form, Field} from '@angular/forms/signals';
/**
* @title Signal Forms - Login Example
*/
@Component({
selector: 'app-root',
templateUrl: 'app.component.html',
styleUrl: 'app.component.css',
imports: [Field],
})
export class App {
loginModel = signal({
email: '',
password: '',
});
loginForm = form(this.loginModel);
}
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<base href="/" />
<title>Signal Forms - Login Example</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<app-root></app-root>
</body>
</html>
@@ -0,0 +1,5 @@
import {bootstrapApplication} from '@angular/platform-browser';
import {App} from './app/app.component';
bootstrapApplication(App);
@@ -0,0 +1,70 @@
form {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 400px;
padding: 1rem;
font-family:
Inter,
system-ui,
-apple-system,
sans-serif;
}
div {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-weight: 500;
}
input {
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1rem;
font-family: inherit;
}
input:focus {
outline: none;
border-color: #4285f4;
}
button {
padding: 0.75rem 1.5rem;
background-color: #4285f4;
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
font-family: inherit;
cursor: pointer;
transition: background-color 0.2s;
}
button:hover {
background-color: #357ae8;
}
button:active {
background-color: #2a65c8;
}
.error-list {
color: red;
font-size: 0.875rem;
margin: 0.25rem 0 0 0;
padding-left: 0;
list-style-position: inside;
}
.error-list p {
margin: 0;
}
@@ -0,0 +1,33 @@
<form (submit)="onSubmit($event)">
<div>
<label>
Email:
<input type="email" [field]="loginForm.email" />
</label>
@if (loginForm.email().touched() && loginForm.email().invalid()) {
<ul class="error-list">
@for (error of loginForm.email().errors(); track $index) {
<li>{{ error.message }}</li>
}
</ul>
}
</div>
<div>
<label>
Password:
<input type="password" [field]="loginForm.password" />
</label>
@if (loginForm.password().touched() && loginForm.password().invalid()) {
<div class="error">
@for (error of loginForm.password().errors(); track $index) {
<p>{{ error.message }}</p>
}
</div>
}
</div>
<button type="submit">Log In</button>
</form>
@@ -0,0 +1,34 @@
import {Component, signal} from '@angular/core';
import {form, Field, required, email, submit} from '@angular/forms/signals';
/**
* @title Login Form with Validation
*/
@Component({
selector: 'app-root',
templateUrl: 'app.component.html',
styleUrl: 'app.component.css',
imports: [Field],
})
export class App {
loginModel = signal({
email: '',
password: '',
});
loginForm = form(this.loginModel, (p) => {
required(p.email, {message: 'Email is required'});
email(p.email, {message: 'Enter a valid email address'});
required(p.password, {message: 'Password is required'});
});
onSubmit(event: Event) {
event.preventDefault();
submit(this.loginForm, async () => {
// Perform login logic here
const credentials = this.loginModel();
console.log('Logging in with:', credentials);
// e.g., await this.authService.login(credentials);
});
}
}
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<base href="/" />
<title>Signal Forms - Login with Validation</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<app-root></app-root>
</body>
</html>
@@ -0,0 +1,5 @@
import {bootstrapApplication} from '@angular/platform-browser';
import {App} from './app/app.component';
bootstrapApplication(App);
@@ -16,6 +16,7 @@ generate_guides(
"//adev/src/assets/images:signals.svg",
"//adev/src/assets/images:templates.svg",
"//adev/src/assets/images:what_is_angular.svg",
"//adev/src/content/examples",
],
mermaid_blocks = True,
visibility = ["//adev:__subpackages__"],
@@ -0,0 +1,237 @@
<docs-decorative-header title="Forms with signals" imgSrc="adev/src/assets/images/signals.svg"> <!-- markdownlint-disable-line -->
Signal Forms is built on Angular signals to provide a reactive, type-safe way to manage form state.
</docs-decorative-header>
## How it works
### 1. Create a signal model
When you create a form, you start by creating a signal that holds the state of your form:
```ts
const loginModel = signal({
email: '',
password: '',
});
```
### 2. Pass the data model to `form()`
Then, you pass your form model into the `form()` function to create a form tree that mirrors and enhances your model's structure:
```ts
form(loginModel);
// Access fields directly by property name
loginForm.email
loginForm.password
```
### 3. Bind inputs with `[field]` directive
Next, you bind your HTML inputs to the form using the `[field]` directive, which creates two-way binding between them:
```html
<input type="email" [field]="loginForm.email" />
<input type="password" [field]="loginForm.password" />
```
As a result, user changes (such as typing in the field) automatically updates the form, and any programmatic changes update the displayed value as well:
```ts
// Update the value programmatically
loginForm.email().value.set('alice@wonderland.com');
// The model signal is also updated
console.log(loginModel().email); // 'alice@wonderland.com'
```
NOTE: The `[field]` directive also syncs field state for attributes like `required`, `disabled`, and `readonly` when appropriate. You can read more in the upcoming in-depth guide.
### 4. Read form field values with `value()`
Finally, you can access field values as reactive signals by calling the field as a function and accessing its `value()`:
```html
<!-- Render form value that updates automatically as user types -->
<p>Email: {{ loginForm.email().value() }}</p>
```
```ts
// Get the current value
const currentEmail = loginForm.email().value();
```
Here's a complete example:
<docs-code-multifile preview path="adev/src/content/examples/signal-forms/src/login-simple/app/app.component.ts">
<docs-code header="app/app.component.ts" path="adev/src/content/examples/signal-forms/src/login-simple/app/app.component.ts"/>
<docs-code header="app/app.component.html" path="adev/src/content/examples/signal-forms/src/login-simple/app/app.component.html"/>
<docs-code header="app/app.component.css" path="adev/src/content/examples/signal-forms/src/login-simple/app/app.component.css"/>
</docs-code-multifile>
## Basic usage
The `[field]` directive works with all standard HTML input types. Here are the most common patterns:
### Text inputs
Text inputs work with various `type` attributes and textareas:
```html
<!-- Text and email -->
<input type="text" [field]="form.name" />
<input type="email" [field]="form.email" />
```
#### Numbers
Number inputs automatically convert between strings and numbers:
```html
<!-- Number - automatically converts to number type -->
<input type="number" [field]="form.age" />
```
#### Date and time
Date inputs store values as `YYYY-MM-DD` strings, and time inputs use `HH:mm` format:
```html
<!-- Date and time - stores as ISO format strings -->
<input type="date" [field]="form.eventDate" />
<input type="time" [field]="form.eventTime" />
```
If you need to convert date strings to Date objects, you can do so by passing the field value into `Date()`:
```ts
const dateObject = new Date(form.eventDate().value());
```
#### Multiline text
Textareas work the same way as text inputs:
```html
<!-- Textarea -->
<textarea [field]="form.message" rows="4"></textarea>
```
### Checkboxes
Checkboxes bind to boolean values:
```html
<!-- Single checkbox -->
<label>
<input type="checkbox" [field]="form.agreeToTerms" />
I agree to the terms
</label>
```
#### Multiple checkboxes
For multiple options, create a separate boolean `field` for each:
```html
<label>
<input type="checkbox" [field]="form.emailNotifications" />
Email notifications
</label>
<label>
<input type="checkbox" [field]="form.smsNotifications" />
SMS notifications
</label>
```
### Radio buttons
Radio buttons work similarly to checkboxes. As long as the radio buttons use the same `[field]` value, Signal Forms will automatically bind the same `name` attribute to all of them:
```html
<label>
<input type="radio" value="free" [field]="form.plan" />
Free
</label>
<label>
<input type="radio" value="premium" [field]="form.plan" />
Premium
</label>
```
When a user selects a radio button, the form `field` stores the value from that radio button's `value` attribute. For example, selecting "Premium" sets `form.plan().value()` to `"premium"`.
### Select dropdowns
Select elements work with both static and dynamic options:
```html
<!-- Static options -->
<select [field]="form.country">
<option value="">Select a country</option>
<option value="us">United States</option>
<option value="ca">Canada</option>
</select>
<!-- Dynamic options with @for -->
<select [field]="form.productId">
<option value="">Select a product</option>
@for (product of products; track product.id) {
<option [value]="product.id">{{ product.name }}</option>
}
</select>
```
NOTE: Multiple select (`<select multiple>`) is not supported by the `[field]` directive at this time.
## Validation and state
Signal Forms provides built-in validators that you can apply to your form fields. To add validation, pass a schema function as the second argument to `form()`. This function receives a field **path** (sometimes abbreviated as `p`) that allows you to reference the model and its subsequent fields:
```ts
const loginForm = form(loginModel, (p) => {
required(p.email);
email(p.email);
});
```
Common validators include:
- **`required()`** - Ensures the field has a value
- **`email()`** - Validates email format
- **`min()`** / **`max()`** - Validates number ranges
- **`minLength()`** / **`maxLength()`** - Validates string or collection length
- **`pattern()`** - Validates against a regex pattern
You can also customize error messages by passing an options object as the second argument to the validator:
```ts
required(p.email, { message: 'Email is required' });
email(p.email, { message: 'Please enter a valid email address' });
```
Each form field exposes its validation state through signals. For example, you can check `field.valid()` to see if validation passes, `field.touched()` to see if the user has interacted with it, and `field.errors()` to get the list of validation errors.
Here's a complete example:
<docs-code-multifile preview path="adev/src/content/examples/signal-forms/src/login-validation/app/app.component.ts">
<docs-code header="app/app.component.ts" path="adev/src/content/examples/signal-forms/src/login-validation/app/app.component.ts"/>
<docs-code header="app/app.component.html" path="adev/src/content/examples/signal-forms/src/login-validation/app/app.component.html"/>
<docs-code header="app/app.component.css" path="adev/src/content/examples/signal-forms/src/login-validation/app/app.component.css"/>
</docs-code-multifile>
### Field State Signals
Each field provides these state signals:
| State | Description |
| ------------ | -------------------------------------------------------------------------- |
| `valid()` | Returns `true` if the field passes all validation rules |
| `touched()` | Returns `true` if the user has focused and blurred the field |
| `dirty()` | Returns `true` if the user has changed the value |
| `disabled()` | Returns `true` if the field is disabled |
| `pending()` | Returns `true` if async validation is in progress |
| `errors()` | Returns an array of validation errors with `kind` and `message` properties |
TIP: Show errors only after `touched()` is true to avoid displaying validation messages before the user has interacted with a field.