docs(docs-infra): backport docs changes from 21.0.x

The PRs should have targeted main instead of targeting `21.0.x`  explicitly
This commit is contained in:
Matthieu Riegler
2025-11-14 02:33:55 +02:00
committed by Jessica Janiuk
parent fc1ef79ad4
commit 1464d02327
42 changed files with 2370 additions and 45 deletions
+1
View File
@@ -5,6 +5,7 @@
"@algolia/requester-browser-xhr": "5.43.0",
"@algolia/requester-node-http": "5.43.0",
"@angular/animations": "workspace:*",
"@angular/aria": "21.0.0-rc.2",
"@angular/build": "21.0.0-rc.3",
"@angular/cdk": "21.0.0-rc.2",
"@angular/cli": "21.0.0-rc.3",
+53 -6
View File
@@ -420,12 +420,31 @@ const DOCS_SUB_NAVIGATION_DATA: NavigationItem[] = [
},
{
label: 'Forms',
status: 'updated',
children: [
{
label: 'Overview',
path: 'guide/forms',
contentPath: 'guide/forms/overview',
},
{
label: 'Signal forms',
status: 'new',
children: [
{
label: 'Overview',
path: 'guide/forms/signals/overview',
contentPath: 'guide/forms/signals/overview',
status: 'new',
},
{
label: 'Form models',
path: 'guide/forms/signals/models',
contentPath: 'guide/forms/signals/models',
status: 'new',
},
],
},
{
label: 'Reactive forms',
path: 'guide/forms/reactive-forms',
@@ -691,6 +710,7 @@ const DOCS_SUB_NAVIGATION_DATA: NavigationItem[] = [
? [
{
label: 'Angular Aria',
// TODO: Mark status: 'new' after unwrapped from dev mode
children: [
{
label: 'Overview',
@@ -702,6 +722,11 @@ const DOCS_SUB_NAVIGATION_DATA: NavigationItem[] = [
path: 'guide/aria/accordion',
contentPath: 'guide/aria/accordion',
},
{
label: 'Autocomplete',
path: 'guide/aria/autocomplete',
contentPath: 'guide/aria/autocomplete',
},
{
label: 'Combobox',
path: 'guide/aria/combobox',
@@ -722,11 +747,6 @@ const DOCS_SUB_NAVIGATION_DATA: NavigationItem[] = [
path: 'guide/aria/menu',
contentPath: 'guide/aria/menu',
},
{
label: 'Radio Group',
path: 'guide/aria/radio',
contentPath: 'guide/aria/radio',
},
{
label: 'Tabs',
path: 'guide/aria/tabs',
@@ -742,10 +762,37 @@ const DOCS_SUB_NAVIGATION_DATA: NavigationItem[] = [
path: 'guide/aria/tree',
contentPath: 'guide/aria/tree',
},
{
label: 'Select',
path: 'guide/aria/select',
contentPath: 'guide/aria/select',
},
{
label: 'Multiselect',
path: 'guide/aria/multiselect',
contentPath: 'guide/aria/multiselect',
},
],
},
]
: []),
: [
// // TODO: Uncomment & modify for PR Previews
// {
// label: 'Angular Aria',
// children: [
// {
// label: 'Autocomplete',
// path: 'guide/aria/autocomplete',
// contentPath: 'guide/aria/autocomplete',
// },
// {
// label: 'Toolbar',
// path: 'guide/aria/toolbar',
// contentPath: 'guide/aria/toolbar',
// },
// ],
// },
]),
],
},
{
+1
View File
@@ -24,6 +24,7 @@ copy_to_directory(
"//adev/src/content/guide/di",
"//adev/src/content/guide/directives",
"//adev/src/content/guide/forms",
"//adev/src/content/guide/forms/signals",
"//adev/src/content/guide/http",
"//adev/src/content/guide/i18n",
"//adev/src/content/guide/ngmodules",
@@ -0,0 +1,89 @@
html {
font-family: var(--inter-font);
}
.combobox-container {
max-width: 400px;
margin: 20px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #e0e0e0;
}
.input-container {
position: relative;
}
.combobox-input {
width: 100%;
padding: 10px 12px;
border: 1px solid #404040;
border-radius: 4px;
font-size: 16px;
box-sizing: border-box;
background-color: #1a1a1a;
color: #e0e0e0;
}
.combobox-input::placeholder {
color: #888;
}
.combobox-input:focus {
outline: none;
border-color: #4a9eff;
background-color: #1f1f1f;
}
.popover {
margin: 0;
padding: 0;
border: 1px solid #404040;
border-radius: 4px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
background: #1a1a1a;
max-height: 300px;
overflow-y: auto;
}
.listbox {
padding: 4px 0;
}
.option {
padding: 10px 12px;
cursor: pointer;
user-select: none;
color: #e0e0e0;
}
.option:hover {
background-color: #2a2a2a;
}
.option[data-active] {
background-color: #2d4a6e;
color: #ffffff;
}
.option[aria-selected='true'] {
background-color: #4a9eff;
color: #000000;
}
.info {
margin: 20px;
padding: 16px;
background-color: #1f1f1f;
border-radius: 4px;
border-left: 4px solid #4a9eff;
color: #e0e0e0;
}
.info p {
margin: 8px 0;
}
@@ -0,0 +1,89 @@
html {
font-family: var(--inter-font);
}
.combobox-container {
max-width: 400px;
margin: 20px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #e0e0e0;
}
.input-container {
position: relative;
}
.combobox-input {
width: 100%;
padding: 10px 12px;
border: 1px solid #404040;
border-radius: 4px;
font-size: 16px;
box-sizing: border-box;
background-color: #1a1a1a;
color: #e0e0e0;
}
.combobox-input::placeholder {
color: #888;
}
.combobox-input:focus {
outline: none;
border-color: #4a9eff;
background-color: #1f1f1f;
}
.popover {
margin: 0;
padding: 0;
border: 1px solid #404040;
border-radius: 4px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
background: #1a1a1a;
max-height: 300px;
overflow-y: auto;
}
.listbox {
padding: 4px 0;
}
.option {
padding: 10px 12px;
cursor: pointer;
user-select: none;
color: #e0e0e0;
}
.option:hover {
background-color: #2a2a2a;
}
.option[data-active] {
background-color: #2d4a6e;
color: #ffffff;
}
.option[aria-selected='true'] {
background-color: #4a9eff;
color: #000000;
}
.info {
margin: 20px;
padding: 16px;
background-color: #1f1f1f;
border-radius: 4px;
border-left: 4px solid #4a9eff;
color: #e0e0e0;
}
.info p {
margin: 8px 0;
}
@@ -0,0 +1,31 @@
<div ngCombobox #combobox="ngCombobox" filterMode="auto-select" class="combobox-container">
<label for="state-input">Select a state:</label>
<div class="input-container">
<input
id="state-input"
ngComboboxInput
placeholder="Search states..."
[(value)]="searchString"
class="combobox-input"
/>
</div>
<div popover="manual" #popover class="popover">
<ng-template ngComboboxPopupContainer>
<div ngListbox class="listbox">
@for (option of options(); track option) {
<div class="option" ngOption [value]="option" [label]="option">
<span>{{ option }}</span>
</div>
}
</div>
</ng-template>
</div>
</div>
<div class="info">
<p><strong>Auto-select mode:</strong> The input updates automatically as you type to match the first option.</p>
@if (searchString()) {
<p>Current value: <strong>{{ searchString() }}</strong></p>
}
</div>
@@ -0,0 +1,108 @@
import {Combobox, ComboboxInput, ComboboxPopupContainer} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {
afterRenderEffect,
ChangeDetectionStrategy,
Component,
computed,
ElementRef,
signal,
viewChild,
} from '@angular/core';
@Component({
selector: 'app-root',
templateUrl: 'app.component.html',
styleUrl: 'app.component.css',
imports: [Combobox, ComboboxInput, ComboboxPopupContainer, Listbox, Option],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
popover = viewChild<ElementRef>('popover');
listbox = viewChild<Listbox<any>>(Listbox);
combobox = viewChild<Combobox<any>>(Combobox);
searchString = signal('');
options = computed(() =>
states.filter((state) => state.toLowerCase().startsWith(this.searchString().toLowerCase())),
);
constructor() {
afterRenderEffect(() => {
const popover = this.popover()!;
const combobox = this.combobox()!;
combobox.expanded() ? this.showPopover() : popover.nativeElement.hidePopover();
this.listbox()?.scrollActiveItemIntoView();
});
}
showPopover() {
const popover = this.popover()!;
const combobox = this.combobox()!;
const comboboxRect = combobox.inputElement()?.getBoundingClientRect();
const popoverEl = popover.nativeElement;
if (comboboxRect) {
popoverEl.style.width = `${comboboxRect.width}px`;
popoverEl.style.top = `${comboboxRect.bottom + 4}px`;
popoverEl.style.left = `${comboboxRect.left - 1}px`;
}
popover.nativeElement.showPopover();
}
}
const states = [
'Alabama',
'Alaska',
'Arizona',
'Arkansas',
'California',
'Colorado',
'Connecticut',
'Delaware',
'Florida',
'Georgia',
'Hawaii',
'Idaho',
'Illinois',
'Indiana',
'Iowa',
'Kansas',
'Kentucky',
'Louisiana',
'Maine',
'Maryland',
'Massachusetts',
'Michigan',
'Minnesota',
'Mississippi',
'Missouri',
'Montana',
'Nebraska',
'Nevada',
'New Hampshire',
'New Jersey',
'New Mexico',
'New York',
'North Carolina',
'North Dakota',
'Ohio',
'Oklahoma',
'Oregon',
'Pennsylvania',
'Rhode Island',
'South Carolina',
'South Dakota',
'Tennessee',
'Texas',
'Utah',
'Vermont',
'Virginia',
'Washington',
'West Virginia',
'Wisconsin',
'Wyoming',
];
@@ -0,0 +1,89 @@
html {
font-family: var(--inter-font);
}
.combobox-container {
max-width: 400px;
margin: 20px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #e0e0e0;
}
.input-container {
position: relative;
}
.combobox-input {
width: 100%;
padding: 10px 12px;
border: 1px solid #404040;
border-radius: 4px;
font-size: 16px;
box-sizing: border-box;
background-color: #1a1a1a;
color: #e0e0e0;
}
.combobox-input::placeholder {
color: #888;
}
.combobox-input:focus {
outline: none;
border-color: #4a9eff;
background-color: #1f1f1f;
}
.popover {
margin: 0;
padding: 0;
border: 1px solid #404040;
border-radius: 4px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
background: #1a1a1a;
max-height: 300px;
overflow-y: auto;
}
.listbox {
padding: 4px 0;
}
.option {
padding: 10px 12px;
cursor: pointer;
user-select: none;
color: #e0e0e0;
}
.option:hover {
background-color: #2a2a2a;
}
.option[data-active] {
background-color: #2d4a6e;
color: #ffffff;
}
.option[aria-selected='true'] {
background-color: #4a9eff;
color: #000000;
}
.info {
margin: 20px;
padding: 16px;
background-color: #1f1f1f;
border-radius: 4px;
border-left: 4px solid #4a9eff;
color: #e0e0e0;
}
.info p {
margin: 8px 0;
}
@@ -0,0 +1,31 @@
<div ngCombobox #combobox="ngCombobox" filterMode="highlight" class="combobox-container">
<label for="state-input">Select a state:</label>
<div class="input-container">
<input
id="state-input"
ngComboboxInput
placeholder="Search states..."
[(value)]="searchString"
class="combobox-input"
/>
</div>
<div popover="manual" #popover class="popover">
<ng-template ngComboboxPopupContainer>
<div ngListbox class="listbox">
@for (option of options(); track option) {
<div class="option" ngOption [value]="option" [label]="option">
<span>{{ option }}</span>
</div>
}
</div>
</ng-template>
</div>
</div>
<div class="info">
<p><strong>Highlight mode:</strong> Options are highlighted as you navigate with arrow keys, but the input only updates when you press Enter or click.</p>
@if (searchString()) {
<p>Current value: <strong>{{ searchString() }}</strong></p>
}
</div>
@@ -0,0 +1,108 @@
import {Combobox, ComboboxInput, ComboboxPopupContainer} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {
afterRenderEffect,
ChangeDetectionStrategy,
Component,
computed,
ElementRef,
signal,
viewChild,
} from '@angular/core';
@Component({
selector: 'app-root',
templateUrl: 'app.component.html',
styleUrl: 'app.component.css',
imports: [Combobox, ComboboxInput, ComboboxPopupContainer, Listbox, Option],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
popover = viewChild<ElementRef>('popover');
listbox = viewChild<Listbox<any>>(Listbox);
combobox = viewChild<Combobox<any>>(Combobox);
searchString = signal('');
options = computed(() =>
states.filter((state) => state.toLowerCase().startsWith(this.searchString().toLowerCase())),
);
constructor() {
afterRenderEffect(() => {
const popover = this.popover()!;
const combobox = this.combobox()!;
combobox.expanded() ? this.showPopover() : popover.nativeElement.hidePopover();
this.listbox()?.scrollActiveItemIntoView();
});
}
showPopover() {
const popover = this.popover()!;
const combobox = this.combobox()!;
const comboboxRect = combobox.inputElement()?.getBoundingClientRect();
const popoverEl = popover.nativeElement;
if (comboboxRect) {
popoverEl.style.width = `${comboboxRect.width}px`;
popoverEl.style.top = `${comboboxRect.bottom + 4}px`;
popoverEl.style.left = `${comboboxRect.left - 1}px`;
}
popover.nativeElement.showPopover();
}
}
const states = [
'Alabama',
'Alaska',
'Arizona',
'Arkansas',
'California',
'Colorado',
'Connecticut',
'Delaware',
'Florida',
'Georgia',
'Hawaii',
'Idaho',
'Illinois',
'Indiana',
'Iowa',
'Kansas',
'Kentucky',
'Louisiana',
'Maine',
'Maryland',
'Massachusetts',
'Michigan',
'Minnesota',
'Mississippi',
'Missouri',
'Montana',
'Nebraska',
'Nevada',
'New Hampshire',
'New Jersey',
'New Mexico',
'New York',
'North Carolina',
'North Dakota',
'Ohio',
'Oklahoma',
'Oregon',
'Pennsylvania',
'Rhode Island',
'South Carolina',
'South Dakota',
'Tennessee',
'Texas',
'Utah',
'Vermont',
'Virginia',
'Washington',
'West Virginia',
'Wisconsin',
'Wyoming',
];
@@ -0,0 +1,89 @@
html {
font-family: var(--inter-font);
}
.combobox-container {
max-width: 400px;
margin: 20px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #e0e0e0;
}
.input-container {
position: relative;
}
.combobox-input {
width: 100%;
padding: 10px 12px;
border: 1px solid #404040;
border-radius: 4px;
font-size: 16px;
box-sizing: border-box;
background-color: #1a1a1a;
color: #e0e0e0;
}
.combobox-input::placeholder {
color: #888;
}
.combobox-input:focus {
outline: none;
border-color: #4a9eff;
background-color: #1f1f1f;
}
.popover {
margin: 0;
padding: 0;
border: 1px solid #404040;
border-radius: 4px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
background: #1a1a1a;
max-height: 300px;
overflow-y: auto;
}
.listbox {
padding: 4px 0;
}
.option {
padding: 10px 12px;
cursor: pointer;
user-select: none;
color: #e0e0e0;
}
.option:hover {
background-color: #2a2a2a;
}
.option[data-active] {
background-color: #2d4a6e;
color: #ffffff;
}
.option[aria-selected='true'] {
background-color: #4a9eff;
color: #000000;
}
.info {
margin: 20px;
padding: 16px;
background-color: #1f1f1f;
border-radius: 4px;
border-left: 4px solid #4a9eff;
color: #e0e0e0;
}
.info p {
margin: 8px 0;
}
@@ -0,0 +1,31 @@
<div ngCombobox #combobox="ngCombobox" filterMode="manual" class="combobox-container">
<label for="state-input">Select a state:</label>
<div class="input-container">
<input
id="state-input"
ngComboboxInput
placeholder="Search states..."
[(value)]="searchString"
class="combobox-input"
/>
</div>
<div popover="manual" #popover class="popover">
<ng-template ngComboboxPopupContainer>
<div ngListbox class="listbox">
@for (option of options(); track option) {
<div class="option" ngOption [value]="option" [label]="option">
<span>{{ option }}</span>
</div>
}
</div>
</ng-template>
</div>
</div>
<div class="info">
<p><strong>Manual mode:</strong> The input only updates when you explicitly select an option with Enter or click.</p>
@if (searchString()) {
<p>Current value: <strong>{{ searchString() }}</strong></p>
}
</div>
@@ -0,0 +1,108 @@
import {Combobox, ComboboxInput, ComboboxPopupContainer} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {
afterRenderEffect,
ChangeDetectionStrategy,
Component,
computed,
ElementRef,
signal,
viewChild,
} from '@angular/core';
@Component({
selector: 'app-root',
templateUrl: 'app.component.html',
styleUrl: 'app.component.css',
imports: [Combobox, ComboboxInput, ComboboxPopupContainer, Listbox, Option],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
popover = viewChild<ElementRef>('popover');
listbox = viewChild<Listbox<any>>(Listbox);
combobox = viewChild<Combobox<any>>(Combobox);
searchString = signal('');
options = computed(() =>
states.filter((state) => state.toLowerCase().startsWith(this.searchString().toLowerCase())),
);
constructor() {
afterRenderEffect(() => {
const popover = this.popover()!;
const combobox = this.combobox()!;
combobox.expanded() ? this.showPopover() : popover.nativeElement.hidePopover();
this.listbox()?.scrollActiveItemIntoView();
});
}
showPopover() {
const popover = this.popover()!;
const combobox = this.combobox()!;
const comboboxRect = combobox.inputElement()?.getBoundingClientRect();
const popoverEl = popover.nativeElement;
if (comboboxRect) {
popoverEl.style.width = `${comboboxRect.width}px`;
popoverEl.style.top = `${comboboxRect.bottom + 4}px`;
popoverEl.style.left = `${comboboxRect.left - 1}px`;
}
popover.nativeElement.showPopover();
}
}
const states = [
'Alabama',
'Alaska',
'Arizona',
'Arkansas',
'California',
'Colorado',
'Connecticut',
'Delaware',
'Florida',
'Georgia',
'Hawaii',
'Idaho',
'Illinois',
'Indiana',
'Iowa',
'Kansas',
'Kentucky',
'Louisiana',
'Maine',
'Maryland',
'Massachusetts',
'Michigan',
'Minnesota',
'Mississippi',
'Missouri',
'Montana',
'Nebraska',
'Nevada',
'New Hampshire',
'New Jersey',
'New Mexico',
'New York',
'North Carolina',
'North Dakota',
'Ohio',
'Oklahoma',
'Oregon',
'Pennsylvania',
'Rhode Island',
'South Carolina',
'South Dakota',
'Tennessee',
'Texas',
'Utah',
'Vermont',
'Virginia',
'Washington',
'West Virginia',
'Wisconsin',
'Wyoming',
];
@@ -0,0 +1,47 @@
.toolbar {
gap: 16px;
padding: 8px;
display: flex;
border-radius: 4px;
background-color: #ffffff;
border: 1px solid #e0e0e0;
}
.group {
gap: 4px;
display: flex;
}
.separator {
width: 1px;
background-color: rgba(0, 0, 0, 0.12);
}
[ngToolbarWidget] {
cursor: pointer;
padding: 6px 12px;
background-color: transparent;
border: 1px solid transparent;
border-radius: 4px;
font-size: 14px;
}
[ngToolbarWidget]:hover {
background-color: rgba(0, 0, 0, 0.05);
}
[ngToolbarWidget]:focus {
border-color: #4a9eff;
outline: 2px solid rgba(74, 158, 255, 0.3);
}
[ngToolbarWidget][aria-pressed="true"],
[ngToolbarWidget][aria-checked="true"] {
color: #1565c0;
background-color: rgba(33, 150, 243, 0.15);
}
[ngToolbarWidget][aria-disabled="true"] {
cursor: default;
opacity: 0.45;
}
@@ -0,0 +1,27 @@
<div ngToolbar class="toolbar" aria-label="Text Formatting Tools">
<div class="group" aria-label="Undo and Redo options">
<button ngToolbarWidget value="undo">Undo</button>
<button ngToolbarWidget value="redo">Redo</button>
</div>
<div class="separator" role="separator"></div>
<div class="group" aria-label="Text formatting options">
<button ngToolbarWidget value="bold">Bold</button>
<button ngToolbarWidget value="italic">Italic</button>
<button ngToolbarWidget value="underline">Underline</button>
</div>
<div class="separator" role="separator"></div>
<div
ngToolbarWidgetGroup
role="radiogroup"
class="group"
aria-label="Alignment options"
>
<button ngToolbarWidget value="align-left">Align Left</button>
<button ngToolbarWidget value="align-center">Align Center</button>
<button ngToolbarWidget value="align-right">Align Right</button>
</div>
</div>
@@ -0,0 +1,10 @@
import {Component} from '@angular/core';
import {Toolbar, ToolbarWidget, ToolbarWidgetGroup} from '@angular/aria/toolbar';
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
imports: [Toolbar, ToolbarWidget, ToolbarWidgetGroup],
})
export class App {}
@@ -0,0 +1,47 @@
.toolbar {
gap: 16px;
padding: 8px;
display: flex;
border-radius: 4px;
background-color: #ffffff;
border: 1px solid #e0e0e0;
}
.group {
gap: 4px;
display: flex;
}
.separator {
width: 1px;
background-color: rgba(0, 0, 0, 0.12);
}
[ngToolbarWidget] {
cursor: pointer;
padding: 6px 12px;
background-color: transparent;
border: 1px solid transparent;
border-radius: 4px;
font-size: 14px;
}
[ngToolbarWidget]:hover {
background-color: rgba(0, 0, 0, 0.05);
}
[ngToolbarWidget]:focus {
border-color: #4a9eff;
outline: 2px solid rgba(74, 158, 255, 0.3);
}
[ngToolbarWidget][aria-pressed="true"],
[ngToolbarWidget][aria-checked="true"] {
color: #1565c0;
background-color: rgba(33, 150, 243, 0.15);
}
[ngToolbarWidget][aria-disabled="true"] {
cursor: default;
opacity: 0.45;
}
@@ -0,0 +1,35 @@
<div
ngToolbar
[softDisabled]="false"
class="toolbar"
aria-label="Text Formatting Tools"
>
<button ngToolbarWidget value="undo">Undo</button>
<button ngToolbarWidget value="redo" [disabled]="true">Redo</button>
<div class="separator" role="separator"></div>
<div
ngToolbarWidgetGroup
role="radiogroup"
class="group"
aria-label="Alignment options"
>
<button ngToolbarWidget value="left">Align Left</button>
<button ngToolbarWidget value="center" [disabled]="true">Align Center</button>
<button ngToolbarWidget value="right">Align Right</button>
</div>
<div class="separator" role="separator"></div>
<div
ngToolbarWidgetGroup
[disabled]="true"
role="radiogroup"
class="group"
aria-label="List formatting"
>
<button ngToolbarWidget value="bullet">Bullet List</button>
<button ngToolbarWidget value="numbered">Numbered List</button>
</div>
</div>
@@ -0,0 +1,10 @@
import {Component} from '@angular/core';
import {Toolbar, ToolbarWidget, ToolbarWidgetGroup} from '@angular/aria/toolbar';
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
imports: [Toolbar, ToolbarWidget, ToolbarWidgetGroup],
})
export class App {}
@@ -0,0 +1,47 @@
.toolbar {
gap: 16px;
padding: 8px;
display: flex;
border-radius: 4px;
background-color: #ffffff;
border: 1px solid #e0e0e0;
}
.group {
gap: 4px;
display: flex;
}
.separator {
width: 1px;
background-color: rgba(0, 0, 0, 0.12);
}
[ngToolbarWidget] {
cursor: pointer;
padding: 6px 12px;
background-color: transparent;
border: 1px solid transparent;
border-radius: 4px;
font-size: 14px;
}
[ngToolbarWidget]:hover {
background-color: rgba(0, 0, 0, 0.05);
}
[ngToolbarWidget]:focus {
border-color: #4a9eff;
outline: 2px solid rgba(74, 158, 255, 0.3);
}
[ngToolbarWidget][aria-pressed="true"],
[ngToolbarWidget][aria-checked="true"] {
color: #1565c0;
background-color: rgba(33, 150, 243, 0.15);
}
[ngToolbarWidget][aria-disabled="true"] {
cursor: default;
opacity: 0.45;
}
@@ -0,0 +1,29 @@
<div dir="rtl">
<div ngToolbar class="toolbar" aria-label="Text Formatting Tools">
<div class="group" aria-label="Undo and Redo options">
<button ngToolbarWidget value="undo">Undo</button>
<button ngToolbarWidget value="redo">Redo</button>
</div>
<div class="separator" role="separator"></div>
<div class="group" aria-label="Text formatting options">
<button ngToolbarWidget value="bold">Bold</button>
<button ngToolbarWidget value="italic">Italic</button>
<button ngToolbarWidget value="underline">Underline</button>
</div>
<div class="separator" role="separator"></div>
<div
ngToolbarWidgetGroup
role="radiogroup"
class="group"
aria-label="Alignment options"
>
<button ngToolbarWidget value="align-left">Align Left</button>
<button ngToolbarWidget value="align-center">Align Center</button>
<button ngToolbarWidget value="align-right">Align Right</button>
</div>
</div>
</div>
@@ -0,0 +1,10 @@
import {Component} from '@angular/core';
import {Toolbar, ToolbarWidget, ToolbarWidgetGroup} from '@angular/aria/toolbar';
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
imports: [Toolbar, ToolbarWidget, ToolbarWidgetGroup],
})
export class App {}
@@ -0,0 +1,60 @@
.toolbar {
gap: 16px;
padding: 8px;
display: flex;
border-radius: 4px;
background-color: #ffffff;
border: 1px solid #e0e0e0;
}
.toolbar[aria-orientation="vertical"] {
flex-direction: column;
}
.group {
gap: 4px;
display: flex;
}
.toolbar[aria-orientation="vertical"] .group {
flex-direction: column;
}
.separator {
width: 1px;
background-color: rgba(0, 0, 0, 0.12);
}
.toolbar[aria-orientation="vertical"] .separator {
height: 1px;
width: auto;
}
[ngToolbarWidget] {
cursor: pointer;
padding: 6px 12px;
background-color: transparent;
border: 1px solid transparent;
border-radius: 4px;
font-size: 14px;
}
[ngToolbarWidget]:hover {
background-color: rgba(0, 0, 0, 0.05);
}
[ngToolbarWidget]:focus {
border-color: #4a9eff;
outline: 2px solid rgba(74, 158, 255, 0.3);
}
[ngToolbarWidget][aria-pressed="true"],
[ngToolbarWidget][aria-checked="true"] {
color: #1565c0;
background-color: rgba(33, 150, 243, 0.15);
}
[ngToolbarWidget][aria-disabled="true"] {
cursor: default;
opacity: 0.45;
}
@@ -0,0 +1,32 @@
<div
ngToolbar
orientation="vertical"
class="toolbar"
aria-label="Vertical Text Formatting Tools"
>
<div class="group" aria-label="Undo and Redo options">
<button ngToolbarWidget value="undo">Undo</button>
<button ngToolbarWidget value="redo">Redo</button>
</div>
<div class="separator" role="separator"></div>
<div class="group" aria-label="Text formatting options">
<button ngToolbarWidget value="bold">Bold</button>
<button ngToolbarWidget value="italic">Italic</button>
<button ngToolbarWidget value="underline">Underline</button>
</div>
<div class="separator" role="separator"></div>
<div
ngToolbarWidgetGroup
role="radiogroup"
class="group"
aria-label="Alignment options"
>
<button ngToolbarWidget value="align-left">Align Left</button>
<button ngToolbarWidget value="align-center">Align Center</button>
<button ngToolbarWidget value="align-right">Align Right</button>
</div>
</div>
@@ -0,0 +1,10 @@
import {Component} from '@angular/core';
import {Toolbar, ToolbarWidget, ToolbarWidgetGroup} from '@angular/aria/toolbar';
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
imports: [Toolbar, ToolbarWidget, ToolbarWidgetGroup],
})
export class App {}
@@ -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,22 @@
import {Component, signal} from '@angular/core';
import {form, Field} from '@angular/forms/signals';
interface LoginData {
email: string;
password: string;
}
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
imports: [Field],
})
export class App {
loginModel = signal<LoginData>({
email: '',
password: '',
});
loginForm = form(this.loginModel);
}
@@ -1,5 +1,5 @@
import {bootstrapApplication} from '@angular/platform-browser';
import {App} from './app/app.component';
import {App} from './app/app';
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,36 @@
import {Component, signal} from '@angular/core';
import {form, Field, required, email, submit} from '@angular/forms/signals';
interface LoginData {
email: string;
password: string;
}
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
imports: [Field],
})
export class App {
loginModel = signal<LoginData>({
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);
});
}
}
@@ -1,5 +1,5 @@
import {bootstrapApplication} from '@angular/platform-browser';
import {App} from './app/app.component';
import {App} from './app/app';
bootstrapApplication(App);
+1
View File
@@ -22,6 +22,7 @@ copy_to_bin(
"//adev/src/content/guide/di:guide_files",
"//adev/src/content/guide/directives:guide_files",
"//adev/src/content/guide/forms:guide_files",
"//adev/src/content/guide/forms/signals:guide_files",
"//adev/src/content/guide/http:guide_files",
"//adev/src/content/guide/i18n:guide_files",
"//adev/src/content/guide/performance:guide_files",
+140
View File
@@ -0,0 +1,140 @@
<docs-decorative-header title="Autocomplete">
An accessible input field that filters and suggests options as users type, helping them find and select values from a list.
</docs-decorative-header>
<docs-code-multifile preview hideCode path="adev/src/content/examples/aria/autocomplete/src/basic/app/app.component.ts">
<docs-code header="app.component.ts" path="adev/src/content/examples/aria/autocomplete/src/basic/app/app.component.ts"/>
<docs-code header="app.component.html" path="adev/src/content/examples/aria/autocomplete/src/basic/app/app.component.html"/>
<docs-code header="app.component.css" path="adev/src/content/examples/aria/autocomplete/src/basic/app/app.component.css"/>
</docs-code-multifile>
## Usage
Autocomplete works best when users need to select from a large set of options where typing is faster than scrolling. Consider using autocomplete when:
- **The option list is long** (more than 20 items) - Typing narrows down choices faster than scrolling through a dropdown
- **Users know what they're looking for** - They can type part of the expected value (like a state name, product, or username)
- **Options follow predictable patterns** - Users can guess partial matches (like country codes, email domains, or categories)
- **Speed matters** - Forms benefit from quick selection without extensive navigation
Avoid autocomplete when:
- The list has fewer than 10 options - A regular dropdown or radio group provides better visibility
- Users need to browse options - If discovery is important, show all options upfront
- Options are unfamiliar - Users can't type what they don't know exists in the list
## Features
Angular's autocomplete provides a fully accessible combobox implementation with:
- **Keyboard Navigation** - Navigate options with arrow keys, select with Enter, close with Escape
- **Screen Reader Support** - Built-in ARIA attributes for assistive technologies
- **Three Filter Modes** - Choose between auto-select, manual selection, or highlighting behavior
- **Signal-Based Reactivity** - Reactive state management using Angular signals
- **Popover API Integration** - Leverages the native HTML Popover API for optimal positioning
- **Bidirectional Text Support** - Automatically handles right-to-left (RTL) languages
## Examples
### Auto-select mode
Users typing partial text expect immediate confirmation that their input matches an available option. Auto-select mode updates the input value to match the first filtered option as users type, reducing the number of keystrokes needed and providing instant feedback that their search is on the right track.
<docs-code-multifile preview hideCode path="adev/src/content/examples/aria/autocomplete/src/basic/app/app.component.ts">
<docs-code header="app.component.ts" path="adev/src/content/examples/aria/autocomplete/src/basic/app/app.component.ts" visibleLines="[1,7,33,40]"/>
<docs-code header="app.component.html" path="adev/src/content/examples/aria/autocomplete/src/basic/app/app.component.html"/>
</docs-code-multifile>
### Manual selection mode
Manual selection mode keeps the typed text unchanged while users navigate the suggestion list, preventing confusion from automatic updates. The input only changes when users explicitly confirm their choice with Enter or a click.
<docs-code-multifile preview hideCode path="adev/src/content/examples/aria/autocomplete/src/manual/app/app.component.ts">
<docs-code header="app.component.html" path="adev/src/content/examples/aria/autocomplete/src/manual/app/app.component.html" visibleLines="[1]"/>
</docs-code-multifile>
### Highlight mode
Highlight mode allows the user to navigate options with arrow keys without changing the input value as they browse until they explicitly select a new option with Enter or click.
<docs-code-multifile preview hideCode path="adev/src/content/examples/aria/autocomplete/src/highlight/app/app.component.ts">
<docs-code header="app.component.html" path="adev/src/content/examples/aria/autocomplete/src/highlight/app/app.component.html" visibleLines="[1]"/>
</docs-code-multifile>
## Showcase
TBD
## APIs
### Combobox Directive
The `ngCombobox` directive provides the container for autocomplete functionality.
#### Inputs
| Property | Type | Default | Description |
| ------------ | ---------------------------------------------- | ---------- | ------------------------------------------------- |
| `filterMode` | `'auto-select'` \| `'manual'` \| `'highlight'` | `'manual'` | Controls selection behavior |
| `disabled` | `boolean` | `false` | Disables the combobox |
| `firstMatch` | `string` | - | The value of the first matching item in the popup |
#### Outputs
| Property | Type | Description |
| ---------- | ----------------- | ----------------------------------------------------- |
| `expanded` | `Signal<boolean>` | Signal indicating whether the popup is currently open |
### ComboboxInput Directive
The `ngComboboxInput` directive connects an input element to the combobox.
#### Model
| Property | Type | Description |
| -------- | -------- | ------------------------------------------------------------ |
| `value` | `string` | Two-way bindable string value of the input using `[(value)]` |
### ComboboxPopupContainer Directive
The `ngComboboxPopupContainer` directive wraps the popup content and manages its display.
Must be used with `<ng-template>` inside a popover element.
### Related components
Autocomplete uses [Listbox](https://angular.dev/api/aria/listbox/Listbox) and [Option](https://angular.dev/api/aria/listbox/Option) directives to render the suggestion list. See the [Listbox documentation](https://angular.dev/guide/aria/listbox) for additional customization options.
## Styling
The autocomplete components don't include default styles. This allows full customization to match your design system. Apply styles through standard CSS classes or style bindings.
### Styling the input
```css
input[ngComboboxInput] {
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 4px;
}
input[ngComboboxInput]:focus {
outline: 2px solid blue;
border-color: blue;
}
```
### Styling the popup
```css
[popover] {
border: 1px solid #ccc;
border-radius: 4px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
padding: 0;
}
```
### Styling options
Options use the listbox styling. See the [Listbox styling guide](https://angular.dev/guide/aria/listbox#styling) for detailed customization patterns.
+158 -13
View File
@@ -1,22 +1,167 @@
<docs-decorative-header title="Toolbar">
<!-- TODO: Add a short description about Toolbar. -->
</docs-decorative-header>
<docs-pill-row>
<docs-pill href="https://www.w3.org/WAI/ARIA/apg/patterns/toolbar/" title="ARIA pattern"/>
<!-- TODO: Add a link to the Toolbar API reference. -->
</docs-pill-row>
## Overview
<!-- TODO: Add a top level component preview with code example hidden.
A container for grouping related controls and actions with keyboard navigation, commonly used for text formatting, toolbars, and command panels.
<docs-code-multifile preview themed hideCode path="adev/src/content/examples/aria/src/toolbar/app/app.component.ts">
<docs-code header="app/app.component.html" path="adev/src/content/examples/aria/src/toolbar/app/app.component.html"/>
<docs-code header="app/app.component.ts" path="adev/src/content/examples/aria/src/toolbar/app/app.component.ts"/>
<docs-code header="app/app.component.css" path="adev/src/content/examples/aria/src/toolbar/app/app.component.css"/>
<docs-code-multifile preview hideCode path="adev/src/content/examples/aria/toolbar/src/basic/app/app.ts">
<docs-code header="app.ts" path="adev/src/content/examples/aria/toolbar/src/basic/app/app.ts"/>
<docs-code header="app.html" path="adev/src/content/examples/aria/toolbar/src/basic/app/app.html"/>
<docs-code header="app.css" path="adev/src/content/examples/aria/toolbar/src/basic/app/app.css"/>
</docs-code-multifile>
-->
## Usage
### Example with TailwindCSS
Toolbar works best for grouping related controls that users access frequently. Consider using toolbar when:
<!-- TODO: Add more code examples with different styles. -->
- **Multiple related actions** - You have several controls that perform related functions (like text formatting buttons)
- **Keyboard efficiency matters** - Users benefit from quick keyboard navigation through arrow keys
- **Grouped controls** - You need to organize controls into logical sections with separators
- **Frequent access** - Controls are used repeatedly within a workflow
Avoid toolbar when:
- A simple button group is sufficient - For just 2-3 unrelated actions, individual buttons work better
- Controls aren't related - Toolbar implies a logical grouping; unrelated controls confuse users
- Complex nested navigation - Deep hierarchies are better served by menus or navigation components
## Features
Angular's toolbar provides a fully accessible toolbar implementation with:
- **Keyboard Navigation** - Navigate widgets with arrow keys, activate with Enter or Space
- **Screen Reader Support** - Built-in ARIA attributes for assistive technologies
- **Widget Groups** - Organize related widgets like radio button groups or toggle button groups
- **Flexible Orientation** - Horizontal or vertical layouts with automatic keyboard navigation
- **Signal-Based Reactivity** - Reactive state management using Angular signals
- **Bidirectional Text Support** - Automatically handles right-to-left (RTL) languages
- **Configurable Focus** - Choose between wrapping navigation or hard stops at edges
## Examples
### Basic horizontal toolbar
Horizontal toolbars organize controls from left to right, matching the common pattern in text editors and design tools. Arrow keys navigate between widgets, maintaining focus within the toolbar until users press Tab to move to the next page element.
<docs-code-multifile preview hideCode path="adev/src/content/examples/aria/toolbar/src/basic/app/app.ts">
<docs-code header="app.ts" path="adev/src/content/examples/aria/toolbar/src/basic/app/app.ts"/>
<docs-code header="app.html" path="adev/src/content/examples/aria/toolbar/src/basic/app/app.html"/>
<docs-code header="app.css" path="adev/src/content/examples/aria/toolbar/src/basic/app/app.css"/>
</docs-code-multifile>
### Vertical toolbar
Vertical toolbars stack controls top to bottom, useful for side panels or vertical command palettes. Up and down arrow keys navigate between widgets.
<docs-code-multifile preview hideCode path="adev/src/content/examples/aria/toolbar/src/vertical/app/app.ts">
<docs-code header="app.ts" path="adev/src/content/examples/aria/toolbar/src/vertical/app/app.ts"/>
<docs-code header="app.html" path="adev/src/content/examples/aria/toolbar/src/vertical/app/app.html" highlight="[3]"/>
<docs-code header="app.css" path="adev/src/content/examples/aria/toolbar/src/vertical/app/app.css"/>
</docs-code-multifile>
### Widget groups
Widget groups contain related controls that work together, like text alignment options or list formatting choices. Groups maintain their own internal state while participating in toolbar navigation.
In the examples above, the alignment buttons are wrapped in `ngToolbarWidgetGroup` with `role="radiogroup"` to create a mutually exclusive selection group.
The `multi` input controls whether multiple widgets within a group can be selected simultaneously:
<docs-code language="html" highlight="[15]">
<!-- Single selection (radio group) -->
<div
ngToolbarWidgetGroup
role="radiogroup"
aria-label="Alignment"
>
<button ngToolbarWidget value="left">Left</button>
<button ngToolbarWidget value="center">Center</button>
<button ngToolbarWidget value="right">Right</button>
</div>
<!-- Multiple selection (toggle group) -->
<div
ngToolbarWidgetGroup
[multi]="true"
aria-label="Formatting"
>
<button ngToolbarWidget value="bold">Bold</button>
<button ngToolbarWidget value="italic">Italic</button>
<button ngToolbarWidget value="underline">Underline</button>
</div>
</docs-code>
### Disabled widgets
Toolbars support two disabled modes:
1. **Soft-disabled** widgets remain focusable but visually indicate they're unavailable
2. **Hard-disabled** widgets are completely removed from keyboard navigation.
By default, `softDisabled` is `true`, which allows disabled widgets to still receive focus. If you want to enable hard-disabled mode, set `[softDisabled]="false"` on the toolbar.
<docs-code-multifile preview hideCode path="adev/src/content/examples/aria/toolbar/src/disabled/app/app.ts">
<docs-code header="app.html" path="adev/src/content/examples/aria/toolbar/src/disabled/app/app.html" highlight="[3,8,19,27]"/>
</docs-code-multifile>
### Right-to-left (RTL) support
Toolbars automatically support right-to-left languages. Wrap the toolbar in a container with `dir="rtl"` to reverse the layout and keyboard navigation direction. Arrow key navigation adjusts automatically: left arrow moves to the next widget, right arrow to the previous.
<docs-code-multifile preview hideCode path="adev/src/content/examples/aria/toolbar/src/rtl/app/app.ts">
<docs-code header="app.html" path="adev/src/content/examples/aria/toolbar/src/rtl/app/app.html" highlight="[1]"/>
</docs-code-multifile>
## Showcase
TBD
## APIs
### Toolbar Directive
The `ngToolbar` directive provides the container for toolbar functionality.
#### Inputs
| Property | Type | Default | Description |
| -------------- | ------------------------------ | -------------- | ------------------------------------------------------ |
| `orientation` | `'vertical'` \| `'horizontal'` | `'horizontal'` | Whether toolbar is vertically or horizontally oriented |
| `disabled` | `boolean` | `false` | Disables the entire toolbar |
| `softDisabled` | `boolean` | `true` | Whether disabled items can receive focus |
| `wrap` | `boolean` | `true` | Whether focus should wrap at the edges |
### ToolbarWidget Directive
The `ngToolbarWidget` directive marks an element as a navigable widget within the toolbar.
#### Inputs
| Property | Type | Default | Description |
| ---------- | --------- | ------- | ----------------------------------------------- |
| `id` | `string` | auto | Unique identifier for the widget |
| `disabled` | `boolean` | `false` | Disables the widget |
| `value` | `V` | - | The value associated with the widget (required) |
#### Signals
| Property | Type | Description |
| ---------- | ----------------- | ------------------------------------------- |
| `active` | `Signal<boolean>` | Whether the widget is currently focused |
| `selected` | `Signal<boolean>` | Whether the widget is selected (in a group) |
### ToolbarWidgetGroup Directive
The `ngToolbarWidgetGroup` directive groups related widgets together.
#### Inputs
| Property | Type | Default | Description |
| ---------- | --------- | ------- | ---------------------------------------- |
| `disabled` | `boolean` | `false` | Disables all widgets in the group |
| `multi` | `boolean` | `false` | Whether multiple widgets can be selected |
### Related components
Toolbar can contain various widget types including buttons, trees, and comboboxes. See individual component documentation for specific widget implementations.
+3
View File
@@ -5,8 +5,11 @@ Handling user input with forms is the cornerstone of many common applications.
Applications use forms to enable users to log in, to update a profile, to enter sensitive information, and to perform many other data-entry tasks.
Angular provides two different approaches to handling user input through forms: reactive and template-driven.
Both capture user input events from the view, validate the user input, create a form model and data model to update, and provide a way to track changes.
TIP: If you're looking for the new experimental Signal Forms, check out our [essential Signal Forms guide](/essentials/signal-forms)!
This guide provides information to help you decide which type of form works best for your situation.
It introduces the common building blocks used by both approaches.
It also summarizes the key differences between the two approaches, and demonstrates those differences in the context of setup, data flow, and testing.
@@ -0,0 +1,22 @@
load("//adev/shared-docs:defaults.bzl", "copy_to_bin")
load("//adev/shared-docs:index.bzl", "generate_guides")
generate_guides(
name = "signals",
srcs = glob([
"*.md",
]),
api_manifest = "//adev/src/assets:docs_api_manifest",
data = [
"//adev/src/assets/images:signals.svg",
"//adev/src/content/examples",
],
mermaid_blocks = True,
visibility = ["//adev:__subpackages__"],
)
copy_to_bin(
name = "guide_files",
srcs = glob(["**/*.md"]),
visibility = ["//adev:__subpackages__"],
)
@@ -0,0 +1,536 @@
# Form models
Form models are the foundation of Signal Forms, serving as the single source of truth for your form data. This guide explores how to create form models, update them, and design them for maintainability.
NOTE: Form models are distinct from Angular's `model()` signal used for component two-way binding. A form model is a writable signal that stores form data, while `model()` creates inputs/outputs for parent/child component communication.
## What form models solve
Forms require managing data that changes over time. Without a clear structure, this data can become scattered across component properties, making it difficult to track changes, validate input, or submit data to a server.
Form models solve this by centralizing form data in a single writable signal. When the model updates, the form automatically reflects those changes. When users interact with the form, the model updates accordingly.
## Creating models
A form model is a writable signal created with Angular's `signal()` function. The signal holds an object that represents your form's data structure.
```ts
import { Component, signal } from '@angular/core'
import { form, Field } from '@angular/forms/signals'
@Component({
selector: 'app-login',
imports: [Field],
template: `
<input type="email" [field]="loginForm.email" />
<input type="password" [field]="loginForm.password" />
`
})
export class LoginComponent {
loginModel = signal({
email: '',
password: ''
})
loginForm = form(this.loginModel)
}
```
The `form()` function accepts the model signal and creates a **field tree** - a special object structure that mirrors your model's shape. The field tree is both navigable (access child fields with dot notation like `loginForm.email`) and callable (call a field as a function to access its state).
The `[field]` directive binds each input element to its corresponding field in the field tree, enabling automatic two-way synchronization between the UI and model.
### Using TypeScript types
While TypeScript infers types from object literals, defining explicit types improves code quality and provides better IntelliSense support.
```ts
interface LoginData {
email: string
password: string
}
export class LoginComponent {
loginModel = signal<LoginData>({
email: '',
password: ''
})
loginForm = form(this.loginModel)
}
```
With explicit types, the field tree provides full type safety. Accessing `loginForm.email` is typed as `FieldTree<string>`, and attempting to access a non-existent property results in a compile-time error.
```ts
// TypeScript knows this is FieldTree<string>
const emailField = loginForm.email
// TypeScript error: Property 'username' does not exist
const usernameField = loginForm.username
```
### Initializing all fields
Form models should provide initial values for all fields you want to include in the field tree.
```ts
// Good: All fields initialized
const userModel = signal({
name: '',
email: '',
age: 0
})
// Avoid: Missing initial value
const userModel = signal({
name: '',
email: ''
// age field is not defined - cannot access userForm.age
})
```
For optional fields, explicitly set them to `null` or an empty value:
```ts
interface UserData {
name: string
email: string
phoneNumber: string | null
}
const userModel = signal<UserData>({
name: '',
email: '',
phoneNumber: null
})
```
Fields set to `undefined` are excluded from the field tree. A model with `{value: undefined}` behaves identically to `{}` - accessing the field returns `undefined` rather than a `FieldTree`.
### Dynamic field addition
You can dynamically add fields by updating the model with new properties. The field tree automatically updates to include new fields when they appear in the model value.
```ts
// Start with just email
const model = signal({ email: '' })
const myForm = form(model)
// Later, add a password field
model.update(current => ({ ...current, password: '' }))
// myForm.password is now available
```
This pattern is useful when fields become relevant based on user choices or loaded data.
## Reading model values
You can access form values in two ways: directly from the model signal, or through individual fields. Each approach serves a different purpose.
### Reading from the model
Access the model signal when you need the complete form data, such as during form submission:
```ts
onSubmit() {
const formData = this.loginModel();
console.log(formData.email, formData.password);
// Send to server
await this.authService.login(formData);
}
```
The model signal returns the entire data object, making it ideal for operations that work with the complete form state.
### Reading from field state
Each field in the field tree is a function. Calling a field returns a `FieldState` object containing reactive signals for the field's value, validation status, and interaction state.
Access field state when working with individual fields in templates or reactive computations:
```ts
@Component({
template: `
<p>Current email: {{ loginForm.email().value() }}</p>
<p>Password length: {{ passwordLength() }}</p>
`
})
export class LoginComponent {
loginModel = signal({ email: '', password: '' })
loginForm = form(this.loginModel)
passwordLength = computed(() => {
return this.loginForm.password().value().length
})
}
```
Field state provides reactive signals for each field's value, making it suitable for displaying field-specific information or creating derived state.
TIP: Field state includes many more signals beyond `value()`, such as validation state (e.g., valid, invalid, errors), interaction tracking (e.g., touched, dirty), and visibility (e.g., hidden, disabled).
<!-- TODO: UNCOMMENT BELOW WHEN GUIDE IS AVAILABLE -->
<!-- See the [Field State Management guide](guide/forms/signal-forms/field-state-management) for complete coverage. -->
## Updating form models programmatically
Form models update through programmatic mechanisms:
1. [Replace the entire form model](#replacing-form-models-with-set) with `set()`
2. [Update one or more fields](#update-one-or-more-fields-with-update) with `update()`
3. [Update a single field directly](#update-a-single-field-directly-with-set) through field state
### Replacing form models with `set()`
Use `set()` on the form model to replace the entire value:
```ts
loadUserData() {
this.userModel.set({
name: 'Alice',
email: 'alice@example.com',
age: 30,
});
}
resetForm() {
this.userModel.set({
name: '',
email: '',
age: 0,
});
}
```
This approach works well when loading data from an API or resetting the entire form.
### Update one or more fields with `update()`
Use `update()` to modify specific fields while preserving others:
```ts
updateEmail(newEmail: string) {
this.userModel.update(current => ({
...current,
email: newEmail,
}));
}
```
This pattern is useful when you need to change one or more fields based on the current model state.
### Update a single field directly with `set()`
Use `set()` on individual field values to directly update the field state:
```ts
clearEmail() {
this.userForm.email().value.set('');
}
incrementAge() {
const currentAge = this.userForm.age().value();
this.userForm.age().value.set(currentAge + 1);
}
```
These are also known as "field-level updates." They automatically propagate to the model signal and keep both in sync.
### Example: Loading data from an API
A common pattern involves fetching data and populating the model:
```ts
export class UserProfileComponent {
userModel = signal({
name: '',
email: '',
bio: ''
})
userForm = form(this.userModel)
private userService = inject(UserService)
ngOnInit() {
this.loadUserProfile()
}
async loadUserProfile() {
const userData = await this.userService.getUserProfile()
this.userModel.set(userData)
}
}
```
The form fields automatically update when the model changes, displaying the fetched data without additional code.
## Two-way data binding
The `[field]` directive creates automatic two-way synchronization between the model, form state, and UI.
### How data flows
Changes flow bidirectionally:
**User input → Model:**
1. User types in an input element
2. The `[field]` directive detects the change
3. Field state updates
4. Model signal updates
**Programmatic update → UI:**
1. Code updates the model with `set()` or `update()`
2. Model signal notifies subscribers
3. Field state updates
4. The `[field]` directive updates the input element
This synchronization happens automatically. You don't write subscriptions or event handlers to keep the model and UI in sync.
### Example: Both directions
```ts
@Component({
template: `
<input type="text" [field]="userForm.name" />
<button (click)="setName('Bob')">Set Name to Bob</button>
<p>Current name: {{ userModel().name }}</p>
`
})
export class UserComponent {
userModel = signal({ name: '' })
userForm = form(this.userModel)
setName(name: string) {
this.userModel.update(current => ({ ...current, name }))
// Input automatically displays 'Bob'
}
}
```
When the user types in the input, `userModel().name` updates. When the button is clicked, the input value changes to "Bob". No manual synchronization code is required.
## Model structure patterns
Form models can be flat objects or contain nested objects and arrays. The structure you choose affects how you access fields and organize validation.
### Flat vs nested models
Flat form models keep all fields at the top level:
```ts
// Flat structure
const userModel = signal({
name: '',
email: '',
street: '',
city: '',
state: '',
zip: ''
})
```
Nested models group related fields:
```ts
// Nested structure
const userModel = signal({
name: '',
email: '',
address: {
street: '',
city: '',
state: '',
zip: ''
}
})
```
**Use flat structures when:**
- Fields don't have clear conceptual groupings
- You want simpler field access (`userForm.city` vs `userForm.address.city`)
- Validation rules span multiple potential groups
**Use nested structures when:**
- Fields form a clear conceptual group (like an address)
- The grouped data matches your API structure
- You want to validate the group as a unit
### Working with nested objects
You can access nested fields by following the object path:
```ts
const userModel = signal({
profile: {
firstName: '',
lastName: ''
},
settings: {
theme: 'light',
notifications: true
}
})
const userForm = form(userModel)
// Access nested fields
userForm.profile.firstName // FieldTree<string>
userForm.settings.theme // FieldTree<string>
```
In templates, you bind nested fields the same way as top-level fields:
```ts
@Component({
template: `
<input [field]="userForm.profile.firstName" />
<input [field]="userForm.profile.lastName" />
<select [field]="userForm.settings.theme">
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
`,
})
```
### Working with arrays
Models can include arrays for collections of items:
```ts
const orderModel = signal({
customerName: '',
items: [{ product: '', quantity: 0, price: 0 }]
})
const orderForm = form(orderModel)
// Access array items by index
orderForm.items[0].product // FieldTree<string>
orderForm.items[0].quantity // FieldTree<number>
```
Array items containing objects automatically receive tracking identities, which helps maintain field state even when items change position in the array. This ensures validation state and user interactions persist correctly when arrays are reordered.
<!-- TBD: For dynamic arrays and complex array operations, see the [Working with arrays guide](guide/forms/signal-forms/arrays). -->
## Model design best practices
Well-designed form models make forms easier to maintain and extend. Follow these patterns when designing your models.
### Use specific types
Always define interfaces or types for your models as shown in [Using TypeScript types](#using-typescript-types). Explicit types provide better IntelliSense, catch errors at compile time, and serve as documentation for what data the form contains.
### Initialize all fields
Provide initial values for every field in your model:
```ts
// Good: All fields initialized
const taskModel = signal({
title: '',
description: '',
priority: 'medium',
completed: false
})
```
```ts
// Avoid: Partial initialization
const taskModel = signal({
title: ''
// Missing description, priority, completed
})
```
Missing initial values mean those fields won't exist in the field tree, making them inaccessible for form interactions.
### Keep models focused
Each model should represent a single form or a cohesive set of related data:
```ts
// Good: Focused on login
const loginModel = signal({
email: '',
password: ''
})
```
```ts
// Avoid: Mixing unrelated concerns
const appModel = signal({
// Login data
email: '',
password: '',
// User preferences
theme: 'light',
language: 'en',
// Shopping cart
cartItems: []
})
```
Separate models for different concerns makes forms easier to understand and reuse. Create multiple forms if you're managing distinct sets of data.
### Consider validation requirements
Design models with validation in mind. Group fields that validate together:
```ts
// Good: Password fields grouped for comparison
interface PasswordChangeData {
currentPassword: string
newPassword: string
confirmPassword: string
}
```
This structure makes cross-field validation (like checking if `newPassword` matches `confirmPassword`) more natural.
### Plan for initial state
Consider whether your form starts empty or pre-populated:
```ts
// Form that starts empty (new user)
const newUserModel = signal({
name: '',
email: '',
});
// Form that loads existing data
const editUserModel = signal({
name: '',
email: '',
});
// Later, in ngOnInit:
ngOnInit() {
this.loadExistingUser();
}
async loadExistingUser() {
const user = await this.userService.getUser(this.userId);
this.editUserModel.set(user);
}
```
For forms that always start with existing data, you might wait to render the form until data loads in order to avoid a flash of empty fields.
<!-- TODO: UNCOMMENT WHEN THE GUIDES ARE AVAILABLE -->
<!-- ## Next steps
<docs-pill-row>
<docs-pill href="guide/forms/signal-forms/field-state-management" title="Field State Management" />
<docs-pill href="guide/forms/signal-forms/validation" title="Validation" />
<docs-pill href="guide/forms/signal-forms/arrays" title="Working with Arrays" />
</docs-pill-row> -->
@@ -0,0 +1,59 @@
<docs-decorative-header title="Forms with Angular Signals" imgSrc="adev/src/assets/images/signals.svg"> <!-- markdownlint-disable-line -->
</docs-decorative-header>
CRITICAL: Signal Forms are [experimental](/reference/releases#experimental). The API may change in future releases. Avoid using experimental APIs in production applications without understanding the risks.
Signal Forms is an experimental library that allows you to manage form state in Angular applications by building on the reactive foundation of signals. With automatic two-way binding, type-safe field access, and schema-based validation, Signal Forms help you create robust forms.
TIP: For a quick introduction to Signal Forms, see the [Signal Forms essentials guide](essentials/signal-forms).
## Why Signal Forms?
Building forms in web applications involves managing several interconnected concerns: tracking field values, validating user input, handling error states, and keeping the UI synchronized with your data model. Managing these concerns separately creates boilerplate code and complexity.
Signal Forms address these challenges by:
- **Synchronizing state automatically** - Automatically syncs the form data model with bound form fields
- **Providing type safety** - Supports fully type safe schemas & bindings between your UI controls and data model
- **Centralizing validation logic** - Define all validation rules in one place using a validation schema
Signal Forms work best in new applications built with signals. If you're working with an existing application that uses reactive forms, or if you need production stability guarantees, reactive forms remain a solid choice.
<!-- TODO: UNCOMMENT SECTION BELOW WHEN AVAILABLE -->
<!-- NOTE: If you're coming from template or reactive forms, you may be interested in our [comparison guide](guide/forms/signal-forms/comparison). -->
## Prerequisites
Signal Forms require:
- Angular v21 or higher
## Setup
Signal Forms are already included in the `@angular/forms` package. Import the necessary functions and directives from `@angular/forms/signals`:
```ts
import { form, Field, required, email } from '@angular/forms/signals'
```
The `Field` directive must be imported into any component that binds form fields to HTML inputs:
```ts
@Component({
// ...
imports: [Field],
})
```
<!-- TODO: UNCOMMENT SECTION BELOW WHEN AVAILABLE -->
<!-- ## Next steps
To learn more about how Signal Forms work, check out the following guides:
<docs-pill-row>
<docs-pill href="essentials/signal-forms" title="Signal forms essentials" />
<docs-pill href="guide/forms/signal-forms/models" title="Form models" />
<docs-pill href="guide/forms/signal-forms/field-state-management" title="Field state management" />
<docs-pill href="guide/forms/signal-forms/validation" title="Validation" />
<docs-pill href="guide/forms/signal-forms/custom-controls" title="Custom controls" />
</docs-pill-row> -->
@@ -2,22 +2,31 @@
Signal Forms is built on Angular signals to provide a reactive, type-safe way to manage form state.
</docs-decorative-header>
## How it works
Signal Forms manage form state using Angular signals to provide automatic synchronization between your data model and the UI.
### 1. Create a signal model
This guide walks you through the core concepts to create forms with Signal Forms. Here's how it works:
When you create a form, you start by creating a signal that holds the state of your form:
## Creating your first form
### 1. Create a form model
Every form starts by creating a signal that holds your form's data model:
```ts
const loginModel = signal({
interface LoginData {
email: string;
password: string;
}
const loginModel = signal<LoginData>({
email: '',
password: '',
});
```
### 2. Pass the data model to `form()`
### 2. Pass the form 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:
Then, you pass your form model into the `form()` function to create a **field tree** - an object structure that mirrors your model's shape, allowing you to access fields with dot notation:
```ts
form(loginModel);
@@ -46,11 +55,17 @@ loginForm.email().value.set('alice@wonderland.com');
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.
NOTE: The `[field]` directive also syncs field state for attributes like `required`, `disabled`, and `readonly` when appropriate.
### 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()`:
You can access field state by calling the field as a function. This returns a `FieldState` object containing reactive signals for the field's value, validation status, and interaction state:
```ts
loginForm.email() // Returns FieldState with value(), valid(), touched(), etc.
```
To read the field's current value, access the `value()` signal:
```html
<!-- Render form value that updates automatically as user types -->
@@ -64,10 +79,10 @@ 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 preview path="adev/src/content/examples/signal-forms/src/login-simple/app/app.ts">
<docs-code header="app.ts" path="adev/src/content/examples/signal-forms/src/login-simple/app/app.ts"/>
<docs-code header="app.html" path="adev/src/content/examples/signal-forms/src/login-simple/app/app.html"/>
<docs-code header="app.css" path="adev/src/content/examples/signal-forms/src/login-simple/app/app.css"/>
</docs-code-multifile>
## Basic usage
@@ -187,15 +202,17 @@ NOTE: Multiple select (`<select multiple>`) is not supported by the `[field]` di
## 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:
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 **FieldPath** parameter that allows you to reference the fields in your form model:
```ts
const loginForm = form(loginModel, (p) => {
required(p.email);
email(p.email);
const loginForm = form(loginModel, (fieldPath) => {
required(fieldPath.email);
email(fieldPath.email);
});
```
NOTE: FieldPath only mirrors the shape of your data and does not allow you to access value or any other state.
Common validators include:
- **`required()`** - Ensures the field has a value
@@ -211,19 +228,19 @@ 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.
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 preview path="adev/src/content/examples/signal-forms/src/login-validation/app/app.ts">
<docs-code header="app.ts" path="adev/src/content/examples/signal-forms/src/login-validation/app/app.ts"/>
<docs-code header="app.html" path="adev/src/content/examples/signal-forms/src/login-validation/app/app.html"/>
<docs-code header="app.css" path="adev/src/content/examples/signal-forms/src/login-validation/app/app.css"/>
</docs-code-multifile>
### Field State Signals
Each field provides these state signals:
Every `field()` provides these state signals:
| State | Description |
| ------------ | -------------------------------------------------------------------------- |
@@ -234,4 +251,4 @@ Each field provides these state signals:
| `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.
TIP: Show errors only after `field().touched()` is true to avoid displaying validation messages before the user has interacted with a field.
+15 -1
View File
@@ -484,6 +484,9 @@ importers:
'@angular/animations':
specifier: workspace:*
version: link:../packages/animations
'@angular/aria':
specifier: 21.0.0-rc.2
version: 21.0.0-rc.2(@angular/cdk@21.0.0-rc.2(@angular/common@packages+common)(@angular/core@packages+core)(rxjs@7.8.2))(@angular/core@packages+core)
'@angular/build':
specifier: 21.0.0-rc.3
version: 21.0.0-rc.3(9a9f6c7518c5024acf7d665b34b02242)
@@ -1661,6 +1664,12 @@ packages:
resolution: {integrity: sha512-4aorKS9E3FuDt6nSz/muAC0zr/y9GI5fIPwmcOGOsN5Z54HudOyyEFYDJ4edBiUIotM6aAEX7tvYUT6tXfO4vw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
'@angular/aria@21.0.0-rc.2':
resolution: {integrity: sha512-JXGSeiNfvONDjSll25CNtJG1LNrlNhlfMau474qh6zV94iOin3DlzKDIdul3osH9o9M/shaFss9IypeTzk+nLA==}
peerDependencies:
'@angular/cdk': 21.0.0-rc.2
'@angular/core': ^21.0.0-0 || ^21.1.0-0 || ^21.2.0-0 || ^21.3.0-0 || ^22.0.0-0
'@angular/build@21.0.0-rc.3':
resolution: {integrity: sha512-8BlRqloz/WE5zerRjSI+jn898zH6z/ogN4jpMIIKoeBDlcURdlmYEIjOcC85RWUEs62nlrfVVJ0PdYljsCQl2Q==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
@@ -11067,7 +11076,6 @@ packages:
engines: {node: '>=0.6.0', teleport: '>=0.2.0'}
deprecated: |-
You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.
(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)
qjobs@1.2.0:
@@ -13672,6 +13680,12 @@ snapshots:
transitivePeerDependencies:
- chokidar
'@angular/aria@21.0.0-rc.2(@angular/cdk@21.0.0-rc.2(@angular/common@packages+common)(@angular/core@packages+core)(rxjs@7.8.2))(@angular/core@packages+core)':
dependencies:
'@angular/cdk': 21.0.0-rc.2(@angular/common@packages+common)(@angular/core@packages+core)(rxjs@7.8.2)
'@angular/core': link:packages/core
tslib: 2.8.1
'@angular/build@21.0.0-rc.3(9a9f6c7518c5024acf7d665b34b02242)':
dependencies:
'@ampproject/remapping': 2.3.0