diff --git a/adev/src/app/routing/sub-navigation-data.ts b/adev/src/app/routing/sub-navigation-data.ts index 598ef98d0c3..1c1d4d2eea4 100644 --- a/adev/src/app/routing/sub-navigation-data.ts +++ b/adev/src/app/routing/sub-navigation-data.ts @@ -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', diff --git a/adev/src/content/examples/signal-forms/src/login-simple/app/app.component.css b/adev/src/content/examples/signal-forms/src/login-simple/app/app.component.css new file mode 100644 index 00000000000..effcb966291 --- /dev/null +++ b/adev/src/content/examples/signal-forms/src/login-simple/app/app.component.css @@ -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; +} diff --git a/adev/src/content/examples/signal-forms/src/login-simple/app/app.component.html b/adev/src/content/examples/signal-forms/src/login-simple/app/app.component.html new file mode 100644 index 00000000000..7c0aeb275f7 --- /dev/null +++ b/adev/src/content/examples/signal-forms/src/login-simple/app/app.component.html @@ -0,0 +1,14 @@ +
+ + + + +

Hello {{ loginForm.email().value() }}!

+

Password length: {{ loginForm.password().value().length }}

+
diff --git a/adev/src/content/examples/signal-forms/src/login-simple/app/app.component.ts b/adev/src/content/examples/signal-forms/src/login-simple/app/app.component.ts new file mode 100644 index 00000000000..e1004498c43 --- /dev/null +++ b/adev/src/content/examples/signal-forms/src/login-simple/app/app.component.ts @@ -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); +} diff --git a/adev/src/content/examples/signal-forms/src/login-simple/index.html b/adev/src/content/examples/signal-forms/src/login-simple/index.html new file mode 100644 index 00000000000..34474578c22 --- /dev/null +++ b/adev/src/content/examples/signal-forms/src/login-simple/index.html @@ -0,0 +1,14 @@ + + + + + + Signal Forms - Login Example + + + + + + + + diff --git a/adev/src/content/examples/signal-forms/src/login-simple/main.ts b/adev/src/content/examples/signal-forms/src/login-simple/main.ts new file mode 100644 index 00000000000..8f970447f48 --- /dev/null +++ b/adev/src/content/examples/signal-forms/src/login-simple/main.ts @@ -0,0 +1,5 @@ +import {bootstrapApplication} from '@angular/platform-browser'; + +import {App} from './app/app.component'; + +bootstrapApplication(App); diff --git a/adev/src/content/examples/signal-forms/src/login-validation/app/app.component.css b/adev/src/content/examples/signal-forms/src/login-validation/app/app.component.css new file mode 100644 index 00000000000..2b9a3100a69 --- /dev/null +++ b/adev/src/content/examples/signal-forms/src/login-validation/app/app.component.css @@ -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; +} diff --git a/adev/src/content/examples/signal-forms/src/login-validation/app/app.component.html b/adev/src/content/examples/signal-forms/src/login-validation/app/app.component.html new file mode 100644 index 00000000000..73ec4635295 --- /dev/null +++ b/adev/src/content/examples/signal-forms/src/login-validation/app/app.component.html @@ -0,0 +1,33 @@ +
+
+ + + @if (loginForm.email().touched() && loginForm.email().invalid()) { + + } +
+ +
+ + + @if (loginForm.password().touched() && loginForm.password().invalid()) { +
+ @for (error of loginForm.password().errors(); track $index) { +

{{ error.message }}

+ } +
+ } +
+ + +
diff --git a/adev/src/content/examples/signal-forms/src/login-validation/app/app.component.ts b/adev/src/content/examples/signal-forms/src/login-validation/app/app.component.ts new file mode 100644 index 00000000000..23d4d9009ca --- /dev/null +++ b/adev/src/content/examples/signal-forms/src/login-validation/app/app.component.ts @@ -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); + }); + } +} diff --git a/adev/src/content/examples/signal-forms/src/login-validation/index.html b/adev/src/content/examples/signal-forms/src/login-validation/index.html new file mode 100644 index 00000000000..3258da4d93e --- /dev/null +++ b/adev/src/content/examples/signal-forms/src/login-validation/index.html @@ -0,0 +1,14 @@ + + + + + + Signal Forms - Login with Validation + + + + + + + + diff --git a/adev/src/content/examples/signal-forms/src/login-validation/main.ts b/adev/src/content/examples/signal-forms/src/login-validation/main.ts new file mode 100644 index 00000000000..8f970447f48 --- /dev/null +++ b/adev/src/content/examples/signal-forms/src/login-validation/main.ts @@ -0,0 +1,5 @@ +import {bootstrapApplication} from '@angular/platform-browser'; + +import {App} from './app/app.component'; + +bootstrapApplication(App); diff --git a/adev/src/content/introduction/essentials/BUILD.bazel b/adev/src/content/introduction/essentials/BUILD.bazel index 4a238832929..92710491604 100644 --- a/adev/src/content/introduction/essentials/BUILD.bazel +++ b/adev/src/content/introduction/essentials/BUILD.bazel @@ -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__"], diff --git a/adev/src/content/introduction/essentials/signal-forms.md b/adev/src/content/introduction/essentials/signal-forms.md new file mode 100644 index 00000000000..f74089036af --- /dev/null +++ b/adev/src/content/introduction/essentials/signal-forms.md @@ -0,0 +1,237 @@ + +Signal Forms is built on Angular signals to provide a reactive, type-safe way to manage form state. + + +## 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 + + +``` + +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 + +

Email: {{ loginForm.email().value() }}

+``` + +```ts +// Get the current value +const currentEmail = loginForm.email().value(); +``` + +Here's a complete example: + + + + + + + +## 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 + + + +``` + +#### Numbers + +Number inputs automatically convert between strings and numbers: + +```html + + +``` + +#### Date and time + +Date inputs store values as `YYYY-MM-DD` strings, and time inputs use `HH:mm` format: + +```html + + + +``` + +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 + + +``` + +### Checkboxes + +Checkboxes bind to boolean values: + +```html + + +``` + +#### Multiple checkboxes + +For multiple options, create a separate boolean `field` for each: + +```html + + +``` + +### 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 + + +``` + +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 + + + + + +``` + +NOTE: Multiple select (`