Vue поверх DOM-контроллера, а не вместо него
08 Июн 2026
После 2.0.0 DOM API DialogLite позволяет передать готовый элемент или селектор, настроить закрытие, фокус и прокрутку, а затем явно уничтожить контроллер через destroy().
Для обычного JavaScript этого хватает. Но в моих рабочих проектах много Vue и Nuxt, и там вокруг такого API неизбежно появляется фреймворковая обвязка. DOM-элемент доступен только после монтирования, состояние открытия хочется видеть как реактивное значение, а при размонтировании компонента нужно снять обработчики и очистить таймеры.
В 2.1.0 эта обвязка переехала в сам пакет. Переписывать DialogLite как Vue-компонент при этом не хотелось: основной контроллер уже решает свою задачу без фреймворка.
Теперь у пакета есть вторая точка входа:
import { useDialogLite, DialogLiteRoot } from 'dialog-lite/vue';
Обычный импорт остаётся прежним:
import { initDialogLite } from 'dialog-lite';
Отдельная точка входа для Vue
Vue API можно было экспортировать прямо из корневого dialog-lite. Пользователю не пришлось бы помнить про дополнительный путь импорта, но тогда Vue оказался бы частью контракта пакета даже для тех, кому нужен только DOM-контроллер.
В 2.1.0 поле exports разделено:
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.es.js",
"require": "./dist/index.cjs"
},
"./vue": {
"types": "./dist/vue.d.ts",
"import": "./dist/vue.es.js",
"require": "./dist/vue.cjs"
}
}
}
Vue остаётся peer-зависимостью, а через peerDependenciesMeta помечена как необязательная:
{
"peerDependencies": {
"vue": "^3.3.0"
},
"peerDependenciesMeta": {
"vue": {
"optional": true
}
}
}
В сборку Vue не включается: адаптер использует Vue из приложения.
Мне нравится такая граница: основной пакет ничего не знает о ref, v-model и жизненном цикле Vue.
Composable для своей разметки
Не во всех проектах хочется, чтобы библиотека задавала структуру диалога. Часто разметка уже принадлежит приложению, особенно если внутри диалога есть формы, сложные слоты или проектные компоненты.
Для таких случаев появился useDialogLite():
<script setup lang="ts">
import { ref } from 'vue';
import { useDialogLite } from 'dialog-lite/vue';
import 'dialog-lite/dialog-lite.css';
const dialogRef = ref<HTMLElement | null>(null);
const { isOpen, open, close } = useDialogLite(dialogRef, {
closingBackdrop: true,
});
</script>
<template>
<button type="button" @click="open()">
Open
</button>
<div
ref="dialogRef"
class="dialog-lite dialog-lite--out"
hidden
aria-hidden="true"
>
<div class="dialog-lite__backdrop"></div>
<div class="dialog-lite__container">
<div class="dialog-lite__container-inner">
<button type="button" @click="close">Close</button>
</div>
</div>
</div>
</template>
Composable принимает Vue Ref с элементом или функцию, которая этот элемент возвращает. Автоматическая инициализация происходит после монтирования, когда DOM уже доступен.
CSS в этом примере подключён отдельно: Vue-адаптер сам его не внедряет. Настройка mainContent оставлена по умолчанию. В 2.1.0 значение null не отключает поиск #main-content, потому что контроллер заменяет его значением по умолчанию.
Жизненный цикл сводится к знакомой схеме:
onMounted(() => {
init();
});
onScopeDispose(() => {
destroy();
});
Это именно та обвязка, которую не хочется повторять в каждом компоненте. init() получает элемент из ref и передаёт его обычному DialogLite через параметр dialog, а destroy() запускает очистку DOM-контроллера.
open() и close() делегируют работу DialogLite, а адаптер добавляет реактивное состояние:
const isOpen = ref(false);
Чтобы состояние не расходилось с контроллером, composable использует колбэки основного API. После open значение становится true, после close — false. Пользовательские onOpen и onClose при этом продолжают вызываться.
Компонент для стандартной обёртки
Composable оставляет разметку приложению. Но если стандартная структура DialogLite устраивает, каждый раз писать корневой элемент, фон и контейнер тоже не хочется.
Для этого появился DialogLiteRoot:
<script setup lang="ts">
import { ref } from 'vue';
import { DialogLiteRoot } from 'dialog-lite/vue';
import 'dialog-lite/dialog-lite.css';
const isDialogOpen = ref(false);
</script>
<template>
<button type="button" @click="isDialogOpen = true">
Open
</button>
<DialogLiteRoot
v-model="isDialogOpen"
close-on-backdrop
>
<p>Dialog content</p>
</DialogLiteRoot>
</template>
Компонент рендерит стандартную BEM-структуру, внутри использует тот же useDialogLite(), а состояние можно менять снаружи через v-model.
Синхронизация работает в обе стороны. Изменение modelValue вызывает open() или close(). Если контроллер сам закрывает окно по фону или Escape, компонент отправляет update:modelValue:
onClose: (detail) => {
emit('update:modelValue', false);
emit('close', detail);
}
Иначе легко получить ситуацию, когда окно уже закрыто, а v-model у родителя всё ещё true.
В DialogLiteRoot есть слоты: можно заменить фон или кнопку закрытия, а слот по умолчанию получает методы open, close и текущее isOpen.
Инициализация в Nuxt
В Nuxt нужно учитывать SSR: наличие Vue API ещё не означает, что DOM можно трогать на сервере.
useDialogLite() не создаёт контроллер при импорте модуля. Автоматическая инициализация происходит в onMounted(), то есть уже на клиенте. Поэтому dialog-lite/vue можно импортировать в SSR-приложении без немедленного обращения к DOM. Ручные init() и open() нужно вызывать там, где DOM уже доступен.
Это не модуль Nuxt и не отдельная SSR-абстракция. Для страницы с SSR граница остаётся простой: DialogLite инициализируется на клиенте, а сам диалог при необходимости можно поместить в <ClientOnly>.
Прятать браузерную природу библиотеки за дополнительной фреймворковой магией здесь не хочется.
Проверять теперь нужно две точки входа
Вторую точку входа тоже легко случайно сломать при сборке.
Теперь в dist должны появиться отдельные файлы Vue-адаптера:
dist/index.es.js
dist/index.cjs
dist/index.d.ts
dist/vue.es.js
dist/vue.cjs
dist/vue.d.ts
dist/dialog-lite.css
Вместе с адаптером появились Vue-тесты и smoke-проверка собранного пакета.
Один тест монтирует компонент с useDialogLite(), открывает и закрывает окно, а после размонтирования проверяет очистку. Другой проходит через DialogLiteRoot: управление через modelValue, добавление класса оформления и закрытие по фону.
Smoke-проверка работает уже с dist: проверяет наличие файлов Vue-адаптера и импортирует собранный vue.es.js, чтобы убедиться, что он экспортирует useDialogLite и DialogLiteRoot.
TypeScript-файл может без проблем компилироваться внутри репозитория, но этого мало, если пользователь потом не может импортировать его через обещанный путь.
Один контроллер, два способа интеграции
В 2.1.0 Vue не стал главным способом использования DialogLite.
При этом можно выбрать уровень обвязки. useDialogLite() подходит, когда разметка принадлежит приложению. DialogLiteRoot — когда стандартная структура устраивает и хочется сократить шаблонный код.