Vue поверх DOM-контроллера, а не вместо него

08 Июн 2026

После 2.0.0 у DialogLite уже есть достаточно самостоятельный DOM API. Можно передать готовый элемент или селектор, настроить закрытие, фокус и прокрутку, а затем явно уничтожить контроллер через destroy().

Для обычного JavaScript этого хватает. Но в рабочих проектах много Vue и Nuxt, и там вокруг такого API неизбежно появляется фреймворковая обвязка. DOM-элемент доступен только после монтирования, состояние открытия хочется видеть как реактивное значение, а при размонтировании компонента нужно снять обработчики и очистить таймеры.

В 2.1.0 эта обвязка переехала в сам пакет. Переписывать DialogLite как Vue-компонент при этом не хотелось: основной контроллер уже решает свою задачу без фреймворка.

Теперь у пакета есть вторая точка входа:

import { useDialogLite, DialogLiteRoot } from 'dialog-lite/vue';

Обычный импорт остаётся прежним:

import { initDialogLite } from 'dialog-lite';

Vue здесь отдельный слой поверх DOM-контроллера, а не новая основа библиотеки.

Отдельная точка входа вместо 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 не включается и остаётся внешней зависимостью.

Для обычного DOM API остаётся dialog-lite. Vue-интеграция подключается отдельно через dialog-lite/vue, а сам Vue уже приходит из приложения.

Мне нравится такая граница: основной пакет ничего не знает о ref, v-model и жизненном цикле Vue. Всё фреймворковое остаётся в адаптере.

Composable для своей разметки

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

Для таких случаев появился useDialogLite():

<script setup lang="ts">
import { ref } from 'vue';
import { useDialogLite } from 'dialog-lite/vue';

const dialogRef = ref<HTMLElement | null>(null);

const { isOpen, open, close } = useDialogLite(dialogRef, {
  closingBackdrop: true,
  mainContent: null,
});
</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 с элементом или функцию, которая этот элемент возвращает. Экземпляр DialogLite создаётся только после монтирования, когда DOM уже доступен.

Жизненный цикл сводится к знакомой схеме:

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 продолжают вызываться дальше.

То есть второй жизненный цикл модального окна внутри Vue-слоя не появляется. Адаптер только синхронизирует состояние с уже существующим контроллером.

Компонент для стандартной обёртки

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

Для этого появился DialogLiteRoot:

<script setup lang="ts">
import { ref } from 'vue';
import { DialogLiteRoot } from 'dialog-lite/vue';

const isDialogOpen = ref(false);
</script>

<template>
  <button type="button" @click="isDialogOpen = true">
    Open
  </button>

  <DialogLiteRoot
    v-model="isDialogOpen"
    close-on-backdrop
    :main-content="null"
  >
    <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 есть отдельная деталь: сам факт наличия Vue API ещё не означает, что DOM можно трогать во время SSR.

useDialogLite() не создаёт контроллер при импорте модуля. Инициализация происходит в onMounted(), то есть уже на клиенте. Поэтому dialog-lite/vue можно импортировать в SSR-приложении без немедленного обращения к DOM.

Это не модуль Nuxt и не отдельная SSR-абстракция. Для страницы с SSR граница остаётся простой: DialogLite инициализируется на клиенте, а сам диалог при необходимости можно поместить в <ClientOnly>.

Прятать браузерную природу библиотеки за дополнительной фреймворковой магией здесь не хочется. Vue-адаптер просто подключает DOM-контроллер в подходящий момент жизненного цикла.

Проверять теперь нужно две точки входа

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

Теперь в 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. Основной пакет по-прежнему работает с DOM и доступен через dialog-lite, а интеграция с Vue живёт отдельно в dialog-lite/vue.

При этом можно выбрать уровень обвязки. useDialogLite() подходит, когда разметка принадлежит приложению. DialogLiteRoot — когда стандартная структура устраивает и хочется сократить шаблонный код.

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