Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions packages/ng/form/focus-first-invalid.spec.ts
Original file line number Diff line number Diff line change
@@ -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 = `
<input id="valid" class="ng-valid" />
<input id="first-invalid" class="ng-invalid" />
<input id="second-invalid" class="ng-invalid" />
`;

focusFirstInvalidControl(form);

expect(document.activeElement?.id).toBe('first-invalid');
});

it('should focus the inner focusable element of an invalid non-focusable host', () => {
form.innerHTML = `
<lu-simple-select class="ng-invalid">
<div><input id="inner-input" tabindex="0" /></div>
</lu-simple-select>
`;

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 = `
<div class="ng-invalid">
<input id="leaf-invalid" class="ng-invalid" />
</div>
`;

focusFirstInvalidControl(form);

expect(document.activeElement?.id).toBe('leaf-invalid');
});

it('should do nothing when no control is invalid', () => {
form.innerHTML = `<input id="valid" class="ng-valid" />`;
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 = `<div class="ng-invalid"><span>not focusable</span></div>`;
const initialActiveElement = document.activeElement;

focusFirstInvalidControl(form);

expect(document.activeElement).toBe(initialActiveElement);
});
});
19 changes: 19 additions & 0 deletions packages/ng/form/focus-first-invalid.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>('.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<HTMLElement>(INNER_FOCUSABLE_SELECTOR);
if (!target) {
return;
}

target.focus();
target.scrollIntoView({ block: 'center' });
}
121 changes: 121 additions & 0 deletions packages/ng/form/form.component.spec.ts
Original file line number Diff line number Diff line change
@@ -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: `
<form luForm [formGroup]="formGroup" [focusInvalidOnSubmit]="focusInvalidOnSubmit()" (submit)="onSubmit()">
<input id="name" formControlName="name" />
<input id="email" formControlName="email" />
<button id="save" type="submit">Save</button>
</form>
`,
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: `
<form luForm [formGroup]="formGroup">
<input id="name" formControlName="name" />
<button id="save" type="submit">Save</button>
</form>
`,
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<HTMLFormElement>('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<HTMLFormElement>('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');
});
});
28 changes: 27 additions & 1 deletion packages/ng/form/form.component.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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: [
Expand All @@ -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<HTMLFormElement>>(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 },
);
}
}
Loading