Skip to content

Commit ba009b6

Browse files
authored
feat(forms): add form directive
Adds a `formRoot` directive to manage submitting the form in signal forms.
1 parent bd2868e commit ba009b6

File tree

5 files changed

+220
-33
lines changed

5 files changed

+220
-33
lines changed

adev/src/content/guide/forms/signals/field-state-management.md

Lines changed: 32 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -595,14 +595,13 @@ While field state typically updates through user interactions (typing, focusing,
595595

596596
#### Form submission
597597

598-
When a user submits a form, use the `submit()` function to handle validation and reveal errors.
599-
600-
Signal Forms handles validation through its own system, so you need to prevent the browser's default form behavior. Add `novalidate` to the `<form>` element to disable native HTML validation (such as browser tooltip popups for `required` or `type="email"` fields), and call `$event.preventDefault()` in your submit handler to prevent the browser from reloading the page:
598+
Signal Forms provides a `FormRoot` directive that simplifies form submission. It automatically prevents the default browser form submission behavior and sets the `novalidate` attribute on the `<form>` element.
601599

602600
```angular-ts
603601
@Component({
602+
imports: [FormRoot, FormField],
604603
template: `
605-
<form novalidate (submit)="onSubmit($event)">
604+
<form [formRoot]="registrationForm">
606605
<input [formField]="registrationForm.username" />
607606
<input type="email" [formField]="registrationForm.email" />
608607
<input type="password" [formField]="registrationForm.password" />
@@ -614,51 +613,51 @@ Signal Forms handles validation through its own system, so you need to prevent t
614613
export class Registration {
615614
registrationModel = signal({username: '', email: '', password: ''});
616615
617-
registrationForm = form(this.registrationModel, (schemaPath) => {
618-
required(schemaPath.username);
619-
email(schemaPath.email);
620-
required(schemaPath.password);
621-
});
622-
623-
onSubmit(event: Event) {
624-
event.preventDefault();
625-
submit(this.registrationForm, async () => {
626-
this.submitToServer();
627-
});
628-
}
616+
registrationForm = form(
617+
this.registrationModel,
618+
(schemaPath) => {
619+
required(schemaPath.username);
620+
email(schemaPath.email);
621+
required(schemaPath.password);
622+
},
623+
{
624+
submission: {
625+
action: async () => this.submitToServer(),
626+
},
627+
},
628+
);
629629
630630
private submitToServer() {
631631
// Send data to server
632632
}
633633
}
634634
```
635635

636-
The `submit()` function automatically marks all fields as touched (revealing validation errors) and only executes your callback if the form is valid.
636+
When you use `FormRoot`, submitting the form automatically calls the `submit()` function, which marks all fields as touched (revealing validation errors) and executes your `action` callback if the form is valid.
637+
638+
You can also submit a form manually, without using the directive, by calling `submit(this.registrationForm)`. When explicitly calling the `submit` function like this, you can pass a `FormSubmitOptions` to override the default `submission` logic for the form: `submit(this.registrationForm, {action: () => /* ... */ })`.
637639

638640
#### Resetting forms after submission
639641

640-
After successfully submitting a form, you may want to return it to its initial state - clearing both user interaction history and field values. The `reset()` method clears the touched and dirty flags but doesn't change field values, so you need to update your model separately:
642+
After successfully submitting a form, you may want to return it to its initial state - clearing both user interaction history and field values. The `reset()` method clears the touched and dirty flags. You can also pass an optional value to `reset()` to update the model data:
641643

642644
```ts
643645
export class Contact {
644-
contactModel = signal({name: '', email: '', message: ''});
645-
contactForm = form(this.contactModel);
646-
647-
async onSubmit() {
648-
if (!this.contactForm().valid()) return;
649-
650-
await this.api.sendMessage(this.contactModel());
651-
652-
// Clear interaction state (touched, dirty)
653-
this.contactForm().reset();
654-
655-
// Clear values
656-
this.contactModel.set({name: '', email: '', message: ''});
657-
}
646+
private readonly INITIAL_MODEL = {name: '', email: '', message: ''};
647+
contactModel = signal({...this.INITIAL_MODEL});
648+
contactForm = form(this.contactModel, {
649+
submission: {
650+
action: async (f) => {
651+
await this.api.sendMessage(this.contactModel());
652+
// Clear interaction state (touched, dirty) and reset to initial values
653+
f().reset({...this.INITIAL_MODEL});
654+
},
655+
},
656+
});
658657
}
659658
```
660659

661-
This two-step reset ensures the form is ready for new input without showing stale error messages or dirty state indicators.
660+
This ensures the form is ready for new input without showing stale error messages or dirty state indicators.
662661

663662
## Styling based on validation state
664663

goldens/public-api/forms/signals/index.api.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,18 @@ export interface FormOptions<TModel> {
213213
submission?: FormSubmitOptions<TModel, unknown>;
214214
}
215215

216+
// @public
217+
export class FormRoot<T> {
218+
// (undocumented)
219+
readonly fieldTree: i0.InputSignal<FieldTree<T>>;
220+
// (undocumented)
221+
protected onSubmit(event: Event): void;
222+
// (undocumented)
223+
static ɵdir: i0.ɵɵDirectiveDeclaration<FormRoot<any>, "form[formRoot]", never, { "fieldTree": { "alias": "formRoot"; "required": true; "isSignal": true; }; }, {}, never, never, true, never>;
224+
// (undocumented)
225+
static ɵfac: i0.ɵɵFactoryDeclaration<FormRoot<any>, never>;
226+
}
227+
216228
// @public
217229
export interface FormSubmitOptions<TRootModel, TSubmittedModel> {
218230
action: (field: FieldTree<TRootModel & TSubmittedModel>, detail: {

packages/forms/signals/public_api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,4 @@ export * from './src/api/structure';
2121
export * from './src/api/transformed_value';
2222
export * from './src/api/types';
2323
export * from './src/directive/form_field_directive';
24+
export * from './src/directive/ng_signal_form';
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {Directive, input} from '@angular/core';
10+
11+
import {submit} from '../api/structure';
12+
import {FieldTree} from '../api/types';
13+
14+
/**
15+
* A directive that binds a `FieldTree` to a `<form>` element.
16+
*
17+
* It automatically:
18+
* 1. Sets `novalidate` on the form element to disable browser validation.
19+
* 2. Listens for the `submit` event, prevents the default behavior, and calls `submit()` on the
20+
* `FieldTree`.
21+
*
22+
* @usageNotes
23+
*
24+
* ```html
25+
* <form [formRoot]="myFieldTree">
26+
* ...
27+
* </form>
28+
* ```
29+
*
30+
* @publicApi
31+
* @experimental 21.0.0
32+
*/
33+
@Directive({
34+
selector: 'form[formRoot]',
35+
host: {
36+
'novalidate': '',
37+
'(submit)': 'onSubmit($event)',
38+
},
39+
})
40+
export class FormRoot<T> {
41+
readonly fieldTree = input.required<FieldTree<T>>({alias: 'formRoot'});
42+
43+
protected onSubmit(event: Event): void {
44+
event.preventDefault();
45+
submit(this.fieldTree());
46+
}
47+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {Component, provideZonelessChangeDetection, signal} from '@angular/core';
10+
import {TestBed} from '@angular/core/testing';
11+
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
12+
13+
import {form, FormRoot} from '../../public_api';
14+
15+
@Component({
16+
template: `
17+
<form [formRoot]="f">
18+
<button type="submit">Submit</button>
19+
</form>
20+
`,
21+
imports: [FormRoot],
22+
})
23+
class TestCmp {
24+
submitted = false;
25+
readonly f = form(signal({}), {
26+
submission: {
27+
action: async () => {
28+
this.submitted = true;
29+
},
30+
},
31+
});
32+
}
33+
34+
describe('FormRoot', () => {
35+
beforeEach(() => {
36+
TestBed.configureTestingModule({
37+
providers: [provideZonelessChangeDetection()],
38+
});
39+
});
40+
41+
it('should set novalidate on the form element', () => {
42+
const fixture = act(() => TestBed.createComponent(TestCmp));
43+
const formElement = fixture.nativeElement.querySelector('form') as HTMLFormElement;
44+
expect(formElement.hasAttribute('novalidate')).toBeTrue();
45+
});
46+
47+
it('should call submit on the field tree when form is submitted', async () => {
48+
const fixture = act(() => TestBed.createComponent(TestCmp));
49+
const component = fixture.componentInstance;
50+
const formElement = fixture.nativeElement.querySelector('form') as HTMLFormElement;
51+
52+
const event = new Event('submit', {cancelable: true});
53+
act(() => formElement.dispatchEvent(event));
54+
55+
expect(event.defaultPrevented).toBe(true);
56+
expect(component.submitted).toBeTrue();
57+
});
58+
59+
it('works when FormsModule is imported', () => {
60+
@Component({
61+
template: `
62+
<form [formRoot]="f">
63+
<button type="submit">Submit</button>
64+
</form>
65+
`,
66+
imports: [FormRoot, FormsModule],
67+
})
68+
class TestCmp {
69+
submitted = false;
70+
readonly f = form(signal({}), {
71+
submission: {
72+
action: async () => {
73+
this.submitted = true;
74+
},
75+
},
76+
});
77+
}
78+
79+
const fixture = act(() => TestBed.createComponent(TestCmp));
80+
const component = fixture.componentInstance;
81+
const formElement = fixture.nativeElement.querySelector('form') as HTMLFormElement;
82+
83+
const event = new Event('submit', {cancelable: true});
84+
act(() => formElement.dispatchEvent(event));
85+
86+
expect(event.defaultPrevented).toBe(true);
87+
expect(component.submitted).toBeTrue();
88+
});
89+
90+
it('works when ReactiveFormsModule is imported', () => {
91+
@Component({
92+
template: `
93+
<form [formRoot]="f">
94+
<button type="submit">Submit</button>
95+
</form>
96+
`,
97+
imports: [FormRoot, ReactiveFormsModule],
98+
})
99+
class TestCmp {
100+
submitted = false;
101+
readonly f = form(signal({}), {
102+
submission: {
103+
action: async () => {
104+
this.submitted = true;
105+
},
106+
},
107+
});
108+
}
109+
110+
const fixture = act(() => TestBed.createComponent(TestCmp));
111+
const component = fixture.componentInstance;
112+
const formElement = fixture.nativeElement.querySelector('form') as HTMLFormElement;
113+
114+
const event = new Event('submit', {cancelable: true});
115+
act(() => formElement.dispatchEvent(event));
116+
117+
expect(event.defaultPrevented).toBe(true);
118+
expect(component.submitted).toBeTrue();
119+
});
120+
});
121+
122+
function act<T>(fn: () => T): T {
123+
try {
124+
return fn();
125+
} finally {
126+
TestBed.tick();
127+
}
128+
}

0 commit comments

Comments
 (0)