Один контроллер вместо диалогов под каждый проект

09 Мар 2025

В разных проектах я снова и снова делал примерно одно и то же: находил контейнер диалога, открывал его, закрывал по кнопке или фону, переключал классы для анимации и следил, чтобы после закрытия страница вернулась в нормальное состояние.

Разметка и внешний вид каждый раз отличались, но управляющий код повторялся. Готовые библиотеки для этой задачи казались заметно тяжелее, чем сама задача, поэтому прошлым летом я решил собрать накопившийся опыт в небольшой переиспользуемый контроллер.

Так появился dialog-lite. К версии 1.5.1 его внешний API состоит из нескольких действий: создать экземпляр, вызвать init(), затем открывать и закрывать диалог через open() и close().

import DialogLite from 'dialog-lite';
import 'dialog-lite/dist/index.css';

const dialog = new DialogLite({
  closingButton: true,
  closingBackdrop: true,
});

dialog.init();

button.addEventListener('click', () => {
  dialog.open({
    stylingClass: 'dialog-lite--first-window',
  });
});

Здесь важнее не сократить пару строк, а один раз определить границу ответственности. Приложение решает, когда нужен диалог и чем он заполнен. DialogLite занимается его состоянием и повторяющейся DOM-логикой.

Не делать из контроллера готовый UI-компонент

DialogLite не создаёт разметку диалогового окна из JavaScript. Он работает с уже существующим DOM и ожидает несколько известных селекторов:

this.dialogEl = document.querySelector<HTMLDivElement>('.dialog-lite');
this.mainContentEl = document.getElementById('main-content');

if (!this.dialogEl) {
  throw new Error('Dialog element not found');
}

this.dialogCloseEl = this.dialogEl.querySelector<HTMLButtonElement>(
  '.dialog-lite-close-button',
);

this.dialogBackdropEl = this.dialogEl.querySelector<HTMLDivElement>(
  '.dialog-lite__backdrop',
);

Это оставляет содержимое окна обычной частью приложения. Внутри может быть форма, текст, кнопки или любая другая разметка. Контроллеру не нужно знать её структуру.

Из настроек конструктор принимает два флага:

const dialog = new DialogLite({
  closingButton: true,
  closingBackdrop: true,
});

Они отвечают за закрытие по кнопке и фону. init() добавляет соответствующие обработчики, а Escape работает через отдельный обработчик keydown и вызывает close() только когда окно открыто.

document.addEventListener('keydown', (event: KeyboardEvent) => {
  if (event.key === 'Escape' && this.isOpen) {
    this.close();
  }
});

Контракт с разметкой довольно жёсткий, зато сам класс остаётся маленьким. Если .dialog-lite не найден, лучше сразу получить понятную ошибку, чем экземпляр, который молча ничего не делает.

Закрытие оказалось не мгновенным действием

В самой первой версии open() и close() в основном переключали два класса:

  • dialog-lite--in для открытого состояния
  • dialog-lite--out для закрытия

Дополнительно open() может получить stylingClass. Это позволяет добавить к тому же контейнеру класс конкретного сценария:

dialog.open({
  stylingClass: 'dialog-lite--first-window',
});

Сначала закрытие выглядело просто: убрать текущий класс оформления и заменить --in на --out. При обкатке быстро выяснилось, что этого мало.

Класс оформления нельзя снимать до завершения закрывающего перехода, поэтому его удаление пришлось отложить:

if (this.currentClass) {
  if (delayRemove) {
    const classToRemove = this.currentClass;

    setTimeout(() => {
      this.dialogEl?.classList.remove(classToRemove);
      this.currentClass = '';
    }, 500);
  } else {
    this.dialogEl.classList.remove(this.currentClass);
  }
}

В 1.5.1 само закрытие тоже завершается не сразу. Сначала контроллер переводит диалог в состояние dialog-lite--out, а через 500ms окончательно скрывает его через style.display = 'none':

this.updateClassList({
  addClass: 'dialog-lite--out',
  removeClass: 'dialog-lite--in',
  newClass: '',
  delayRemove: true,
});

setTimeout(() => {
  if (this.dialogEl) {
    this.dialogEl.style.display = 'none';
  }
}, 500);

При следующем открытии display: none снимается до переключения классов. Чтение offsetWidth принудительно завершает пересчёт геометрии перед началом нового перехода:

if (this.dialogEl.style.display === 'none') {
  this.dialogEl.style.display = '';
  void this.dialogEl.offsetWidth;
}

В 1.5.1 задержка просто захардкожена на 500ms и синхронизирована с текущими стилями пакета. Фактическую продолжительность пользовательской CSS-анимации библиотека не вычисляет.

Повторный вызов тоже часть состояния

Следующая проблема проявилась там же, во время обкатки ранней версии. Пока идёт переход, open() и close() можно вызвать ещё раз.

Если разрешить такие команды без ограничений, классы начинают переключаться быстрее, чем интерфейс успевает завершить предыдущую фазу. Для небольшого контроллера я выбрал простое ограничение повторных вызовов на те же 500ms:

private isDebounced(): boolean {
  const now = Date.now();

  if (now - this.lastActionTime < 500) return true;

  this.lastActionTime = now;
  return false;
}

И open(), и close() начинают с этой проверки:

if (this.isDebounced()) return;

Повторный вызов в течение 500ms просто игнорируется. Для этой версии этого хватает. Полноценная машина состояний для такой маленькой задачи уже перебор.

Открыть и закрыть мало

По мере обкатки вокруг переключения классов добавились ещё несколько небольших обязанностей.

При открытии DialogLite отмечает основной контент как скрытый через aria-hidden, а сам диалог — как видимый:

if (this.mainContentEl) {
  this.mainContentEl.setAttribute('aria-hidden', 'true');
}

this.dialogEl.setAttribute('aria-hidden', 'false');
this.previouslyFocusedElement = document.activeElement as HTMLElement;

При закрытии атрибуты переключаются обратно, а фокус возвращается на элемент, который был активен перед открытием:

if (this.mainContentEl) {
  this.mainContentEl.setAttribute('aria-hidden', 'false');
}

this.dialogEl.setAttribute('aria-hidden', 'true');

if (this.previouslyFocusedElement) {
  this.previouslyFocusedElement.focus();
}

Полноценную модель доступности модального окна эта версия ещё не реализует. Контроллер синхронизирует aria-hidden и возвращает фокус на элемент, с которого окно было открыто.

Ещё один баг нашёлся с фокусом. Код без проверки искал [tabindex="0"] и сразу вызывал focus(). В диалоге без такого элемента он закономерно падал, поэтому добавил проверку.

У 1.5.1 остаётся довольно жёсткий контракт с приложением. DialogLite берёт первый .dialog-lite, знает селекторы кнопки закрытия и фона, при наличии работает с #main-content, а ограничение повторных вызовов и задержка закрытия зафиксированы в коде на 500ms.

До универсального движка диалогов здесь далеко, но такой цели пока и не было. Главное, что базовую логику диалога больше не нужно заново писать под каждый проект.