Один контроллер вместо диалогов под каждый проект
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.
До универсального движка диалогов здесь далеко, но такой цели пока и не было. Главное, что базовую логику диалога больше не нужно заново писать под каждый проект.