Сервис развивается: тестируем формат, собираем идеи, улучшаем сервис. Есть идеи?

Написать
Войти
Дайджесты
Иллюстрация к статье о создании доступных модальных окон на HTML dialog

Доступные модальные окна на HTML `<dialog>`: от фокус-трапов до CSS-анимаций

Использование нативного элемента HTML

с методом showModal() полностью решает задачи перехвата фокуса, отображения в верхнем слое и закрытия по клавише Escape. Показываем создание изолированного Lit-компонента с доступным наименованием, закрытием light-dismiss и CSS-анимациями.

Доступные модальные окна на HTML : от фокус-трапов до CSS-анимаций

Создание модальных диалоговых окон — одна из наиболее частых и одновременно сложных задач во фронтенд-разработке. На первый взгляд модальное окно кажется простым элементом: достаточно отобразить прямоугольный блок поверх содержимого страницы, затемнить задний фон и добавить кнопку закрытия. Однако при реальной эксплуатации возникает множество скрытых проблем доступности и взаимодействия. Пользователи клавиатурной навигации случайно переходят по ссылкам под затемненным фоном, скринридеры не зачитывают заголовок окна, страница продолжается прокручиваться колесиком мыши, а при закрытии окна фокус ввода теряется и возвращается в начало документа.

Долгое время решение этих проблем требовало написания громоздких JavaScript-скриптов и подключения сторонних библиотек перехвата фокуса (focus-trap). Появление нативного HTML-элемента <dialog> кардинально изменило подход к созданию модальных окон, перенеся ключевую ответственность на уровень самого браузера.

Проблемы кастомных модальных окон и преимущества платформенного элемента

При использовании традиционных кастомных модальных окон на базе элементов <div> разработчикам приходится вручную эмулировать сложное поведение:

  • Управление слоями (z-index): попытки перекрыть сторонние виджеты и выпадающие меню часто приводят к бесконечной гонке значений z-index.
  • Изоляция фокуса (Focus Trap): перехват нажатий клавиши Tab для предотвращения выхода фокуса за пределы окна требует непрерывного отслеживания фокуса в DOM.
  • Доступность (Accessibility): необходимость вручную расставлять атрибуты aria-modal, role="dialog" и управлять состоянием aria-hidden или inert для всего остального дерева элементов.

Нативный элемент <dialog>, вызванный с помощью метода showModal(), автоматически решает эти задачи на уровне движка браузера:

  1. Элемент помещается в специальный верхний слой браузера (top layer), гарантированно отображаясь поверх любых элементов документа без использования z-index.
  2. Задний фон автоматически затемняется псевдоэлементом ::backdrop.
  3. Весь остальной документ становится инертным (inert), блокируя клики и переход фокуса.
  4. Браузер автоматически обрабатывает нажатие клавиши Escape и возвращает фокус на элемент, открывший диалог.

Упаковка в переиспользуемый веб-компонент на базе Lit

Чтобы использовать нативный диалог в крупных приложениях без дублирования верстки, его удобно обернуть в переиспользуемый веб-компонент. Библиотека Lit предоставляет удобный декораторный синтаксис над стандартом Web Components, сохраняя минимальный объем служебного кода.

Определение компонента диалога на TypeScript:

import { LitElement, html, css } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';

@customElement('app-dialog')
export class AppDialog extends LitElement {
  @property({ type: String }) heading = '';
  @property({ type: Boolean }) dismissible = false;

  @query('dialog') private $dialog!: HTMLDialogElement;

  static styles = css`
    dialog {
      border: none;
      border-radius: var(--app-dialog-radius, 12px);
      padding: 1.5rem;
      max-width: 32rem;
      width: calc(100% - 2rem);
    }
  `;

  async show(): Promise<void> {
    await this.updateComplete;
    if (!this.$dialog.open) {
      this.$dialog.showModal();
    }
  }

  close(returnValue = ''): void {
    if (this.$dialog?.open) {
      this.$dialog.close(returnValue);
    }
  }

  render() {
    return html`
      <dialog
        aria-labelledby="title"
        closedby=${this.dismissible ? 'any' : 'closerequest'}
        @close=${this.handleClose}
        @click=${this.handleClick}
      >
        <h2 id="title">${this.heading}</h2>
        <slot></slot>
      </dialog>
    `;
  }
}

Передача результатов закрытия и типизация событий в TypeScript

Модальное окно должно сообщать вызвавшему его коду о принятом пользователем решении. Нативный элемент <dialog> сохраняет результат в свойстве returnValue.

Внутри веб-компонента обработчик события закрытия генерирует пользовательское событие dialog-close:

export interface AppDialogCloseDetail {
  returnValue: string;
}

private handleClose = (): void => {
  this.dispatchEvent(
    new CustomEvent<AppDialogCloseDetail>('dialog-close', {
      detail: { returnValue: this.$dialog.returnValue },
      bubbles: true,
      composed: true,
    })
  );
};

При нажатии на кнопки внутри слота атрибут data-close позволяет автоматизировать передачу значения:

private handleClick = (event: MouseEvent): void => {
  const $button = event
    .composedPath()
    .find((el): el is HTMLButtonElement => 
      el instanceof HTMLButtonElement && el.hasAttribute('data-close')
    );
  if ($button && !$button.disabled) {
    this.close($button.value);
  }
};

Требования к доступности WAI-ARIA, доступные имена и начальный фокус

Для обеспечения полной доступности модального окна необходимо соблюдать следующие правила:

  1. Доступное имя (Accessible Name): атрибут aria-labelledby="title" связывает контейнер диалога с заголовком <h2>. Без этого скринридер зачитает только обезличенное слово «диалог».
  2. Безопасный начальный фокус: при открытии опасных или деструктивных окон (например, подтверждение удаления аккаунта) фокус следует устанавливать на безопасную кнопку отмены (Cancel) с помощью атрибута autofocus. Это предотвратит случайное нажатие кнопки удаления при быстрой клавиатурной печати.

Настройка способов закрытия: Escape, кнопки и параметр light-dismiss

Существует три основных пути закрытия модального окна:

  • Нажатие клавиши Escape (автоматически обрабатывается showModal()).
  • Клики по явным кнопкам внутри окна (data-close).
  • Клик по затемненному фону вне окна (light-dismiss).

Атрибут closedby="any" включает автоматический light-dismiss на уровне браузера. Однако закрытие по клику на фон следует включать осмысленно с помощью свойства dismissible. Для критически важных диалогов подтверждения закрытие по случайному клику мимо окна вредно, так как приводит к потере введенных данных.

Трехсоставная модель CSS-анимаций с , @starting-style и reduced motion

Анимирование нативного диалога ранее вызвало сложности из-за мгновенного переключения свойства display: none. Современные стандарты CSS решают это с помощью двух возможностей: transition-behavior: allow-discrete и правила @starting-style.

Для плавного появления и исчезновения используется трехсоставная модель стилей:

dialog {
  transition:
    opacity 0.2s ease,
    translate 0.2s ease,
    display 0.2s ease allow-discrete,
    overlay 0.2s ease allow-discrete;
}

/* 1. Состояние закрыто (и при закрытии) */
dialog:not(:open) {
  opacity: 0;
  translate: 0 8px;
}

/* 2. Состояние открыто */
dialog:open {
  opacity: 1;
  translate: 0 0;
}

/* 3. Начальное состояние перед анимацией появления */
@starting-style {
  dialog:open {
    opacity: 0;
    translate: 0 8px;
  }
}

/* Отключение анимаций при соответствующих настройках ОС */
@media (prefers-reduced-motion: reduce) {
  dialog, dialog::backdrop {
    transition: none;
  }
}

Подробный разбор поведения доступности и спецификаций доступен в документации MDN по элементу dialog.