Жёсткий 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-контроллером. Он не рендерит содержимое, не вводит дерево компонентов и не управляет бизнес-логикой окна.
Но дефолты теперь действительно работают как дефолты, а не как скрытые требования. Старую схему можно оставить почти без изменений или передать свои элементы, селекторы, тайминги и часть поведения модального окна через настройки.