Skip to content

Commit 2b8eea4

Browse files
committed
feat(form): focus the first invalid field when an invalid form is submitted
1 parent 19680d6 commit 2b8eea4

4 files changed

Lines changed: 196 additions & 1 deletion

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { focusFirstInvalidControl } from './focus-first-invalid';
2+
3+
describe('focusFirstInvalidControl', () => {
4+
let form: HTMLFormElement;
5+
6+
beforeEach(() => {
7+
form = document.createElement('form');
8+
document.body.appendChild(form);
9+
});
10+
11+
afterEach(() => {
12+
form.remove();
13+
});
14+
15+
it('should focus the first invalid native input', () => {
16+
form.innerHTML = `
17+
<input id="valid" class="ng-valid" />
18+
<input id="first-invalid" class="ng-invalid" />
19+
<input id="second-invalid" class="ng-invalid" />
20+
`;
21+
22+
focusFirstInvalidControl(form);
23+
24+
expect(document.activeElement?.id).toBe('first-invalid');
25+
});
26+
27+
it('should focus the inner focusable element of an invalid non-focusable host', () => {
28+
form.innerHTML = `
29+
<lu-simple-select class="ng-invalid">
30+
<div><input id="inner-input" tabindex="0" /></div>
31+
</lu-simple-select>
32+
`;
33+
34+
focusFirstInvalidControl(form);
35+
36+
expect(document.activeElement?.id).toBe('inner-input');
37+
});
38+
39+
it('should focus the innermost invalid control when a wrapping group is also ng-invalid', () => {
40+
form.innerHTML = `
41+
<div class="ng-invalid">
42+
<input id="leaf-invalid" class="ng-invalid" />
43+
</div>
44+
`;
45+
46+
focusFirstInvalidControl(form);
47+
48+
expect(document.activeElement?.id).toBe('leaf-invalid');
49+
});
50+
51+
it('should do nothing when no control is invalid', () => {
52+
form.innerHTML = `<input id="valid" class="ng-valid" />`;
53+
const initialActiveElement = document.activeElement;
54+
55+
focusFirstInvalidControl(form);
56+
57+
expect(document.activeElement).toBe(initialActiveElement);
58+
});
59+
60+
it('should do nothing when the invalid element has no focusable target', () => {
61+
form.innerHTML = `<div class="ng-invalid"><span>not focusable</span></div>`;
62+
const initialActiveElement = document.activeElement;
63+
64+
focusFirstInvalidControl(form);
65+
66+
expect(document.activeElement).toBe(initialActiveElement);
67+
});
68+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
const NATIVE_FOCUSABLE_SELECTOR = 'input, select, textarea, button';
2+
const INNER_FOCUSABLE_SELECTOR = 'input, select, textarea, button, [tabindex]:not([tabindex="-1"])';
3+
4+
export function focusFirstInvalidControl(formElement: HTMLElement): void {
5+
// Innermost invalid elements only: an invalid FormGroup wrapper also carries ng-invalid
6+
const firstInvalid = Array.from(formElement.querySelectorAll<HTMLElement>('.ng-invalid')).find((element) => !element.querySelector('.ng-invalid'));
7+
if (!firstInvalid) {
8+
return;
9+
}
10+
11+
// Custom form control hosts (e.g. lu-simple-select) carry ng-invalid but are not focusable themselves
12+
const target = firstInvalid.matches(NATIVE_FOCUSABLE_SELECTOR) ? firstInvalid : firstInvalid.querySelector<HTMLElement>(INNER_FOCUSABLE_SELECTOR);
13+
if (!target) {
14+
return;
15+
}
16+
17+
target.focus();
18+
target.scrollIntoView({ block: 'center' });
19+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
2+
import { TestBed } from '@angular/core/testing';
3+
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
4+
import { FormComponent } from './form.component';
5+
6+
@Component({
7+
selector: 'lu-form-host',
8+
imports: [FormComponent, ReactiveFormsModule],
9+
template: `
10+
<form luForm [formGroup]="formGroup" [focusInvalidOnSubmit]="focusInvalidOnSubmit()" (submit)="onSubmit()">
11+
<input id="name" formControlName="name" />
12+
<input id="email" formControlName="email" />
13+
<button id="save" type="submit">Save</button>
14+
</form>
15+
`,
16+
changeDetection: ChangeDetectionStrategy.OnPush,
17+
})
18+
class FormHost {
19+
readonly focusInvalidOnSubmit = signal(true);
20+
21+
readonly formGroup = new FormGroup({
22+
name: new FormControl('', Validators.required),
23+
email: new FormControl('', Validators.required),
24+
});
25+
26+
onSubmit(): void {
27+
this.formGroup.markAllAsTouched();
28+
}
29+
}
30+
31+
describe(FormComponent.name, () => {
32+
function createHost() {
33+
TestBed.configureTestingModule({ imports: [FormHost] });
34+
const fixture = TestBed.createComponent(FormHost);
35+
fixture.detectChanges();
36+
const form = (fixture.nativeElement as HTMLElement).querySelector<HTMLFormElement>('form')!;
37+
document.body.appendChild(fixture.nativeElement);
38+
return { fixture, form };
39+
}
40+
41+
afterEach(() => {
42+
document.body.innerHTML = '';
43+
});
44+
45+
const flushFocusTimeout = () => new Promise((resolve) => setTimeout(resolve));
46+
47+
it('should focus the first invalid field when submitting an invalid form', async () => {
48+
const { form } = createHost();
49+
50+
form.dispatchEvent(new Event('submit'));
51+
await flushFocusTimeout();
52+
53+
expect(document.activeElement?.id).toBe('name');
54+
});
55+
56+
it('should not move focus when the form is valid', async () => {
57+
const { fixture, form } = createHost();
58+
fixture.componentInstance.formGroup.setValue({ name: 'a', email: 'b' });
59+
fixture.detectChanges();
60+
const initialActiveElement = document.activeElement;
61+
62+
form.dispatchEvent(new Event('submit'));
63+
await flushFocusTimeout();
64+
65+
expect(document.activeElement).toBe(initialActiveElement);
66+
});
67+
68+
it('should not move focus when focusInvalidOnSubmit is false', async () => {
69+
const { fixture, form } = createHost();
70+
fixture.componentInstance.focusInvalidOnSubmit.set(false);
71+
fixture.detectChanges();
72+
const initialActiveElement = document.activeElement;
73+
74+
form.dispatchEvent(new Event('submit'));
75+
await flushFocusTimeout();
76+
77+
expect(document.activeElement).toBe(initialActiveElement);
78+
});
79+
80+
it('should focus the first invalid field even when it becomes touched only in the submit handler', async () => {
81+
const { fixture, form } = createHost();
82+
fixture.componentInstance.formGroup.controls.email.setValue('b');
83+
fixture.detectChanges();
84+
85+
form.dispatchEvent(new Event('submit'));
86+
await flushFocusTimeout();
87+
88+
expect(document.activeElement?.id).toBe('name');
89+
});
90+
});

packages/ng/form/form.component.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { ChangeDetectionStrategy, Component, forwardRef, inject, input, ViewEncapsulation } from '@angular/core';
1+
import { ChangeDetectionStrategy, Component, ElementRef, forwardRef, inject, input, ViewEncapsulation } from '@angular/core';
22
import { luBooleanAttribute } from '@lucca-front/ng/core';
33
import { LU_FORM_INSTANCE } from './form-instance';
44
import { LuDialogRef } from '@lucca-front/ng/dialog';
5+
import { focusFirstInvalidControl } from './focus-first-invalid';
56

67
@Component({
78
// eslint-disable-next-line @angular-eslint/component-selector
@@ -14,6 +15,7 @@ import { LuDialogRef } from '@lucca-front/ng/dialog';
1415
'[class.mod-maxWidth]': 'maxWidth()',
1516
'[class.dialog-inside-formOptional]': 'dialogRef !== null',
1617
'[attr.role]': 'presentation() ? "presentation" : null',
18+
'(submit)': 'onSubmit()',
1719
},
1820
changeDetection: ChangeDetectionStrategy.OnPush,
1921
providers: [
@@ -26,7 +28,23 @@ import { LuDialogRef } from '@lucca-front/ng/dialog';
2628
export class FormComponent {
2729
protected readonly dialogRef = inject(LuDialogRef, { optional: true });
2830

31+
readonly #elementRef = inject<ElementRef<HTMLFormElement>>(ElementRef);
32+
2933
readonly maxWidth = input(false, { transform: luBooleanAttribute });
3034

3135
readonly presentation = input(false, { transform: luBooleanAttribute });
36+
37+
readonly focusInvalidOnSubmit = input(true, { transform: luBooleanAttribute });
38+
39+
protected onSubmit(): void {
40+
if (!this.focusInvalidOnSubmit()) {
41+
return;
42+
}
43+
setTimeout(() => {
44+
const form = this.#elementRef.nativeElement;
45+
if (form.classList.contains('ng-invalid')) {
46+
focusFirstInvalidControl(form);
47+
}
48+
});
49+
}
3250
}

0 commit comments

Comments
 (0)