Skip to content

Commit 8faba6a

Browse files
feat(form-field): integrate FormField host directive and improve erro… (#15)
* feat(form-field): integrate FormField host directive and improve error handling - Import FormField from @angular/forms/signals in field-aria-attributes - Add FormField as hostDirective with formField input binding - Remove hardcoded fieldDescriptionId prop from form-field-info component - Comment out FormField import in registration-form-4 component - Add onInvalid callback to focus first error field on form validation failure - Improves accessibility by automatically focusing invalid fields for better UX * refactor: use directive as separate [formFieldAria] * update diffs * fix: format
1 parent 968b07f commit 8faba6a

6 files changed

Lines changed: 915 additions & 6 deletions

File tree

src/app/field-aria-attributes.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { computed, Directive, input } from '@angular/core';
22
import { FieldTree } from '@angular/forms/signals';
33

44
@Directive({
5-
selector: '[formField]',
5+
selector: '[formFieldAria]',
66
host: {
77
'[aria-invalid]': 'ariaInvalid()',
88
'[aria-busy]': 'ariaBusy()',
@@ -11,15 +11,15 @@ import { FieldTree } from '@angular/forms/signals';
1111
},
1212
})
1313
export class FieldAriaAttributes<T> {
14-
readonly formField = input.required<FieldTree<T>>();
14+
readonly formFieldAria = input.required<FieldTree<T>>();
1515
readonly fieldDescriptionId = input<string>();
1616

1717
readonly ariaInvalid = computed(() => {
18-
const state = this.formField()();
18+
const state = this.formFieldAria()();
1919
return state.touched() && !state.pending() ? state.errors().length > 0 : undefined;
2020
});
2121
readonly ariaBusy = computed(() => {
22-
const state = this.formField()();
22+
const state = this.formFieldAria()();
2323
return state.pending();
2424
});
2525
readonly ariaDescribedBy = computed(() => {

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ <h1>Version 4: Metadata and Accessibility Handling</h1>
1414
id="myField"
1515
fieldDescriptionId="username-info"
1616
[formField]="registrationForm.username"
17+
[formFieldAria]="registrationForm.username"
1718
/>
1819
<app-form-field-info id="username-info" [fieldRef]="registrationForm.username" />
1920
</label>
@@ -29,6 +30,7 @@ <h1>Version 4: Metadata and Accessibility Handling</h1>
2930
type="number"
3031
fieldDescriptionId="age-info"
3132
[formField]="registrationForm.age"
33+
[formFieldAria]="registrationForm.age"
3234
/>
3335
<app-form-field-info id="age-info" [fieldRef]="registrationForm.age" />
3436
</label>
@@ -42,6 +44,7 @@ <h1>Version 4: Metadata and Accessibility Handling</h1>
4244
autocomplete
4345
fieldDescriptionId="pw1-info"
4446
[formField]="registrationForm.password.pw1"
47+
[formFieldAria]="registrationForm.password.pw1"
4548
/>
4649
<app-form-field-info id="pw-info pw1-info" [fieldRef]="registrationForm.password.pw1" />
4750
</label>
@@ -52,6 +55,7 @@ <h1>Version 4: Metadata and Accessibility Handling</h1>
5255
autocomplete
5356
fieldDescriptionId="pw2-info"
5457
[formField]="registrationForm.password.pw2"
58+
[formFieldAria]="registrationForm.password.pw2"
5559
/>
5660
<app-form-field-info id="pw-info pw2-info" [fieldRef]="registrationForm.password.pw2" />
5761
</label>
@@ -70,23 +74,25 @@ <h1>Version 4: Metadata and Accessibility Handling</h1>
7074
type="email"
7175
[fieldDescriptionId]="`email-info email${$index}-info`"
7276
[formField]="emailField"
77+
[formFieldAria]="emailField"
7378
[aria-label]="'E-mail ' + $index"
7479
/>
7580
<button type="button" (click)="removeEmail($index)">-</button>
7681
</div>
77-
<app-form-field-info [id]="`email-info email${$index}-info`" fieldDescriptionId="pw1-info" [fieldRef]="emailField" />
82+
<app-form-field-info [id]="`email${$index}-info`" [fieldRef]="emailField" />
7883
</div>
7984
}
8085
</div>
8186
<app-form-field-info id="email-info" [fieldRef]="registrationForm.email" />
8287
</fieldset>
8388
<label
8489
>Subscribe to Newsletter?
85-
<input type="checkbox" [formField]="registrationForm.newsletter" />
90+
<input type="checkbox" [formField]="registrationForm.newsletter" [formFieldAria]="registrationForm.newsletter" />
8691
</label>
8792
<app-multiselect
8893
fieldDescriptionId="newsletter-topics-info"
8994
[formField]="registrationForm.newsletterTopics"
95+
[formFieldAria]="registrationForm.newsletterTopics"
9096
[selectOptions]="['Angular', 'React', 'Vue', 'Svelte']"
9197
label="Topics (multiple possible):"
9298
/>
@@ -97,6 +103,7 @@ <h1>Version 4: Metadata and Accessibility Handling</h1>
97103
type="checkbox"
98104
fieldDescriptionId="agree-info"
99105
[formField]="registrationForm.agreeToTermsAndConditions"
106+
[formFieldAria]="registrationForm.agreeToTermsAndConditions"
100107
/>
101108
</label>
102109
<app-form-field-info id="agree-info" [fieldRef]="registrationForm.agreeToTermsAndConditions" />

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,10 @@ export class RegistrationForm4 {
208208

209209
return errors;
210210
},
211+
onInvalid: (form) => {
212+
const errors = form().errorSummary();
213+
errors.at(0)?.fieldTree().focusBoundControl();
214+
}
211215
},
212216
}
213217
);

version-1-version-2.diff

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
diff --git a/tmp/version-2/identity-form.html b/tmp/version-2/identity-form.html
2+
new file mode 100644
3+
index 0000000..e69de29
4+
diff --git a/tmp/version-2/identity-form.ts b/tmp/version-2/identity-form.ts
5+
new file mode 100644
6+
index 0000000..e69de29
7+
diff --git a/tmp/version-2/multiselect.html b/tmp/version-2/multiselect.html
8+
new file mode 100644
9+
index 0000000..e69de29
10+
diff --git a/tmp/version-2/multiselect.ts b/tmp/version-2/multiselect.ts
11+
new file mode 100644
12+
index 0000000..e69de29
13+
diff --git a/tmp/version-1/registration-form.html b/tmp/version-2/registration-form.html
14+
index 47f7109..85cc7c7 100644
15+
--- a/tmp/version-1/registration-form.html
16+
+++ b/tmp/version-2/registration-form.html
17+
@@ -6,6 +6,9 @@
18+
[formField]="registrationForm.username"
19+
[aria-invalid]="ariaInvalidState(registrationForm.username)"
20+
/>
21+
+ @if (registrationForm.username().pending()) {
22+
+ <small>Checking availability ...</small>
23+
+ }
24+
<app-form-error [fieldRef]="registrationForm.username" />
25+
</label>
26+
27+
@@ -21,6 +24,30 @@
28+
<app-form-error [fieldRef]="registrationForm.age" />
29+
</label>
30+
</div>
31+
+
32+
+ <div>
33+
+ <label
34+
+ >Password
35+
+ <input
36+
+ type="password"
37+
+ autocomplete
38+
+ [formField]="registrationForm.password.pw1"
39+
+ [aria-invalid]="ariaInvalidState(registrationForm.password.pw1)"
40+
+ />
41+
+ <app-form-error [fieldRef]="registrationForm.password.pw1" />
42+
+ </label>
43+
+ <label
44+
+ >Password Confirmation
45+
+ <input
46+
+ type="password"
47+
+ autocomplete
48+
+ [formField]="registrationForm.password.pw2"
49+
+ [aria-invalid]="ariaInvalidState(registrationForm.password.pw2)"
50+
+ />
51+
+ <app-form-error [fieldRef]="registrationForm.password.pw2" />
52+
+ </label>
53+
+ <app-form-error [fieldRef]="registrationForm.password" />
54+
+ </div>
55+
<fieldset>
56+
<legend>
57+
E-mail Addresses
58+
@@ -48,6 +75,18 @@
59+
>Subscribe to Newsletter?
60+
<input type="checkbox" [formField]="registrationForm.newsletter" />
61+
</label>
62+
+
63+
+ <label>
64+
+ Topics (multiple possible):
65+
+ <select [formField]="registrationForm.newsletterTopics">
66+
+ <option value=""></option>
67+
+ <option value="Angular">Angular</option>
68+
+ <option value="Vue">Vue</option>
69+
+ <option value="React">React</option>
70+
+ </select>
71+
+ <app-form-error [fieldRef]="registrationForm.newsletterTopics" />
72+
+ </label>
73+
+
74+
<label
75+
>I agree to the terms and conditions
76+
<input
77+
diff --git a/tmp/version-1/registration-form.ts b/tmp/version-2/registration-form.ts
78+
index 125b378..09ec664 100644
79+
--- a/tmp/version-1/registration-form.ts
80+
+++ b/tmp/version-2/registration-form.ts
81+
@@ -1,41 +1,79 @@
82+
-import { Component, inject, signal } from '@angular/core';
83+
+import { Component, inject, resource, signal } from '@angular/core';
84+
import {
85+
+ applyEach,
86+
+ applyWhen,
87+
FormField,
88+
+ disabled,
89+
+ email,
90+
FieldTree,
91+
form,
92+
maxLength,
93+
min,
94+
minLength,
95+
+ pattern,
96+
required,
97+
schema,
98+
FormRoot,
99+
+ validate,
100+
+ validateAsync,
101+
+ validateTree,
102+
+ ValidationError,
103+
+ WithFieldTree,
104+
} from '@angular/forms/signals';
105+
106+
+import { DebugOutput } from '../debug-output/debug-output';
107+
import { FormError } from '../form-error/form-error';
108+
import { RegistrationService } from '../registration-service';
109+
-import { DebugOutput } from '../debug-output/debug-output';
110+
111+
-interface RegisterFormData {
112+
+export interface RegisterFormData {
113+
username: string;
114+
age: number;
115+
+ password: { pw1: string; pw2: string };
116+
email: string[];
117+
newsletter: boolean;
118+
+ newsletterTopics: string;
119+
agreeToTermsAndConditions: boolean;
120+
}
121+
122+
const initialState: RegisterFormData = {
123+
username: '',
124+
age: 18,
125+
+ password: { pw1: '', pw2: '' },
126+
email: [''],
127+
newsletter: false,
128+
+ newsletterTopics: '',
129+
agreeToTermsAndConditions: false,
130+
};
131+
132+
-const formSchema = schema<RegisterFormData>((path) => {
133+
+export const formSchema = schema<RegisterFormData>((path) => {
134+
// Username validation
135+
required(path.username, { message: 'Username is required.' });
136+
minLength(path.username, 3, { message: 'A username must be at least 3 characters long.' });
137+
maxLength(path.username, 12, { message: 'A username can be max. 12 characters long.' });
138+
+ validateAsync(path.username, {
139+
+ // Reactive params
140+
+ params: (ctx) => ctx.value(),
141+
+ // Factory creating a resource
142+
+ factory: (params) => {
143+
+ const registrationService = inject(RegistrationService);
144+
+ return resource({
145+
+ params,
146+
+ loader: async ({ params }) => {
147+
+ return await registrationService.checkUserExists(params);
148+
+ },
149+
+ });
150+
+ },
151+
+ // Maps resource to error
152+
+ onSuccess: (result) => {
153+
+ return result
154+
+ ? {
155+
+ kind: 'userExists',
156+
+ message: 'The username you entered was already taken.',
157+
+ }
158+
+ : undefined;
159+
+ },
160+
+ onError: () => undefined,
161+
+ });
162+
163+
// Age validation
164+
min(path.age, 18, { message: 'You must be >=18 years old.' });
165+
@@ -44,6 +82,61 @@ const formSchema = schema<RegisterFormData>((path) => {
166+
required(path.agreeToTermsAndConditions, {
167+
message: 'You must agree to the terms and conditions.',
168+
});
169+
+
170+
+ // E-mail validation
171+
+ validate(path.email, (ctx) =>
172+
+ !ctx.value().some((e) => e)
173+
+ ? {
174+
+ kind: 'atLeastOneEmail',
175+
+ message: 'At least one E-mail address must be added.',
176+
+ }
177+
+ : undefined,
178+
+ );
179+
+ applyEach(path.email, (emailPath) => {
180+
+ email(emailPath, { message: 'E-mail format is invalid.' });
181+
+ });
182+
+
183+
+ // Password validation
184+
+ required(path.password.pw1, { message: 'A password is required.' });
185+
+ required(path.password.pw2, {
186+
+ message: 'A password confirmation is required.',
187+
+ });
188+
+ minLength(path.password.pw1, 8, {
189+
+ message: 'A password must be at least 8 characters long.',
190+
+ });
191+
+ pattern(
192+
+ path.password.pw1,
193+
+ new RegExp('^.*[!@#$%^&*(),.?":{}|<>\\[\\]\\\\/~`_+=;\'\\-].*$'),
194+
+ { message: 'The password must contain at least one special character.' },
195+
+ );
196+
+ validateTree(path.password, (ctx) => {
197+
+ return ctx.value().pw2 === ctx.value().pw1
198+
+ ? undefined
199+
+ : {
200+
+ field: ctx.fieldTree.pw2, // assign the error to the second password field
201+
+ kind: 'confirmationPassword',
202+
+ message: 'The entered password must match with the one specified in "Password" field.',
203+
+ };
204+
+ });
205+
+
206+
+ // Newsletter validation
207+
+ applyWhen(
208+
+ path,
209+
+ (ctx) => ctx.value().newsletter,
210+
+ (pathWhenTrue) => {
211+
+ validate(pathWhenTrue.newsletterTopics, (ctx) =>
212+
+ !ctx.value().length
213+
+ ? {
214+
+ kind: 'noTopicSelected',
215+
+ message: 'Select at least one newsletter topic.',
216+
+ }
217+
+ : undefined,
218+
+ );
219+
+ },
220+
+ );
221+
+
222+
+ // Disable newsletter topics when newsletter is unchecked
223+
+ disabled(path.newsletterTopics, (ctx) => !ctx.valueOf(path.newsletter));
224+
});
225+
226+
@Component({
227+
@@ -62,15 +155,33 @@ export class RegistrationForm {
228+
{
229+
submission: {
230+
action: async (form) => {
231+
- await this.#registrationService.registerUser(form().value);
232+
- console.log('Registration successful!');
233+
- this.resetForm();
234+
+ const errors: WithFieldTree<ValidationError>[] = [];
235+
+
236+
+ try {
237+
+ await this.#registrationService.registerUser(form().value);
238+
+ } catch (e) {
239+
+ errors.push({
240+
+ fieldTree: form,
241+
+ kind: 'serverError',
242+
+ message: 'There was a server error, please try again (should work after 3rd try).',
243+
+ });
244+
+ }
245+
+
246+
+ setTimeout(() => this.resetForm(), 3000);
247+
+ return errors;
248+
},
249+
},
250+
}
251+
);
252+
253+
protected ariaInvalidState(field: FieldTree<unknown>): boolean | undefined {
254+
+ if (field().value() === 'validuser') {
255+
+ console.log('###### FIELD:', {
256+
+ touched: field().touched(),
257+
+ pending: field().pending(),
258+
+ errors: field().errors(),
259+
+ });
260+
+ }
261+
return field().touched() && !field().pending() ? field().errors().length > 0 : undefined;
262+
}
263+
264+
@@ -84,6 +195,7 @@ export class RegistrationForm {
265+
.value.update((items) => items.filter((_, index) => index !== removeIndex));
266+
}
267+
268+
+ // Reset form
269+
protected resetForm() {
270+
this.registrationModel.set(initialState);
271+
this.registrationForm().reset();

0 commit comments

Comments
 (0)