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 },
+ );
+ }
}