From 1464d02327fbfccf45cbf350cbce1618441eb989 Mon Sep 17 00:00:00 2001 From: Matthieu Riegler Date: Fri, 14 Nov 2025 02:33:55 +0200 Subject: [PATCH] docs(docs-infra): backport docs changes from `21.0.x` The PRs should have targeted main instead of targeting `21.0.x` explicitly --- adev/package.json | 1 + adev/src/app/routing/sub-navigation-data.ts | 59 +- adev/src/assets/BUILD.bazel | 1 + .../autocomplete/src/assets/autocomplete.css | 89 +++ .../src/basic/app/app.component.css | 89 +++ .../src/basic/app/app.component.html | 31 + .../src/basic/app/app.component.ts | 108 ++++ .../src/highlight/app/app.component.css | 89 +++ .../src/highlight/app/app.component.html | 31 + .../src/highlight/app/app.component.ts | 108 ++++ .../src/manual/app/app.component.css | 89 +++ .../src/manual/app/app.component.html | 31 + .../src/manual/app/app.component.ts | 108 ++++ .../aria/toolbar/src/basic/app/app.css | 47 ++ .../aria/toolbar/src/basic/app/app.html | 27 + .../aria/toolbar/src/basic/app/app.ts | 10 + .../aria/toolbar/src/disabled/app/app.css | 47 ++ .../aria/toolbar/src/disabled/app/app.html | 35 ++ .../aria/toolbar/src/disabled/app/app.ts | 10 + .../examples/aria/toolbar/src/rtl/app/app.css | 47 ++ .../aria/toolbar/src/rtl/app/app.html | 29 + .../examples/aria/toolbar/src/rtl/app/app.ts | 10 + .../aria/toolbar/src/vertical/app/app.css | 60 ++ .../aria/toolbar/src/vertical/app/app.html | 32 ++ .../aria/toolbar/src/vertical/app/app.ts | 10 + .../signal-forms/src/login-simple/app/app.css | 27 + .../src/login-simple/app/app.html | 14 + .../signal-forms/src/login-simple/app/app.ts | 22 + .../signal-forms/src/login-simple/main.ts | 2 +- .../src/login-validation/app/app.css | 70 +++ .../src/login-validation/app/app.html | 33 ++ .../src/login-validation/app/app.ts | 36 ++ .../signal-forms/src/login-validation/main.ts | 2 +- adev/src/content/guide/BUILD.bazel | 1 + adev/src/content/guide/aria/autocomplete.md | 140 +++++ adev/src/content/guide/aria/toolbar.md | 171 +++++- adev/src/content/guide/forms/overview.md | 3 + .../content/guide/forms/signals/BUILD.bazel | 22 + .../src/content/guide/forms/signals/models.md | 536 ++++++++++++++++++ .../content/guide/forms/signals/overview.md | 59 ++ .../introduction/essentials/signal-forms.md | 63 +- pnpm-lock.yaml | 16 +- 42 files changed, 2370 insertions(+), 45 deletions(-) create mode 100644 adev/src/content/examples/aria/autocomplete/src/assets/autocomplete.css create mode 100644 adev/src/content/examples/aria/autocomplete/src/basic/app/app.component.css create mode 100644 adev/src/content/examples/aria/autocomplete/src/basic/app/app.component.html create mode 100644 adev/src/content/examples/aria/autocomplete/src/basic/app/app.component.ts create mode 100644 adev/src/content/examples/aria/autocomplete/src/highlight/app/app.component.css create mode 100644 adev/src/content/examples/aria/autocomplete/src/highlight/app/app.component.html create mode 100644 adev/src/content/examples/aria/autocomplete/src/highlight/app/app.component.ts create mode 100644 adev/src/content/examples/aria/autocomplete/src/manual/app/app.component.css create mode 100644 adev/src/content/examples/aria/autocomplete/src/manual/app/app.component.html create mode 100644 adev/src/content/examples/aria/autocomplete/src/manual/app/app.component.ts create mode 100644 adev/src/content/examples/aria/toolbar/src/basic/app/app.css create mode 100644 adev/src/content/examples/aria/toolbar/src/basic/app/app.html create mode 100644 adev/src/content/examples/aria/toolbar/src/basic/app/app.ts create mode 100644 adev/src/content/examples/aria/toolbar/src/disabled/app/app.css create mode 100644 adev/src/content/examples/aria/toolbar/src/disabled/app/app.html create mode 100644 adev/src/content/examples/aria/toolbar/src/disabled/app/app.ts create mode 100644 adev/src/content/examples/aria/toolbar/src/rtl/app/app.css create mode 100644 adev/src/content/examples/aria/toolbar/src/rtl/app/app.html create mode 100644 adev/src/content/examples/aria/toolbar/src/rtl/app/app.ts create mode 100644 adev/src/content/examples/aria/toolbar/src/vertical/app/app.css create mode 100644 adev/src/content/examples/aria/toolbar/src/vertical/app/app.html create mode 100644 adev/src/content/examples/aria/toolbar/src/vertical/app/app.ts create mode 100644 adev/src/content/examples/signal-forms/src/login-simple/app/app.css create mode 100644 adev/src/content/examples/signal-forms/src/login-simple/app/app.html create mode 100644 adev/src/content/examples/signal-forms/src/login-simple/app/app.ts create mode 100644 adev/src/content/examples/signal-forms/src/login-validation/app/app.css create mode 100644 adev/src/content/examples/signal-forms/src/login-validation/app/app.html create mode 100644 adev/src/content/examples/signal-forms/src/login-validation/app/app.ts create mode 100644 adev/src/content/guide/aria/autocomplete.md create mode 100644 adev/src/content/guide/forms/signals/BUILD.bazel create mode 100644 adev/src/content/guide/forms/signals/models.md create mode 100644 adev/src/content/guide/forms/signals/overview.md diff --git a/adev/package.json b/adev/package.json index 39ba09a4bd9..2f124e16eb6 100644 --- a/adev/package.json +++ b/adev/package.json @@ -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", diff --git a/adev/src/app/routing/sub-navigation-data.ts b/adev/src/app/routing/sub-navigation-data.ts index ac2053ae56d..eb3e49a5c3d 100644 --- a/adev/src/app/routing/sub-navigation-data.ts +++ b/adev/src/app/routing/sub-navigation-data.ts @@ -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', + // }, + // ], + // }, + ]), ], }, { diff --git a/adev/src/assets/BUILD.bazel b/adev/src/assets/BUILD.bazel index 62bc49b9c62..c5754137afc 100644 --- a/adev/src/assets/BUILD.bazel +++ b/adev/src/assets/BUILD.bazel @@ -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", diff --git a/adev/src/content/examples/aria/autocomplete/src/assets/autocomplete.css b/adev/src/content/examples/aria/autocomplete/src/assets/autocomplete.css new file mode 100644 index 00000000000..45e65487e77 --- /dev/null +++ b/adev/src/content/examples/aria/autocomplete/src/assets/autocomplete.css @@ -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; +} diff --git a/adev/src/content/examples/aria/autocomplete/src/basic/app/app.component.css b/adev/src/content/examples/aria/autocomplete/src/basic/app/app.component.css new file mode 100644 index 00000000000..45e65487e77 --- /dev/null +++ b/adev/src/content/examples/aria/autocomplete/src/basic/app/app.component.css @@ -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; +} diff --git a/adev/src/content/examples/aria/autocomplete/src/basic/app/app.component.html b/adev/src/content/examples/aria/autocomplete/src/basic/app/app.component.html new file mode 100644 index 00000000000..ab53201aed6 --- /dev/null +++ b/adev/src/content/examples/aria/autocomplete/src/basic/app/app.component.html @@ -0,0 +1,31 @@ +
+ +
+ +
+ +
+ +
+ @for (option of options(); track option) { +
+ {{ option }} +
+ } +
+
+
+
+ +
+

Auto-select mode: The input updates automatically as you type to match the first option.

+ @if (searchString()) { +

Current value: {{ searchString() }}

+ } +
diff --git a/adev/src/content/examples/aria/autocomplete/src/basic/app/app.component.ts b/adev/src/content/examples/aria/autocomplete/src/basic/app/app.component.ts new file mode 100644 index 00000000000..b24cd4477dc --- /dev/null +++ b/adev/src/content/examples/aria/autocomplete/src/basic/app/app.component.ts @@ -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('popover'); + listbox = viewChild>(Listbox); + combobox = viewChild>(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', +]; diff --git a/adev/src/content/examples/aria/autocomplete/src/highlight/app/app.component.css b/adev/src/content/examples/aria/autocomplete/src/highlight/app/app.component.css new file mode 100644 index 00000000000..45e65487e77 --- /dev/null +++ b/adev/src/content/examples/aria/autocomplete/src/highlight/app/app.component.css @@ -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; +} diff --git a/adev/src/content/examples/aria/autocomplete/src/highlight/app/app.component.html b/adev/src/content/examples/aria/autocomplete/src/highlight/app/app.component.html new file mode 100644 index 00000000000..3e040ce0f57 --- /dev/null +++ b/adev/src/content/examples/aria/autocomplete/src/highlight/app/app.component.html @@ -0,0 +1,31 @@ +
+ +
+ +
+ +
+ +
+ @for (option of options(); track option) { +
+ {{ option }} +
+ } +
+
+
+
+ +
+

Highlight mode: Options are highlighted as you navigate with arrow keys, but the input only updates when you press Enter or click.

+ @if (searchString()) { +

Current value: {{ searchString() }}

+ } +
diff --git a/adev/src/content/examples/aria/autocomplete/src/highlight/app/app.component.ts b/adev/src/content/examples/aria/autocomplete/src/highlight/app/app.component.ts new file mode 100644 index 00000000000..b24cd4477dc --- /dev/null +++ b/adev/src/content/examples/aria/autocomplete/src/highlight/app/app.component.ts @@ -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('popover'); + listbox = viewChild>(Listbox); + combobox = viewChild>(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', +]; diff --git a/adev/src/content/examples/aria/autocomplete/src/manual/app/app.component.css b/adev/src/content/examples/aria/autocomplete/src/manual/app/app.component.css new file mode 100644 index 00000000000..45e65487e77 --- /dev/null +++ b/adev/src/content/examples/aria/autocomplete/src/manual/app/app.component.css @@ -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; +} diff --git a/adev/src/content/examples/aria/autocomplete/src/manual/app/app.component.html b/adev/src/content/examples/aria/autocomplete/src/manual/app/app.component.html new file mode 100644 index 00000000000..a3320dfa1b1 --- /dev/null +++ b/adev/src/content/examples/aria/autocomplete/src/manual/app/app.component.html @@ -0,0 +1,31 @@ +
+ +
+ +
+ +
+ +
+ @for (option of options(); track option) { +
+ {{ option }} +
+ } +
+
+
+
+ +
+

Manual mode: The input only updates when you explicitly select an option with Enter or click.

+ @if (searchString()) { +

Current value: {{ searchString() }}

+ } +
diff --git a/adev/src/content/examples/aria/autocomplete/src/manual/app/app.component.ts b/adev/src/content/examples/aria/autocomplete/src/manual/app/app.component.ts new file mode 100644 index 00000000000..b24cd4477dc --- /dev/null +++ b/adev/src/content/examples/aria/autocomplete/src/manual/app/app.component.ts @@ -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('popover'); + listbox = viewChild>(Listbox); + combobox = viewChild>(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', +]; diff --git a/adev/src/content/examples/aria/toolbar/src/basic/app/app.css b/adev/src/content/examples/aria/toolbar/src/basic/app/app.css new file mode 100644 index 00000000000..79bc2d25c2e --- /dev/null +++ b/adev/src/content/examples/aria/toolbar/src/basic/app/app.css @@ -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; +} diff --git a/adev/src/content/examples/aria/toolbar/src/basic/app/app.html b/adev/src/content/examples/aria/toolbar/src/basic/app/app.html new file mode 100644 index 00000000000..b65a7d370c1 --- /dev/null +++ b/adev/src/content/examples/aria/toolbar/src/basic/app/app.html @@ -0,0 +1,27 @@ +
+
+ + +
+ + + +
+ + + +
+ + + +
+ + + +
+
diff --git a/adev/src/content/examples/aria/toolbar/src/basic/app/app.ts b/adev/src/content/examples/aria/toolbar/src/basic/app/app.ts new file mode 100644 index 00000000000..b53e249a983 --- /dev/null +++ b/adev/src/content/examples/aria/toolbar/src/basic/app/app.ts @@ -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 {} diff --git a/adev/src/content/examples/aria/toolbar/src/disabled/app/app.css b/adev/src/content/examples/aria/toolbar/src/disabled/app/app.css new file mode 100644 index 00000000000..79bc2d25c2e --- /dev/null +++ b/adev/src/content/examples/aria/toolbar/src/disabled/app/app.css @@ -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; +} diff --git a/adev/src/content/examples/aria/toolbar/src/disabled/app/app.html b/adev/src/content/examples/aria/toolbar/src/disabled/app/app.html new file mode 100644 index 00000000000..2422eedffa5 --- /dev/null +++ b/adev/src/content/examples/aria/toolbar/src/disabled/app/app.html @@ -0,0 +1,35 @@ +
+ + + + + +
+ + + +
+ + + +
+ + +
+
diff --git a/adev/src/content/examples/aria/toolbar/src/disabled/app/app.ts b/adev/src/content/examples/aria/toolbar/src/disabled/app/app.ts new file mode 100644 index 00000000000..b53e249a983 --- /dev/null +++ b/adev/src/content/examples/aria/toolbar/src/disabled/app/app.ts @@ -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 {} diff --git a/adev/src/content/examples/aria/toolbar/src/rtl/app/app.css b/adev/src/content/examples/aria/toolbar/src/rtl/app/app.css new file mode 100644 index 00000000000..79bc2d25c2e --- /dev/null +++ b/adev/src/content/examples/aria/toolbar/src/rtl/app/app.css @@ -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; +} diff --git a/adev/src/content/examples/aria/toolbar/src/rtl/app/app.html b/adev/src/content/examples/aria/toolbar/src/rtl/app/app.html new file mode 100644 index 00000000000..5a151c305aa --- /dev/null +++ b/adev/src/content/examples/aria/toolbar/src/rtl/app/app.html @@ -0,0 +1,29 @@ +
+
+
+ + +
+ + + +
+ + + +
+ + + +
+ + + +
+
+
diff --git a/adev/src/content/examples/aria/toolbar/src/rtl/app/app.ts b/adev/src/content/examples/aria/toolbar/src/rtl/app/app.ts new file mode 100644 index 00000000000..b53e249a983 --- /dev/null +++ b/adev/src/content/examples/aria/toolbar/src/rtl/app/app.ts @@ -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 {} diff --git a/adev/src/content/examples/aria/toolbar/src/vertical/app/app.css b/adev/src/content/examples/aria/toolbar/src/vertical/app/app.css new file mode 100644 index 00000000000..a4de916be4e --- /dev/null +++ b/adev/src/content/examples/aria/toolbar/src/vertical/app/app.css @@ -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; +} diff --git a/adev/src/content/examples/aria/toolbar/src/vertical/app/app.html b/adev/src/content/examples/aria/toolbar/src/vertical/app/app.html new file mode 100644 index 00000000000..84dd3a50cfd --- /dev/null +++ b/adev/src/content/examples/aria/toolbar/src/vertical/app/app.html @@ -0,0 +1,32 @@ +
+
+ + +
+ + + +
+ + + +
+ + + +
+ + + +
+
diff --git a/adev/src/content/examples/aria/toolbar/src/vertical/app/app.ts b/adev/src/content/examples/aria/toolbar/src/vertical/app/app.ts new file mode 100644 index 00000000000..b53e249a983 --- /dev/null +++ b/adev/src/content/examples/aria/toolbar/src/vertical/app/app.ts @@ -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 {} diff --git a/adev/src/content/examples/signal-forms/src/login-simple/app/app.css b/adev/src/content/examples/signal-forms/src/login-simple/app/app.css new file mode 100644 index 00000000000..effcb966291 --- /dev/null +++ b/adev/src/content/examples/signal-forms/src/login-simple/app/app.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.html b/adev/src/content/examples/signal-forms/src/login-simple/app/app.html new file mode 100644 index 00000000000..7c0aeb275f7 --- /dev/null +++ b/adev/src/content/examples/signal-forms/src/login-simple/app/app.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.ts b/adev/src/content/examples/signal-forms/src/login-simple/app/app.ts new file mode 100644 index 00000000000..2dcb2f41a0b --- /dev/null +++ b/adev/src/content/examples/signal-forms/src/login-simple/app/app.ts @@ -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({ + email: '', + password: '', + }); + + loginForm = form(this.loginModel); +} 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 index 8f970447f48..917e1f40300 100644 --- a/adev/src/content/examples/signal-forms/src/login-simple/main.ts +++ b/adev/src/content/examples/signal-forms/src/login-simple/main.ts @@ -1,5 +1,5 @@ import {bootstrapApplication} from '@angular/platform-browser'; -import {App} from './app/app.component'; +import {App} from './app/app'; bootstrapApplication(App); diff --git a/adev/src/content/examples/signal-forms/src/login-validation/app/app.css b/adev/src/content/examples/signal-forms/src/login-validation/app/app.css new file mode 100644 index 00000000000..2b9a3100a69 --- /dev/null +++ b/adev/src/content/examples/signal-forms/src/login-validation/app/app.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.html b/adev/src/content/examples/signal-forms/src/login-validation/app/app.html new file mode 100644 index 00000000000..73ec4635295 --- /dev/null +++ b/adev/src/content/examples/signal-forms/src/login-validation/app/app.html @@ -0,0 +1,33 @@ +
+
+ + + @if (loginForm.email().touched() && loginForm.email().invalid()) { +
    + @for (error of loginForm.email().errors(); track $index) { +
  • {{ error.message }}
  • + } +
+ } +
+ +
+ + + @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.ts b/adev/src/content/examples/signal-forms/src/login-validation/app/app.ts new file mode 100644 index 00000000000..9192122fa30 --- /dev/null +++ b/adev/src/content/examples/signal-forms/src/login-validation/app/app.ts @@ -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({ + 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/main.ts b/adev/src/content/examples/signal-forms/src/login-validation/main.ts index 8f970447f48..917e1f40300 100644 --- a/adev/src/content/examples/signal-forms/src/login-validation/main.ts +++ b/adev/src/content/examples/signal-forms/src/login-validation/main.ts @@ -1,5 +1,5 @@ import {bootstrapApplication} from '@angular/platform-browser'; -import {App} from './app/app.component'; +import {App} from './app/app'; bootstrapApplication(App); diff --git a/adev/src/content/guide/BUILD.bazel b/adev/src/content/guide/BUILD.bazel index 9fb825eb900..baaba0422f6 100644 --- a/adev/src/content/guide/BUILD.bazel +++ b/adev/src/content/guide/BUILD.bazel @@ -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", diff --git a/adev/src/content/guide/aria/autocomplete.md b/adev/src/content/guide/aria/autocomplete.md new file mode 100644 index 00000000000..547d39d65e3 --- /dev/null +++ b/adev/src/content/guide/aria/autocomplete.md @@ -0,0 +1,140 @@ + + An accessible input field that filters and suggests options as users type, helping them find and select values from a list. + + + + + + + + +## 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. + + + + + + +### 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. + + + + + +### 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. + + + + + +## 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` | 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 `` 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. diff --git a/adev/src/content/guide/aria/toolbar.md b/adev/src/content/guide/aria/toolbar.md index c6649fe2c97..a06ef7d7a2a 100644 --- a/adev/src/content/guide/aria/toolbar.md +++ b/adev/src/content/guide/aria/toolbar.md @@ -1,22 +1,167 @@ - - - - - +## Overview - +## Usage -### Example with TailwindCSS +Toolbar works best for grouping related controls that users access frequently. Consider using toolbar when: - +- **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. + + + + + + + +### 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. + + + + + + + +### 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: + + + +
+ + + +
+ + +
+ + + +
+
+ +### 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. + + + + + +### 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. + + + + + +## 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` | Whether the widget is currently focused | +| `selected` | `Signal` | 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. diff --git a/adev/src/content/guide/forms/overview.md b/adev/src/content/guide/forms/overview.md index 58757a2f17f..fa83c37163f 100644 --- a/adev/src/content/guide/forms/overview.md +++ b/adev/src/content/guide/forms/overview.md @@ -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. diff --git a/adev/src/content/guide/forms/signals/BUILD.bazel b/adev/src/content/guide/forms/signals/BUILD.bazel new file mode 100644 index 00000000000..10dda716398 --- /dev/null +++ b/adev/src/content/guide/forms/signals/BUILD.bazel @@ -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__"], +) diff --git a/adev/src/content/guide/forms/signals/models.md b/adev/src/content/guide/forms/signals/models.md new file mode 100644 index 00000000000..f0f2fc4e677 --- /dev/null +++ b/adev/src/content/guide/forms/signals/models.md @@ -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: ` + + + ` +}) +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({ + email: '', + password: '' + }) + + loginForm = form(this.loginModel) +} +``` + +With explicit types, the field tree provides full type safety. Accessing `loginForm.email` is typed as `FieldTree`, and attempting to access a non-existent property results in a compile-time error. + +```ts +// TypeScript knows this is FieldTree +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({ + 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: ` +

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

+

Password length: {{ passwordLength() }}

+ ` +}) +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). + + + + +## 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: ` + + +

Current name: {{ userModel().name }}

+ ` +}) +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 +userForm.settings.theme // FieldTree +``` + +In templates, you bind nested fields the same way as top-level fields: + +```ts +@Component({ + template: ` + + + + + `, +}) +``` + +### 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 +orderForm.items[0].quantity // FieldTree +``` + +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. + + + +## 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. + + + diff --git a/adev/src/content/guide/forms/signals/overview.md b/adev/src/content/guide/forms/signals/overview.md new file mode 100644 index 00000000000..fea36bd132c --- /dev/null +++ b/adev/src/content/guide/forms/signals/overview.md @@ -0,0 +1,59 @@ + + + +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. + + + + +## 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], +}) +``` + + + diff --git a/adev/src/content/introduction/essentials/signal-forms.md b/adev/src/content/introduction/essentials/signal-forms.md index f74089036af..3bf1b4b5e36 100644 --- a/adev/src/content/introduction/essentials/signal-forms.md +++ b/adev/src/content/introduction/essentials/signal-forms.md @@ -2,22 +2,31 @@ Signal Forms is built on Angular signals to provide a reactive, type-safe way to manage form state. -## 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({ 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 @@ -64,10 +79,10 @@ const currentEmail = loginForm.email().value(); Here's a complete example: - - - - + + + + ## Basic usage @@ -187,15 +202,17 @@ NOTE: Multiple select (`