Жёсткий DOM-контракт начал мешать

16 Дек 2025

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

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

В 2.0.0 я решил провести эту границу заново. DialogLite по-прежнему не создаёт UI и не требует своей системы компонентов, но теперь может работать не только с одной заранее известной DOM-структурой.

Базовая инициализация выглядит так:

import { initDialogLite } from 'dialog-lite';

const dialog = initDialogLite({
  dialog: '#settings-dialog',
  mainContent: '#page',
  closingButton: true,
  closingBackdrop: true,
  hideDelayMs: 300,
  debounceMs: 300,
});

Корневой элемент больше не нужно переименовывать в .dialog-lite, а основную страницу — подгонять под #main-content. Дефолты остались для простого старта, но перестали быть обязательной частью интеграции.

Передавать элемент или селектор

В старом контроллере поиск DOM был зашит прямо в init():

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

В 2.0.0 корневой элемент диалога и основной контент принимают либо CSS-селектор, либо готовый HTMLElement:

export type DialogLiteOptions = {
  dialog?: HTMLElement | string;
  mainContent?: HTMLElement | string | null;
  closeButtonSelector?: string;
  backdropSelector?: string;
  // ...
};

Разрешение этих значений сведено к одной функции:

private resolveHTMLElement(
  input: HTMLElement | string | null | undefined,
): HTMLElement | null {
  if (input == null) return null;
  if (typeof input === 'string') {
    return document.querySelector<HTMLElement>(input);
  }

  return input;
}

Так не приходится выбирать один способ интеграции. На простой странице удобнее передать селектор. Если приложение уже хранит ссылки на DOM-элементы, повторно искать их через document.querySelector() не нужно.

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

const dialog = initDialogLite({
  dialog: dialogElement,
  closeButtonSelector: '[data-dialog-close]',
  backdropSelector: '[data-dialog-backdrop]',
  closingButton: true,
  closingBackdrop: true,
});

Контроллер всё ещё знает, какие роли ему нужны, но конкретные имена классов больше не навязывает.

Время анимации тоже часть интеграции

Раньше 500ms одновременно ограничивали повторные вызовы open() и close() и задавали задержку перед окончательным скрытием окна. Это было связано с базовыми стилями пакета, но в коде выглядело как универсальная константа.

Теперь оба значения задаются отдельно:

this.options = {
  // ...
  debounceMs: options.debounceMs ?? 500,
  hideDelayMs: options.hideDelayMs ?? 500,
};

Контроллер всё ещё использует простую временную модель и не вычисляет реальную длительность произвольной CSS-анимации. Но теперь приложение может согласовать JavaScript со своим CSS-переходом, не меняя исходники библиотеки.

Заодно я отказался от переключения display: none через style.display. В 2.0.0 состояние видимости выражается через стандартный атрибут hidden:

public open({ stylingClass = '' }: OpenOptions = {}): void {
  // ...
  this.dialogEl.hidden = false;
  void this.dialogEl.offsetWidth;
  // ...
}

public close(): void {
  // ...
  this.hideTimeout = window.setTimeout(() => {
    if (this.dialogEl) {
      this.dialogEl.hidden = true;
    }
  }, this.options.hideDelayMs);
}

Начальная разметка теперь может сразу описывать закрытое состояние:

<div
  id="settings-dialog"
  class="dialog-lite dialog-lite--out"
  hidden
  aria-hidden="true"
>
  <!-- content -->
</div>

hidden отвечает за видимость элемента, а классы --in и --out остаются за визуальными фазами.

У инициализации появился обратный путь

В первой версии init() добавлял обработчики, после чего экземпляр предполагалось просто использовать дальше. Для локального скрипта этого достаточно, но переиспользуемому контроллеру нужен и обратный путь.

Поэтому появился destroy():

public destroy(): void {
  this.abortController?.abort();
  this.abortController = null;
  this.clearTimers();
  this.unlockScroll();
}

Все обработчики, которые создаёт init(), получают один AbortSignal:

this.abortController = new AbortController();
const signal = this.abortController.signal;

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

Та же схема используется для кнопки закрытия, фона и обработки клавиатуры внутри окна. Очистка сводится к abort(), без отдельного removeEventListener() для каждого обработчика.

init() сначала вызывает destroy(), затем заново находит DOM-элементы и подключает обработчики:

public init(): void {
  this.destroy();
  this.resolveElementsOrThrow();

  this.abortController = new AbortController();
  // attach listeners
}

Так повторная инициализация не накапливает обработчики.

Для обычного сценария появилась вспомогательная функция initDialogLite(), которая создаёт экземпляр и сразу вызывает init():

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

Классический вариант остался:

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

dialog.init();

Явный destroy() никуда не исчезает, просто для частого сценария есть короткий путь.

Прокрутка и фокус больше не побочные детали

Открытое модальное окно обычно должно временно остановить прокрутку страницы. Раньше DialogLite этим не занимался, и блокировку прокрутки приходилось прикручивать снаружи.

В 2.0.0 он включён по умолчанию, но остаётся опцией:

const dialog = initDialogLite({
  lockScroll: true,
});

При открытии контроллер сохраняет значения overflow и padding-right из body.style, вычисляет ширину полосы прокрутки и при необходимости компенсирует её через padding. После закрытия исходные значения возвращаются.

const scrollbarWidth =
  window.innerWidth - document.documentElement.clientWidth;

if (scrollbarWidth > 0) {
  const currentPadding = Number.parseFloat(
    getComputedStyle(body).paddingRight || '0',
  );

  body.style.paddingRight = `${currentPadding + scrollbarWidth}px`;
}

body.style.overflow = 'hidden';

Компенсация нужна, чтобы после исчезновения полосы прокрутки страница не сдвигалась по горизонтали.

Поведение фокуса тоже стало настраиваемым. Можно указать focusOnOpenSelector, включить или отключить trapFocus, изменить role и управление aria-modal.

Полноценной абстракции доступности из этого ещё не получается. Но поведение клавиатуры и ARIA теперь задаётся явно, а не остаётся набором предположений внутри контроллера.

События вместо дополнительной связанности

Приложению иногда нужно отреагировать на открытие или закрытие диалога. Добавлять ради каждого такого случая новый колбэк в конструктор не хотелось.

В 2.0.0 DialogLite отправляет DOM-события на самом элементе диалога:

this.dialogEl.dispatchEvent(
  new CustomEvent('dialog-lite:open', {
    detail: { stylingClass },
  }),
);

И при закрытии:

this.dialogEl.dispatchEvent(
  new CustomEvent('dialog-lite:close', {
    detail: {},
  }),
);

Их можно отключить через emitEvents: false, а при включённом поведении приложение подписывается обычным DOM API:

dialogElement.addEventListener('dialog-lite:open', () => {
  // project-specific reaction
});

Так DialogLite сообщает о событии, но не обрастает логикой конкретного приложения.

CSS можно импортировать или инжектировать

До этого пакет ожидал отдельный импорт собранного CSS. Такой вариант остался, только у файла стилей появился отдельный экспорт:

import { initDialogLite } from 'dialog-lite';
import 'dialog-lite/dialog-lite.css';

const dialog = initDialogLite({
  injectCss: false,
});

Но initDialogLite() умеет и сам добавить базовые стили. По умолчанию injectCss включён:

export function initDialogLite(options = {}): DialogLiteInstance {
  const {
    injectCss = true,
    cssText,
    cssTarget,
    ...dialogOptions
  } = options;

  if (injectCss) {
    injectDialogLiteCss({ cssText, target: cssTarget });
  }

  const instance = new DialogLite(dialogOptions);
  instance.init();

  return instance;
}

Сам CSS доступен и как строка dialogLiteCssText. injectDialogLiteCss() принимает Document | ShadowRoot, поэтому базовые стили можно положить и внутрь Shadow DOM:

injectDialogLiteCss({
  target: shadowRoot,
});

Shadow DOM здесь не становится отдельным направлением библиотеки. Просто у небольшого базового CSS теперь нет единственного способа подключения.

После этой переработки DialogLite всё ещё остаётся DOM-контроллером. Он не рендерит содержимое, не вводит дерево компонентов и не управляет бизнес-логикой окна.

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