Модуль 6 · урок 11 з 12
Модалки
Останній шматок цілі: «CRUD modals to add/edit grid items». Модалка виглядає простою, поки не згадаєш про фокус, клавіатуру, незбережені зміни й повернення результату в таблицю. Розберемо все це разом.
Відкрити й отримати результат
import { MatDialog } from '@angular/material/dialog';
export class TasksPage {
private readonly dialog = inject(MatDialog);
private readonly store = inject(TaskStore);
create(): void {
this.open(null);
}
edit(task: Task): void {
this.open(task);
}
private open(task: Task | null): void {
const ref = this.dialog.open<TaskFormDialog, TaskDialogData, Task | undefined>(
TaskFormDialog,
{
data: { task },
width: '560px',
disableClose: true, // закриття контролюємо самі — через перевірку змін
autoFocus: 'first-tabbable',
restoreFocus: true, // повернути фокус на кнопку, яка відкрила
},
);
ref.afterClosed().pipe(takeUntilDestroyed(this.destroyRef)).subscribe(saved => {
if (saved) this.store.reload();
});
}
}
Три параметри дженерика — це компонент, тип вхідних даних і тип результату. Завдяки їм
afterClosed() одразу типізований: у saved буде
Task | undefined, а не any.
Усередині модалки
export interface TaskDialogData {
task: Task | null;
}
@Component({ /* … */ })
export class TaskFormDialog {
private readonly ref = inject(MatDialogRef<TaskFormDialog, Task | undefined>);
private readonly data = inject<TaskDialogData>(MAT_DIALOG_DATA);
private readonly api = inject(TASK_API);
protected readonly isEdit = !!this.data.task;
protected readonly saving = signal(false);
protected readonly form = this.fb.nonNullable.group({
code: [this.data.task?.code ?? '', [Validators.required, taskCode()],
[uniqueCode(this.api, this.data.task?.code)]],
title: [this.data.task?.title ?? '', [Validators.required, Validators.maxLength(120)]],
estimate: [this.data.task?.estimateHours ?? 1, [Validators.min(0.5)]],
});
save(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.saving.set(true);
const payload = this.form.getRawValue();
const request$ = this.isEdit
? this.api.patch(this.data.task!.id, payload)
: this.api.create(payload);
request$.pipe(finalize(() => this.saving.set(false))).subscribe({
next: task => this.ref.close(task), // результат повертається батьку
error: (err: HttpErrorResponse) => this.handle(err),
});
}
cancel(): void {
if (this.form.dirty && !confirm('Є незбережені зміни. Закрити?')) return;
this.ref.close(undefined);
}
}
Незбережені зміни
// перехопити всі способи закриття, а не лише кнопку
constructor() {
this.ref.disableClose = true;
this.ref.backdropClick().pipe(takeUntilDestroyed()).subscribe(() => this.cancel());
this.ref.keydownEvents().pipe(
filter(e => e.key === 'Escape'),
takeUntilDestroyed(),
).subscribe(() => this.cancel());
}
Кнопка «Скасувати», клік по затемненню й клавіша Escape. Якщо перевірку незбережених змін
поставити лише на кнопку, решта двох шляхів тихо втратять дані. Саме тому
disableClose: true плюс власна обробка — це не педантизм.
Той самий сюжет, що й з CanDeactivate guard із модуля 2, тільки для діалогу.
Різниця в тому, що guard захищає перехід між маршрутами, а тут — закриття вікна.
Клавіатура й фокус
| Що має працювати | Як забезпечується |
|---|---|
| Фокус переходить у модалку при відкритті | autoFocus: 'first-tabbable' |
| Tab не виходить за межі модалки | CDK робить це сам — focus trap |
| Escape закриває | Штатно; при disableClose — через keydownEvents() |
| Фокус повертається на кнопку, що відкрила | restoreFocus: true |
| Скрінрідер оголошує заголовок | <h2 mat-dialog-title> плюс aria-labelledby |
| Enter у полі відправляє форму | <form (ngSubmit)> і type="submit" |
Хороша новина: більшість цього Angular CDK робить сам, якщо використати штатний діалог.
Погана: якщо писати модалку руками на position: fixed, доведеться реалізувати
пастку фокуса самому — а це помітно складніше, ніж здається. Тому для IDP бери
MatDialog.
Розмітка модалки
<h2 mat-dialog-title>{{ isEdit ? 'Редагувати задачу' : 'Нова задача' }}</h2>
<form [formGroup]="form" (ngSubmit)="save()">
<mat-dialog-content>
<div class="field">
<label for="code">Код</label>
<input id="code" formControlName="code">
<app-field-error [control]="form.controls.code" />
</div>
<!-- решта полів -->
</mat-dialog-content>
<mat-dialog-actions align="end">
<button type="button" (click)="cancel()">Скасувати</button>
<button type="submit" [disabled]="saving() || form.pending">
{{ saving() ? 'Збереження…' : 'Зберегти' }}
</button>
</mat-dialog-actions>
</form>
[disabled]="saving()" захищає від подвійного натискання. Але надійніше
зробити це на рівні потоку — exhaustMap із модуля 4 ігнорує повторні
натискання, поки триває запит, навіть якщо кнопка чомусь лишилась активною.