Angular IDPStrong Junior
Модулі / Форми / Модалки
🔥 0

Модуль 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 ігнорує повторні натискання, поки триває запит, навіть якщо кнопка чомусь лишилась активною.

≈ 45 хв · +25 XP за урок