diff --git a/packages/ng/form/focus-first-invalid.spec.ts b/packages/ng/form/focus-first-invalid.spec.ts new file mode 100644 index 0000000000..ca052c7581 --- /dev/null +++ b/packages/ng/form/focus-first-invalid.spec.ts @@ -0,0 +1,68 @@ +import { focusFirstInvalidControl } from './focus-first-invalid'; + +describe('focusFirstInvalidControl', () => { + let form: HTMLFormElement; + + beforeEach(() => { + form = document.createElement('form'); + document.body.appendChild(form); + }); + + afterEach(() => { + form.remove(); + }); + + it('should focus the first invalid native input', () => { + form.innerHTML = ` + + + + `; + + focusFirstInvalidControl(form); + + expect(document.activeElement?.id).toBe('first-invalid'); + }); + + it('should focus the inner focusable element of an invalid non-focusable host', () => { + form.innerHTML = ` + +
+
+ `; + + focusFirstInvalidControl(form); + + expect(document.activeElement?.id).toBe('inner-input'); + }); + + it('should focus the innermost invalid control when a wrapping group is also ng-invalid', () => { + form.innerHTML = ` +
+ +
+ `; + + focusFirstInvalidControl(form); + + expect(document.activeElement?.id).toBe('leaf-invalid'); + }); + + it('should do nothing when no control is invalid', () => { + form.innerHTML = ``; + const initialActiveElement = document.activeElement; + + focusFirstInvalidControl(form); + + expect(document.activeElement).toBe(initialActiveElement); + }); + + it('should do nothing when the invalid element has no focusable target', () => { + form.innerHTML = `
not focusable
`; + const initialActiveElement = document.activeElement; + + focusFirstInvalidControl(form); + + expect(document.activeElement).toBe(initialActiveElement); + }); +}); diff --git a/packages/ng/form/focus-first-invalid.ts b/packages/ng/form/focus-first-invalid.ts new file mode 100644 index 0000000000..7e2e5fd256 --- /dev/null +++ b/packages/ng/form/focus-first-invalid.ts @@ -0,0 +1,19 @@ +const NATIVE_FOCUSABLE_SELECTOR = 'input, select, textarea, button'; +const INNER_FOCUSABLE_SELECTOR = 'input, select, textarea, button, [tabindex]:not([tabindex="-1"])'; + +export function focusFirstInvalidControl(formElement: HTMLElement): void { + // Innermost invalid elements only: an invalid FormGroup wrapper also carries ng-invalid + const firstInvalid = Array.from(formElement.querySelectorAll('.ng-invalid')).find((element) => !element.querySelector('.ng-invalid')); + if (!firstInvalid) { + return; + } + + // Custom form control hosts (e.g. lu-simple-select) carry ng-invalid but are not focusable themselves + const target = firstInvalid.matches(NATIVE_FOCUSABLE_SELECTOR) ? firstInvalid : firstInvalid.querySelector(INNER_FOCUSABLE_SELECTOR); + if (!target) { + return; + } + + target.focus(); + target.scrollIntoView({ block: 'center' }); +} diff --git a/packages/ng/form/form.component.spec.ts b/packages/ng/form/form.component.spec.ts new file mode 100644 index 0000000000..351a90fb80 --- /dev/null +++ b/packages/ng/form/form.component.spec.ts @@ -0,0 +1,121 @@ +import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { FormComponent } from './form.component'; + +@Component({ + selector: 'lu-form-host', + imports: [FormComponent, ReactiveFormsModule], + template: ` +
+ + + +
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class FormHost { + readonly focusInvalidOnSubmit = signal(true); + + readonly formGroup = new FormGroup({ + name: new FormControl('', Validators.required), + email: new FormControl('', Validators.required), + }); + + onSubmit(): void { + this.formGroup.markAllAsTouched(); + } +} + +@Component({ + selector: 'lu-default-form-host', + imports: [FormComponent, ReactiveFormsModule], + template: ` +
+ + +
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class DefaultFormHost { + readonly formGroup = new FormGroup({ + name: new FormControl('', Validators.required), + }); +} + +describe(FormComponent.name, () => { + function createHost() { + TestBed.configureTestingModule({ imports: [FormHost] }); + const fixture = TestBed.createComponent(FormHost); + fixture.detectChanges(); + const form = (fixture.nativeElement as HTMLElement).querySelector('form')!; + document.body.appendChild(fixture.nativeElement); + return { fixture, form }; + } + + afterEach(() => { + document.body.innerHTML = ''; + }); + + const flushFocusRender = () => new Promise((resolve) => setTimeout(resolve)); + + it('should focus the first invalid field when submitting an invalid form', async () => { + const { form } = createHost(); + + form.dispatchEvent(new Event('submit')); + await flushFocusRender(); + + expect(document.activeElement?.id).toBe('name'); + }); + + it('should not move focus when the form is valid', async () => { + const { fixture, form } = createHost(); + fixture.componentInstance.formGroup.setValue({ name: 'a', email: 'b' }); + fixture.detectChanges(); + const initialActiveElement = document.activeElement; + + form.dispatchEvent(new Event('submit')); + await flushFocusRender(); + + expect(document.activeElement).toBe(initialActiveElement); + }); + + it('should not move focus by default when submitting an invalid form', async () => { + TestBed.configureTestingModule({ imports: [DefaultFormHost] }); + const fixture = TestBed.createComponent(DefaultFormHost); + fixture.detectChanges(); + const form = (fixture.nativeElement as HTMLElement).querySelector('form')!; + document.body.appendChild(fixture.nativeElement); + const initialActiveElement = document.activeElement; + + form.dispatchEvent(new Event('submit')); + await flushFocusRender(); + + expect(document.activeElement).toBe(initialActiveElement); + }); + + it('should not move focus when focusInvalidOnSubmit is false', async () => { + const { fixture, form } = createHost(); + fixture.componentInstance.focusInvalidOnSubmit.set(false); + fixture.detectChanges(); + const initialActiveElement = document.activeElement; + + form.dispatchEvent(new Event('submit')); + await flushFocusRender(); + + expect(document.activeElement).toBe(initialActiveElement); + }); + + it('should focus the first invalid field even when it becomes touched only in the submit handler', async () => { + const { fixture, form } = createHost(); + fixture.componentInstance.formGroup.controls.email.setValue('b'); + fixture.detectChanges(); + + form.dispatchEvent(new Event('submit')); + await flushFocusRender(); + + expect(document.activeElement?.id).toBe('name'); + }); +}); diff --git a/packages/ng/form/form.component.ts b/packages/ng/form/form.component.ts index 1ac6026402..e669dc0842 100644 --- a/packages/ng/form/form.component.ts +++ b/packages/ng/form/form.component.ts @@ -1,7 +1,8 @@ -import { ChangeDetectionStrategy, Component, forwardRef, inject, input, ViewEncapsulation } from '@angular/core'; +import { afterNextRender, ChangeDetectionStrategy, Component, ElementRef, forwardRef, inject, Injector, input, ViewEncapsulation } from '@angular/core'; import { luBooleanAttribute } from '@lucca-front/ng/core'; import { LU_FORM_INSTANCE } from './form-instance'; import { LuDialogRef } from '@lucca-front/ng/dialog'; +import { focusFirstInvalidControl } from './focus-first-invalid'; @Component({ // eslint-disable-next-line @angular-eslint/component-selector @@ -14,6 +15,7 @@ import { LuDialogRef } from '@lucca-front/ng/dialog'; '[class.mod-maxWidth]': 'maxWidth()', '[class.dialog-inside-formOptional]': 'dialogRef !== null', '[attr.role]': 'presentation() ? "presentation" : null', + '(submit)': 'onSubmit()', }, changeDetection: ChangeDetectionStrategy.OnPush, providers: [ @@ -26,7 +28,31 @@ import { LuDialogRef } from '@lucca-front/ng/dialog'; export class FormComponent { protected readonly dialogRef = inject(LuDialogRef, { optional: true }); + readonly #elementRef = inject>(ElementRef); + + readonly #injector = inject(Injector); + readonly maxWidth = input(false, { transform: luBooleanAttribute }); readonly presentation = input(false, { transform: luBooleanAttribute }); + + /** + * When enabled, submitting an invalid form moves focus to the first invalid field. Disabled by default. + */ + readonly focusInvalidOnSubmit = input(false, { transform: luBooleanAttribute }); + + protected onSubmit(): void { + if (!this.focusInvalidOnSubmit()) { + return; + } + afterNextRender( + () => { + const form = this.#elementRef.nativeElement; + if (form.classList.contains('ng-invalid')) { + focusFirstInvalidControl(form); + } + }, + { injector: this.#injector }, + ); + } }