Skip to content

Commit c41d7f4

Browse files
feat: use field meta data (#4)
1 parent 6b6258c commit c41d7f4

17 files changed

Lines changed: 500 additions & 7 deletions

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

generate-diffs.sh

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@ else
88
fi
99

1010
# Create local tmp directory
11-
mkdir -p tmp/{version-1,version-2,version-3}
11+
mkdir -p tmp/{version-1,version-2,version-3,version-4}
1212

1313
# Copy files with normalized names
1414
cp -r src/app/registration-form-1/* tmp/version-1/
1515
cp -r src/app/registration-form-2/* tmp/version-2/
1616
cp -r src/app/registration-form-3/* tmp/version-3/
17+
cp -r src/app/registration-form-4/* tmp/version-4/
1718

1819
# Copy additional files for version-2 to version-3 comparison
1920
# Create empty files in version-2 to show as new in diff
@@ -27,10 +28,27 @@ cp src/app/identity-form/identity-form.html tmp/version-3/
2728
cp src/app/multiselect/multiselect.ts tmp/version-3/
2829
cp src/app/multiselect/multiselect.html tmp/version-3/
2930

31+
# Copy additional files for version-3 to version-4 comparison
32+
# Create empty files in version-3 to show as new in diff
33+
touch tmp/version-3/src/app/field-aria-attributes.ts
34+
touch tmp/version-3/src/app/form-props.ts
35+
touch tmp/version-3/src/app/form-field-info/form-field-info.html
36+
touch tmp/version-3/src/app/form-field-info/form-field-info.ts
37+
# Copy actual files to version-4
38+
cp src/app/field-aria-attributes.ts tmp/version-4/
39+
cp src/app/form-props.ts tmp/version-4/
40+
cp src/app/form-field-info/form-field-info.ts tmp/version-4/
41+
cp src/app/form-field-info/form-field-info.html tmp/version-4/
42+
cp src/app/identity-form/identity-form.ts tmp/version-4/
43+
cp src/app/identity-form/identity-form.html tmp/version-4/
44+
cp src/app/multiselect/multiselect.ts tmp/version-4/
45+
cp src/app/multiselect/multiselect.html tmp/version-4/
46+
3047
# Rename files
3148
cd tmp/version-1 && for f in registration-form-1.*; do mv "$f" "${f/registration-form-1/registration-form}"; done
3249
cd ../version-2 && for f in registration-form-2.*; do mv "$f" "${f/registration-form-2/registration-form}"; done
3350
cd ../version-3 && for f in registration-form-3.*; do mv "$f" "${f/registration-form-3/registration-form}"; done
51+
cd ../version-4 && for f in registration-form-4.*; do mv "$f" "${f/registration-form-4/registration-form}"; done
3452
cd ../..
3553

3654
# Remove spec files
@@ -47,23 +65,27 @@ eval "$SED_INPLACE 's/BackButton, //g' tmp/*/registration-form.ts"
4765
eval "$SED_INPLACE 's/RegistrationForm1/RegistrationForm/g; s/registration-form-1/registration-form/g' tmp/version-1/registration-form.ts"
4866
eval "$SED_INPLACE 's/RegistrationForm2/RegistrationForm/g; s/registration-form-2/registration-form/g' tmp/version-2/registration-form.ts"
4967
eval "$SED_INPLACE 's/RegistrationForm3/RegistrationForm/g; s/registration-form-3/registration-form/g' tmp/version-3/registration-form.ts"
68+
eval "$SED_INPLACE 's/RegistrationForm4/RegistrationForm/g; s/registration-form-4/registration-form/g' tmp/version-4/registration-form.ts"
5069

5170
# Generate diffs
5271
git diff --no-index tmp/version-1 tmp/version-2 > version-1-version-2.diff
5372
git diff --no-index tmp/version-2 tmp/version-3 > version-2-version-3.diff
73+
git diff --no-index tmp/version-3 tmp/version-4 > version-3-version-4.diff
5474

5575
# Remove tmp paths from diffs
56-
eval "$SED_INPLACE 's|tmp/version[123]/||g' version-1-version-2.diff version-2-version-3.diff"
76+
eval "$SED_INPLACE 's|tmp/version[1234]/||g' version-1-version-2.diff version-2-version-3.diff version-3-version-4.diff"
5777

5878
# Generate HTML views
5979
npx diff2html-cli -i file -o stdout -s side --title="Version 1 - Version 2" -- version-1-version-2.diff > public/version-1-version-2.html
6080
npx diff2html-cli -i file -o stdout -s side --title="Version 2 - Version 3" -- version-2-version-3.diff > public/version-2-version-3.html
81+
npx diff2html-cli -i file -o stdout -s side --title="Version 3 - Version 4" -- version-3-version-4.diff > public/version-3-version-4.html
6182

6283
# Move diff files to public
6384
mv version-1-version-2.diff public/
6485
mv version-2-version-3.diff public/
86+
mv version-3-version-4.diff public/
6587

6688
# Cleanup
6789
rm -rf tmp
6890

69-
echo "Generated: public/version-1-version-2.html and public/version-2-version-3.html"
91+
echo "Generated: public/version-1-version-2.html, public/version-2-version-3.html and public/version-3-version-4.html"

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
];
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { FieldAriaAttributes } from './field-aria-attributes';
2+
3+
describe('FieldAriaAttributes', () => {
4+
it('should create an instance', () => {
5+
const directive = new FieldAriaAttributes();
6+
expect(directive).toBeTruthy();
7+
});
8+
});

src/app/field-aria-attributes.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { computed, Directive, input } from '@angular/core';
2+
import { FieldTree } from '@angular/forms/signals';
3+
4+
@Directive({
5+
selector: '[field]',
6+
host: {
7+
'[aria-invalid]': 'ariaInvalid()',
8+
'[aria-busy]': 'ariaBusy()',
9+
'[aria-describedby]': 'ariaDescribedBy()',
10+
'[aria-errormessage]': 'ariaErrorMessage()',
11+
},
12+
})
13+
export class FieldAriaAttributes<T> {
14+
readonly field = input.required<FieldTree<T>>();
15+
readonly fieldDescriptionId = input<string>();
16+
17+
readonly ariaInvalid = computed(() => {
18+
const state = this.field()();
19+
return state.touched() && !state.pending() ? state.errors().length > 0 : undefined;
20+
});
21+
readonly ariaBusy = computed(() => {
22+
const state = this.field()();
23+
return state.pending();
24+
});
25+
readonly ariaDescribedBy = computed(() => {
26+
const id = this.fieldDescriptionId();
27+
return !id || this.ariaInvalid() ? null : id;
28+
});
29+
readonly ariaErrorMessage = computed(() => {
30+
const id = this.fieldDescriptionId();
31+
return !id || !this.ariaInvalid() ? null : id;
32+
});
33+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
@if(messages()) {
2+
<ul>
3+
@for (message of messages(); track $index) {
4+
<li [class]="message.cssClass">
5+
<small>{{ message.info }}</small>
6+
</li>
7+
}
8+
</ul>
9+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
:host {
2+
display: block;
3+
margin-top: -0.75rem;
4+
margin-bottom: 1rem;
5+
}
6+
7+
ul {
8+
padding: 0;
9+
10+
li {
11+
list-style: none;
12+
font-size: 0.8em;
13+
14+
&.pending {
15+
color: #886b02;
16+
}
17+
18+
&.invalid {
19+
color: var(--pico-del-color);
20+
}
21+
22+
&.valid {
23+
color: var(--pico-ins-color);
24+
}
25+
}
26+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { ComponentFixture, TestBed } from '@angular/core/testing';
2+
3+
import { FormFieldInfo } from './form-field-info';
4+
5+
describe('FormFieldInfo', () => {
6+
let component: FormFieldInfo;
7+
let fixture: ComponentFixture<FormFieldInfo>;
8+
9+
beforeEach(async () => {
10+
await TestBed.configureTestingModule({
11+
imports: [FormFieldInfo]
12+
})
13+
.compileComponents();
14+
15+
fixture = TestBed.createComponent(FormFieldInfo);
16+
component = fixture.componentInstance;
17+
await fixture.whenStable();
18+
});
19+
20+
it('should create', () => {
21+
expect(component).toBeTruthy();
22+
});
23+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Component, computed, input, signal } from '@angular/core';
2+
import { FieldTree } from '@angular/forms/signals';
3+
4+
import { FIELD_INFO } from '../form-props';
5+
6+
@Component({
7+
selector: 'app-form-field-info',
8+
imports: [],
9+
templateUrl: './form-field-info.html',
10+
styleUrl: './form-field-info.scss',
11+
})
12+
export class FormFieldInfo<T> {
13+
readonly fieldRef = input.required<FieldTree<T>>();
14+
15+
protected readonly messages = computed(() => {
16+
const field = this.fieldRef()();
17+
let messages: { info: string; cssClass: 'info' | 'pending' | 'valid' | 'invalid' }[] = [];
18+
19+
if (field.pending()) {
20+
messages = [{ info: 'Checking availability ...', cssClass: 'pending' }];
21+
} else if (field.touched() && field.errors().length > 0) {
22+
messages = field.errors().map((e) => ({ info: e.message || 'Invalid', cssClass: 'invalid' }));
23+
} else if (field.hasMetadata(FIELD_INFO)) {
24+
messages = [{ info: field.metadata(FIELD_INFO)!, cssClass: field.valid() ? 'valid': 'info' }];
25+
}
26+
return messages;
27+
});
28+
}

src/app/form-props.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { createMetadataKey } from "@angular/forms/signals";
2+
3+
export const FIELD_INFO = createMetadataKey<string>()

0 commit comments

Comments
 (0)