Skip to content

Commit 054eaa2

Browse files
committed
feat: add part4
1 parent 09caec8 commit 054eaa2

File tree

8 files changed

+345
-4
lines changed

8 files changed

+345
-4
lines changed

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77
This is the demo application for our blog series about **Angular Signal Forms**:
88

99
- [Angular Signal Forms Part 1: Getting Started with the Basics](https://angular-buch.com/blog/2025-10-signal-forms-part1)
10-
- *Parts 2 and 3 will be published soon!*
11-
10+
- [Angular Signal Forms Part 2: Advanced Validation and Schema Patterns](https://angular-buch.com/blog/2025-10-signal-forms-part2)
11+
- [Angular Signal Forms Part 3: Child Forms, Custom UI Controls and SignalFormsConfig](https://angular-buch.com/blog/2025-10-signal-forms-part3)
12+
- [Angular Signal Forms Part 4: MetaData and Accessibility Handling](https://angular-buch.com/blog/2025-12-signal-forms-part4)
1213

1314
You can try the demo application on StackBlitz or as a live demo on GitHub Pages:
1415

src/app/app.routes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import { Home } from './home/home';
33
import { RegistrationForm1 } from './registration-form-1/registration-form-1';
44
import { RegistrationForm2 } from './registration-form-2/registration-form-2';
55
import { RegistrationForm3 } from './registration-form-3/registration-form-3';
6+
import { RegistrationForm4 } from './registration-form-4/registration-form-4';
67

78
export const routes: Routes = [
89
{ path: '', component: Home, title: 'Angular Signal Forms Demo' },
910
{ path: 'version-1', component: RegistrationForm1, title: 'Angular Signal Forms Demo | 1st version' },
1011
{ path: 'version-2', component: RegistrationForm2, title: 'Angular Signal Forms Demo | 2nd version' },
1112
{ path: 'version-3', component: RegistrationForm3, title: 'Angular Signal Forms Demo | 3rd version' },
13+
{ path: 'version-4', component: RegistrationForm4, title: 'Angular Signal Forms Demo | 4th version' },
1214
{ path: '**', redirectTo: '' },
1315
];

src/app/home/home.html

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ <h1>Angular Signal Forms Demo</h1>
1111
<ul>
1212
<li><a routerLink="version-1">Version 1: Getting Started with the Basics</a></li>
1313
<li><a routerLink="version-2">Version 2: Advanced Validation and Schema Patterns</a></li>
14-
<li><a routerLink="version-3">Version 3: Child Forms and Custom UI Controls</a></li>
14+
<li><a routerLink="version-3">Version 3: Child Forms, Custom UI Controls and SignalFormsConfig</a></li>
15+
<li><a routerLink="version-4">Version 4: MetaData and Accessibility Handling</a></li>
1516
</ul>
1617
</nav>
1718
</aside>
@@ -22,6 +23,7 @@ <h1>Angular Signal Forms Demo</h1>
2223
<ul>
2324
<li><a href="version-1-version-2.html" target="_blank">Diff: Version 1 - Version 2</a></li>
2425
<li><a href="version-2-version-3.html" target="_blank">Diff: Version 2 - Version 3</a></li>
26+
<li><a href="version-3-version-4.html" target="_blank">Diff: Version 3 - Version 4</a></li>
2527
</ul>
2628

2729
</footer>

src/app/registration-form-3/registration-form-3.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<app-back-button />
2-
<h1>Version 3: Child Forms and Custom UI Controls</h1>
2+
<h1>Version 3: Child Forms, Custom UI Controls and SignalFormsConfig</h1>
33

44
<p>
55
<mark>Note:</mark> User "johndoe" already exists. Use this user to simulate async validation
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<app-back-button />
2+
<h1>Version 4: MetaData and Accessibility Handling</h1>
3+
4+
<p>
5+
<mark>Note:</mark> User "johndoe" already exists. Use this user to simulate async validation
6+
error.
7+
</p>
8+
9+
<form (submit)="submitForm()" novalidate>
10+
<label
11+
>Username
12+
<input
13+
type="text"
14+
id="myField"
15+
fieldDescriptionId="username-info"
16+
[field]="registrationForm.username"
17+
descriptionId="username"
18+
/>
19+
<app-form-field-info id="username-info" [fieldRef]="registrationForm.username" />
20+
</label>
21+
22+
<!-- A whole child form with own model -->
23+
<app-identity-form [identity]="registrationForm.identity" />
24+
25+
<!-- native HTML inputs bound with the [field] directive -->
26+
<div>
27+
<label
28+
>Age
29+
<input
30+
type="number"
31+
fieldDescriptionId="age-info"
32+
[field]="registrationForm.age"
33+
/>
34+
<app-form-field-info id="age-info" [fieldRef]="registrationForm.age" />
35+
</label>
36+
</div>
37+
38+
<div>
39+
<label
40+
>Password
41+
<input
42+
type="password"
43+
autocomplete
44+
fieldDescriptionId="pw1-info"
45+
[field]="registrationForm.password.pw1"
46+
/>
47+
<app-form-field-info id="pw-info pw1-info" [fieldRef]="registrationForm.password.pw1" />
48+
</label>
49+
<label
50+
>Password Confirmation
51+
<input
52+
type="password"
53+
autocomplete
54+
fieldDescriptionId="pw2-info"
55+
[field]="registrationForm.password.pw2"
56+
/>
57+
<app-form-field-info id="pw-info pw2-info" [fieldRef]="registrationForm.password.pw2" />
58+
</label>
59+
<app-form-field-info id="pw-info" [fieldRef]="registrationForm.password" />
60+
</div>
61+
<fieldset>
62+
<legend>
63+
E-Mail Addresses
64+
<button type="button" (click)="addEmail()">+</button>
65+
</legend>
66+
<div>
67+
@for (emailField of registrationForm.email; track $index) {
68+
<div>
69+
<div role="group">
70+
<input
71+
type="email"
72+
[fieldDescriptionId]="`email-info email${$index}-info`"
73+
[field]="emailField"
74+
[aria-label]="'E-Mail ' + $index"
75+
/>
76+
<button type="button" (click)="removeEmail($index)">-</button>
77+
</div>
78+
<app-form-field-info [id]="`email-info email${$index}-info`" fieldDescriptionId="pw1-info" [fieldRef]="emailField" />
79+
</div>
80+
}
81+
</div>
82+
<app-form-field-info id="email-info" [fieldRef]="registrationForm.email" />
83+
</fieldset>
84+
<label
85+
>Subscribe to Newsletter?
86+
<input type="checkbox" [field]="registrationForm.newsletter" />
87+
</label>
88+
<app-multiselect
89+
ariaDescribedby="newsletter-topics-info"
90+
[field]="registrationForm.newsletterTopics"
91+
[selectOptions]="['Angular', 'React', 'Vue', 'Svelte']"
92+
label="Topics (multiple possible):"
93+
/>
94+
<app-form-field-info id="newsletter-topics-info" [fieldRef]="registrationForm.newsletterTopics" />
95+
<label
96+
>I agree to the terms and conditions
97+
<input
98+
type="checkbox"
99+
fieldDescriptionId="agree-info"
100+
[field]="registrationForm.agreeToTermsAndConditions"
101+
/>
102+
</label>
103+
<app-form-field-info id="agree-info" [fieldRef]="registrationForm.agreeToTermsAndConditions" />
104+
<hr />
105+
<app-form-field-info [fieldRef]="registrationForm" />
106+
<div role="group">
107+
<button
108+
type="submit"
109+
[disabled]="registrationForm().submitting()"
110+
[aria-busy]="registrationForm().submitting()"
111+
>
112+
@if (registrationForm().submitting()) {
113+
Registering ...
114+
} @else {
115+
Register
116+
}
117+
</button>
118+
<button type="reset" (click)="resetForm()">Reset</button>
119+
</div>
120+
</form>
121+
122+
<app-debug-output [form]="registrationForm"/>

src/app/registration-form-4/registration-form-4.scss

Whitespace-only changes.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { ComponentFixture, TestBed } from '@angular/core/testing';
2+
3+
import { RegistrationForm4 } from './registration-form-4';
4+
5+
describe('RegistrationForm4', () => {
6+
let component: RegistrationForm4;
7+
let fixture: ComponentFixture<RegistrationForm4>;
8+
9+
beforeEach(async () => {
10+
await TestBed.configureTestingModule({
11+
imports: [RegistrationForm4],
12+
}).compileComponents();
13+
14+
fixture = TestBed.createComponent(RegistrationForm4);
15+
component = fixture.componentInstance;
16+
fixture.detectChanges();
17+
});
18+
19+
it('should create', () => {
20+
expect(component).toBeTruthy();
21+
});
22+
});
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { Component, inject, resource, signal } from '@angular/core';
2+
import { apply, applyEach, applyWhen, debounce, disabled, email, Field, FieldTree, form, maxLength, metadata, min, minLength, pattern, required, schema, submit, validate, validateAsync, validateTree, ValidationError, WithField } from '@angular/forms/signals';
3+
4+
import { BackButton } from '../back-button/back-button';
5+
import { DebugOutput } from '../debug-output/debug-output';
6+
import { FormFieldInfo } from '../form-field-info/form-field-info';
7+
import { FIELD_INFO } from '../form-props';
8+
import { FieldAriaAttributes } from '../field-aria-attributes';
9+
import { GenderIdentity, IdentityForm, identitySchema, initialGenderIdentityState } from '../identity-form/identity-form';
10+
import { Multiselect } from '../multiselect/multiselect';
11+
import { RegistrationService } from '../registration-service';
12+
13+
export interface RegisterFormData {
14+
username: string;
15+
identity: GenderIdentity;
16+
age: number;
17+
password: { pw1: string; pw2: string };
18+
email: string[];
19+
newsletter: boolean;
20+
newsletterTopics: string[];
21+
agreeToTermsAndConditions: boolean;
22+
}
23+
24+
const initialState: RegisterFormData = {
25+
username: '',
26+
identity: initialGenderIdentityState,
27+
age: 18,
28+
password: { pw1: '', pw2: '' },
29+
email: [''],
30+
newsletter: true,
31+
newsletterTopics: ['Angular'],
32+
agreeToTermsAndConditions: false,
33+
};
34+
35+
export const formSchema = schema<RegisterFormData>((schemaPath) => {
36+
// Username validation
37+
required(schemaPath.username, { message: 'Username is required' });
38+
minLength(schemaPath.username, 3, { message: 'A username must be at least 3 characters long' });
39+
maxLength(schemaPath.username, 12, { message: 'A username can be max. 12 characters long' });
40+
debounce(schemaPath.username, 500);
41+
validateAsync(schemaPath.username, {
42+
// Reactive params
43+
params: (ctx) => ctx.value(),
44+
// Factory creating a resource
45+
factory: (params) => {
46+
const registrationService = inject(RegistrationService);
47+
return resource({
48+
params,
49+
loader: async ({ params }) => {
50+
return await registrationService.checkUserExists(params);
51+
},
52+
});
53+
},
54+
// Maps resource to error
55+
onSuccess: (result) => {
56+
return result
57+
? {
58+
kind: 'userExists',
59+
message: 'The username you entered was already taken',
60+
}
61+
: undefined;
62+
},
63+
onError: () => undefined
64+
});
65+
metadata(schemaPath.username, FIELD_INFO, () => "A username must consists of 3-12 characters.")
66+
67+
// Age validation
68+
min(schemaPath.age, 18, { message: 'You must be >=18 years old.' });
69+
70+
// Terms and conditions
71+
required(schemaPath.agreeToTermsAndConditions, {
72+
message: 'You must agree to the terms and conditions.',
73+
});
74+
75+
// E-Mail validation
76+
validate(schemaPath.email, (ctx) =>
77+
!ctx.value().some((e) => e)
78+
? {
79+
kind: 'atLeastOneEmail',
80+
message: 'At least one E-Mail address must be added',
81+
}
82+
: undefined
83+
);
84+
applyEach(schemaPath.email, (emailPath) => {
85+
email(emailPath, { message: 'E-Mail format is invalid' });
86+
});
87+
metadata(schemaPath.email, FIELD_INFO, () => "Please enter at least one valid E-Mail address")
88+
89+
// Password validation
90+
required(schemaPath.password.pw1, { message: 'A password is required' });
91+
required(schemaPath.password.pw2, {
92+
message: 'A password confirmation is required',
93+
});
94+
minLength(schemaPath.password.pw1, 8, {
95+
message: 'A password must be at least 8 characters long',
96+
});
97+
pattern(
98+
schemaPath.password.pw1,
99+
new RegExp('^.*[!@#$%^&*(),.?":{}|<>\\[\\]\\\\/~`_+=;\'\\-].*$'),
100+
{ message: 'The passwort must contain at least one special character' }
101+
);
102+
validateTree(schemaPath.password, (ctx) => {
103+
return ctx.value().pw2 === ctx.value().pw1
104+
? undefined
105+
: {
106+
field: ctx.field.pw2, // assign the error to the second password field
107+
kind: 'confirmationPassword',
108+
message: 'The entered password must match with the one specified in "Password" field',
109+
};
110+
});
111+
metadata(schemaPath.password, FIELD_INFO, () => "Please enter a password with min 8 characters and a special character.")
112+
113+
// Newsletter validation
114+
applyWhen(
115+
schemaPath,
116+
(ctx) => ctx.value().newsletter,
117+
(schemaPathWhenTrue) => {
118+
validate(schemaPathWhenTrue.newsletterTopics, (ctx) =>
119+
!ctx.value().length
120+
? {
121+
kind: 'noTopicSelected',
122+
message: 'Select at least one newsletter topic',
123+
}
124+
: undefined
125+
);
126+
}
127+
);
128+
129+
// Disable newsletter topics when newsletter is unchecked
130+
disabled(schemaPath.newsletterTopics, (ctx) => !ctx.valueOf(schemaPath.newsletter));
131+
132+
// apply child schema for identity checks
133+
apply(schemaPath.identity, identitySchema);
134+
});
135+
136+
@Component({
137+
selector: 'app-registration-form-4',
138+
imports: [BackButton, Field, DebugOutput, FormFieldInfo, IdentityForm, Multiselect, FieldAriaAttributes],
139+
templateUrl: './registration-form-4.html',
140+
styleUrl: './registration-form-4.scss',
141+
// Also possible: set SignalFormsConfig only for local component:
142+
// providers: [
143+
// provideSignalFormsConfig(signalFormsConfig)
144+
// ]
145+
})
146+
export class RegistrationForm4 {
147+
readonly #registrationService = inject(RegistrationService);
148+
protected readonly registrationModel = signal<RegisterFormData>(initialState);
149+
150+
protected readonly registrationForm = form(this.registrationModel, formSchema);
151+
152+
protected addEmail(): void {
153+
this.registrationForm.email().value.update((items) => [...items, '']);
154+
}
155+
156+
protected removeEmail(removeIndex: number): void {
157+
this.registrationForm
158+
.email()
159+
.value.update((items) => items.filter((_, index) => index !== removeIndex));
160+
}
161+
162+
protected submitForm() {
163+
// validate when submitting and assign possible errors for matching field for showing in the UI
164+
submit(this.registrationForm, async (form) => {
165+
const errors: WithField<ValidationError>[] = [];
166+
167+
try {
168+
await this.#registrationService.registerUser(form().value);
169+
setTimeout(() => this.resetForm(), 3000);
170+
} catch (e) {
171+
errors.push(
172+
{
173+
field: form,
174+
kind: 'serverError',
175+
message: 'There was an server error, please try again (should work after 3rd try)',
176+
}
177+
);
178+
}
179+
180+
return errors;
181+
});
182+
183+
// Prevent reloading (default browser behavior)
184+
return false;
185+
}
186+
187+
// Reset form
188+
protected resetForm() {
189+
this.registrationModel.set(initialState);
190+
this.registrationForm().reset();
191+
}
192+
}

0 commit comments

Comments
 (0)