GrModal

Пакет: @feugene/granularityядроГруппа: Слои

Берут, когда своя раскладка поверх страницы.

Когда брать

  • своя раскладка поверх страницы — галерея, конструктор, мастер во весь экран: шапки и подвала здесь нет намеренно;
  • нужен только модальный слой — бэкдроп, блокировка скролла, ловушка фокуса, Esc и стек слоёв;
  • строится свой компонент-оверлей — это тот же примитив, на котором стоят GrDialog, GrDrawer и палитра команд;
  • прокрутка ведёт себя нестандартноscrollBehavior переносит её с тела на весь слой.

Когда взять другое

НужноБерите
Обычное окно «шапка — тело — подвал»GrDialog
Спросить «да/нет»GrConfirmDialog
Запросить одно значениеGrPromptDialog
Панель у края экранаGrDrawer
Просмотр изображения во весь экранGrImageViewer

Второй реализации модального слоя в пакете нет и заводить её не нужно: стек, inert для фона и возврат фокуса на триггер приезжают отсюда во все оверлеи сразу.

Имя окна обязательно

Модальный слой без доступного имени — нарушение, которое диктор озвучивает как безымянный «диалог», а axe ловит правилом aria-dialog-name. Порядок такой:

  1. есть #title (или заголовок GrDialog глубже по дереву) — имя даёт он через aria-labelledby;
  2. нет #title, но есть ariaLabel — имя берётся из пропа;
  3. нет ничего — подставляется обобщённое имя из локали (gr.modal.title), а в dev-сборке при первом открытии печатается предупреждение.

Третий пункт — страховка, а не режим работы: обобщённое «Диалог» лучше пустоты, но осмысленное имя знает только автор окна.

<GrModal v-model="open" aria-label="Импорт из CSV">
  <ImportWizard />
</GrModal>

Размер

size — от sm до xl меняет только максимальную ширину панели; поля вокруг окна и скругления остаются.

full — отдельный режим: панель занимает вьюпорт целиком, без полей оболочки и без скруглений. Это «во весь экран», а не «очень широко»: мастер импорта на мобильном должен занимать экран, а не оставлять рамку в четыре пикселя.

Панель без отступов — это решение, а не недоделка

Панель окна — рамка и ничего больше: граница, фон, тень, радиус и обрезка по нему (panelBase в grModalStyles.ts). Слоты #title, #header, #footer и содержимое рендерятся как есть, без единого паддинга, поэтому голый GrModal выглядит «в край».

Отступы внутри панели пришлось бы отменять всякий раз, когда содержимое обязано доходить до края: изображение или карта во всю ширину, таблица со своей сеткой, тулбар, лента шагов с полосой на всю панель. Отмена делается отрицательными марджинами, и они разъезжаются с радиусом и с overflow-hidden панели. Обратная сторона — добавить поля тому, у кого их нет, — стоит один контейнер и ничего не ломает.

Поэтому поля, шапка и подвал живут этажом выше — в GrDialog: px-5 по горизонтали и py-3 / py-5 / py-4 для шапки, тела и подвала (dialogShared.ts), каждый настраивается через headerConfig / bodyConfig / footerConfig. Разделение то же, что у GrCard и его секций: механика в одном компоненте, ритм — в другом.

Скролл длинного содержимого

scrollBehavior:

  • outside (по умолчанию) — скроллится весь оверлей, окно уезжает вверх вместе со страницей;
  • inside — панель ограничена высотой вьюпорта, а скроллится только её тело. Слоты #title, #description, #header и #footer при этом остаются на месте: панель становится колонкой, и заголовок не уезжает вместе с содержимым.

Скролл всегда ровно в одном месте: два скроллбара на одно окно — это баг, а не запас прочности.

Закрепить свою шапку и свой подвал можно слотами #header и #footer: они лежат вне скроллящегося тела и при inside остаются на месте. Своей разметки примитив в них не добавляет — это пустые области раскладки, которыми пользуется GrDialog. Тело при inside попадает в таб-порядок: длинный текст без единого фокусируемого элемента иначе не прокрутить с клавиатуры.

Esc, слой и фокус

Окно регистрируется в общем стеке слоёв (useOverlayLayer, modal: true). Стек гасит Escape в capture-фазе на window, поэтому:

  • закрывается верхний слой, а не тот, что оказался ниже по документу: дропдаун, открытый внутри окна, по Esc закрывает себя, а следующий Esc — уже окно;
  • до локальных обработчиков нажатие не доходит вовсе, поэтому closeOnEsc и closeOnBackdrop не пересекаются: первый про Esc, второй про клик.

Нижние открытые окна помечаются inert, чтобы ловушка фокуса нижнего не отбирала фокус у верхнего. Диалоги useDialogService монтируются отдельным render() в body и всё равно попадают в тот же стек — в этом его смысл.

Оттуда же берётся и высота: каждое открытое окно получает свой уровень внутри --gr-z-modalcalc(var(--gr-z-modal) + глубина). Иначе «верхнее» для отрисовки и «верхнее» для inert расходятся: порядок узлов в портале задаёт создание компонента, и статически объявленный диалог, открытый позже, оказывался бы под окном, оставаясь при этом единственным, кто отвечает на клики (см. ../z-index.md).

Пока окно открыто, остальное содержимое body тоже уходит в inert и aria-hidden: перекрытия мало, иначе Tab уводит на страницу под окном, а диктор читает её как обычную. Корни других слоёв (тосты, панель селекта, открытого изнутри окна) при этом не гасятся.

Ловушка фокусаuseFocusTrap (публичный композабл пакета): Tab ходит по кругу внутри окна, а утёкший фокус возвращается. Панели, открытые изнутри окна, телепортированы в body и лежат вне его поддерева — ловушка знает о них от стека слоёв и фокус у них не отбирает.

initialFocus задаёт элемент, получающий фокус при открытии. По умолчанию это сама панель (tabindex="-1"): диктор объявляет окно целиком, а первый Tab приводит в начало содержимого. Фокус, который поставило содержимое окна в тот же такт (GrConfirmDialog наводит на «Отмену», GrPromptDialog — на поле), ловушка не перебивает.

Клик по подложке

Закрывает только клик, начавшийся на подложке. Выделение текста, начатое в панели и отпущенное за её границей, окно не закрывает — иначе аккуратная работа с текстом внутри окна оборачивалась бы его потерей.

Императивный API

open(), close() и toggle() доступны через ref на компоненте — тот же состав, что у GrDropdown, GrDialog, GrCommandPalette и GrPopover.

<GrModal ref="modal" v-model="open" aria-label="Настройки">

</GrModal>

<script setup>
const modal = ref()
modal.value.open()
</script>

Окно управляемое, поэтому методы просят родителя: они эмитят update:modelValue, а состояние остаётся в его v-model. Без привязки модели вызов ничего не откроет — своего состояния у окна нет намеренно, иначе оно разошлось бы с источником правды.

Жизненный цикл

opened и closed эмитятся после анимации. closed — единственный безопасный момент, чтобы размонтировать содержимое: сделать это по update:modelValue значит оборвать анимацию закрытия на полпути.

Playground 5

Загружается…

Код
<GrModal />

Установка

npm i @feugene/granularity

Импорт

import { GrModal } from '@feugene/granularity/components/GrModal'

API

Props

PropTypeпо умолчаниюОписание
size"sm" | "md" | "lg" | "xl" | "full" | undefinedundefined
ariaLabelstring | undefinedundefinedДоступное имя окна, когда заголовка в слоте `#title` нет. Слот сильнее: при нём имя даёт `aria-labelledby`.
closeOnBackdropboolean | undefinedtrue
closeOnEscboolean | undefinedtrue
scrollBehaviorGrModalScrollBehavior | undefined"outside"Кто скроллится при длинном содержимом: весь оверлей (`outside`) или сама панель (`inside` — окно остаётся на месте, шапка и подвал на виду).
initialFocusHTMLElement | null | undefinednullЭлемент, получающий фокус при открытии. По умолчанию — сама панель: она фокусируема программно (`tabindex="-1"`), диктор объявляет окно целиком, а первый Tab приводит в начало содержимого.
modelValueобязательныйboolean

Slots

SlotTypeОписание
defaultany
titleany
descriptionany
headeranyЗакреплённая шапка: при `inside` остаётся на месте, скроллится только тело.
footeranyЗакреплённый подвал: там же, где и шапка, — вне скроллящегося тела.

Events

EventTypeОписание
update:modelValue[value: boolean]
opened[]
closed[]

Methods / Expose

Methods / ExposeTypeОписание
open() => void
close() => void
toggle() => void

Примеры 7

Голый модальный слой

Базовый сценарий для GrModal: минимальный контейнер, открытие по кнопке и явное закрытие из пользовательского контента.

Basic Flow
<script setup lang="ts">
import { ref } from 'vue'

import { GrButton, GrModal } from '@feugene/granularity'

const open = ref(false)
</script>

<template>
  <div class="grid gap-3">
    <GrButton class="justify-self-start" @click="open = true">
      Open bare modal
    </GrButton>

    <!-- Модальный слой обязан иметь имя: заголовок здесь свёрстан в теле,
         поэтому имя отдаём пропом. Альтернатива — слот #title. -->
    <GrModal
      v-model="open"
      size="sm"
      aria-label="Bare modal shell"
    >
      <div class="grid gap-3">
        <div class="text-sm font-semibold text-[var(--gr-fg)]">
          Bare modal shell
        </div>
        <div class="text-sm text-[var(--gr-muted-fg)]">
          `GrModal` handles the overlay, focus trap and panel sizing — you assemble the content yourself.
        </div>
        <GrButton class="justify-self-start" @click="open = false">
          Close
        </GrButton>
      </div>
    </GrModal>
  </div>
</template>

Панель без внутренних отступов — так и задумано: GrModal даёт только рамку и механику окна, а содержимое кладёт как есть. Поля, шапку и подвал добавляет GrDialog поверх него.

Защита бэкдропа для критичных операций

Показываем closeOnBackdrop=false для кейсов, где нельзя случайно потерять прогресс черновика или подтверждения.

Try clicking the backdrop: the modal stays open until the user picks an explicit action.

Backdrop Guard
<script setup lang="ts">
import { ref } from 'vue'

import { GrButton, GrModal } from '@feugene/granularity'

const open = ref(false)
</script>

<template>
  <div class="grid gap-3">
    <div class="text-sm text-[var(--gr-muted-fg)]">
      Try clicking the backdrop: the modal stays open until the user picks an explicit action.
    </div>

    <GrButton variant="outline" class="justify-self-start" @click="open = true">
      Open guarded modal
    </GrButton>

    <GrModal
      v-model="open"
      :close-on-backdrop="false"
      size="md"
      aria-label="Draft protection"
    >
      <div class="grid gap-4">
        <div class="grid gap-1">
          <div class="text-sm font-semibold text-[var(--gr-fg)]">
            Draft protection
          </div>
          <div class="text-sm text-[var(--gr-muted-fg)]">
            Use this mode for wizard/confirm flows where a draft must not be lost by accident.
          </div>
        </div>

        <div class="rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-muted)]/40 p-3 text-sm text-[var(--gr-muted-fg)]">
          Unsaved changes: pricing rules, SLA exceptions, recipients.
        </div>

        <div class="flex flex-wrap gap-3">
          <GrButton variant="outline" @click="open = false">
            Cancel
          </GrButton>
          <GrButton @click="open = false">
            Save draft
          </GrButton>
        </div>
      </div>
    </GrModal>
  </div>
</template>

Этот сценарий полезен для проверки focus-trap и поведения backdrop в критичных формах/confirm flows.

Размеры под разное содержимое

Изолируем влияние size на layout: один и тот же entry point может открывать compact review или широкую review-панель.

Size Switcher
<script setup lang="ts">
import { ref } from 'vue'

import { GrButton, GrModal } from '@feugene/granularity'

const activeSize = ref<'sm' | 'lg'>('sm')
const open = ref(false)

function openWithSize(size: 'sm' | 'lg') {
  activeSize.value = size
  open.value = true
}
</script>

<template>
  <div class="grid gap-3">
    <div class="flex flex-wrap gap-3">
      <GrButton variant="outline" @click="openWithSize('sm')">
        Compact review
      </GrButton>
      <GrButton @click="openWithSize('lg')">
        Wide review
      </GrButton>
    </div>

    <GrModal
      v-model="open"
      :size="activeSize"
      :aria-label="`Active size: ${activeSize}`"
    >
      <div class="grid gap-4">
        <div class="flex items-center justify-between gap-3">
          <div>
            <div class="text-sm font-semibold text-[var(--gr-fg)]">
              Active size: {{ activeSize }}
            </div>
            <div class="text-sm text-[var(--gr-muted-fg)]">
              The same flow can scale for review, preview or a multi-column payload.
            </div>
          </div>
        </div>

        <div class="grid gap-3 sm:grid-cols-2">
          <div class="rounded-2xl border border-[var(--gr-brd)] p-3 text-sm">
            Summary block
          </div>
          <div class="rounded-2xl border border-[var(--gr-brd)] p-3 text-sm">
            Secondary block
          </div>
        </div>

        <GrButton class="justify-self-start" @click="open = false">
          Done
        </GrButton>
      </div>
    </GrModal>
  </div>
</template>

Императивные диалоги из открытого окна

Запускаем useDialogService (confirm / alert / prompt) прямо из открытой GrModal. Сервис монтирует собственный host в document.body поверх окна, поэтому закрытие диалога не закрывает исходную модалку — решение возвращается через Promise.

An open `GrModal` invokes the imperative `useDialogService`. The service mounts its own host in `document.body` on top of the modal, so closing confirm/alert/prompt does not close the source window — it stays open, and the user's decision is returned through a `Promise`.

Dialog Service
<script setup lang="ts">
import { ref } from 'vue'

import { GrButton, GrModal, useDialogService } from '@feugene/granularity'

const dialog = useDialogService()

const open = ref(false)
const log = ref<string[]>([])

function pushLog(message: string): void {
  log.value = [message, ...log.value].slice(0, 5)
}

// confirm -> Promise<boolean>. Открытая модалка остаётся на месте: сервис
// монтирует свой host в document.body поверх неё.
async function confirmFromModal(): Promise<void> {
  const ok = await dialog.confirm('Delete the selected draft irreversibly?', {
    title: 'Delete draft?',
    confirmText: 'Delete',
    confirmTone: 'danger',
    cancelText: 'Cancel',
  })
  pushLog(ok ? 'confirm -> confirmed (modal not closed)' : 'confirm -> cancelled (modal not closed)')
}

// alert -> Promise<void>. Одна кнопка, разрешается при закрытии.
async function alertFromModal(): Promise<void> {
  await dialog.alert('Changes were saved in the background. The settings window stayed open.', {
    title: 'Done',
    confirmText: 'Got it',
  })
  pushLog('alert -> closed (modal not closed)')
}

// prompt -> Promise<string | null>. Возвращает введённую строку или null.
async function promptFromModal(): Promise<void> {
  const name = await dialog.prompt('Enter a new preset name', {
    title: 'Rename preset',
    label: 'Preset name',
    placeholder: 'For example: Q3 pricing',
    value: 'Draft preset',
    confirmText: 'Save',
    cancelText: 'Cancel',
    required: true,
  })
  pushLog(name === null ? 'prompt -> cancelled' : `prompt -> "${name}"`)
}
</script>

<template>
  <div class="grid gap-3">
    <p class="text-sm text-[var(--gr-muted-fg)]">
      An open `GrModal` invokes the imperative `useDialogService`. The service mounts its own host in `document.body` on top of the modal, so closing confirm/alert/prompt does not close the source window — it stays open, and the user's decision is returned through a `Promise`.
    </p>

    <GrButton class="justify-self-start" @click="open = true">
      Open settings modal
    </GrButton>

    <GrModal
      v-model="open"
      :close-on-backdrop="false"
      size="md"
      aria-label="Workspace settings"
    >
      <div class="grid gap-4">
        <div class="grid gap-1">
          <div class="text-sm font-semibold text-[var(--gr-fg)]">
            Workspace settings
          </div>
          <div class="text-sm text-[var(--gr-muted-fg)]">
            Launch service dialogs straight from the open window — it stays in place after any of them is closed.
          </div>
        </div>

        <div class="flex flex-wrap gap-3">
          <GrButton variant="primary" tone="danger" @click="confirmFromModal">
            confirm
          </GrButton>
          <GrButton variant="outline" @click="alertFromModal">
            alert
          </GrButton>
          <GrButton variant="outline" @click="promptFromModal">
            prompt
          </GrButton>
        </div>

        <div class="rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-muted)]/40 p-3 text-sm">
          <div class="mb-1 font-medium text-[var(--gr-fg)]">
            Results
          </div>
          <ul v-if="log.length" class="grid gap-1 text-[var(--gr-muted-fg)]">
            <li v-for="(entry, index) in log" :key="index">
              {{ entry }}
            </li>
          </ul>
          <div v-else class="text-[var(--gr-muted-fg)]">
            Empty for now — invoke any dialog above.
          </div>
        </div>

        <GrButton variant="outline" class="justify-self-start" @click="open = false">
          Close modal
        </GrButton>
      </div>
    </GrModal>
  </div>
</template>

Закрытие confirm/alert/prompt не закрывает исходную модалку — это удобно для подтверждений и быстрых вводов внутри сложных форм.

Поповеры внутри окна

Селект, автокомплит, дата и меню внутри окна: их панели телепортируются в общий портал и лежат рядом с корнем окна, а высоту получают из стека слоёв.

Nested Poppers
<script setup lang="ts">
import { ref } from 'vue'

import {
  GrAutocomplete,
  GrButton,
  GrDropdown,
  GrFormField,
  GrModal,
  GrSelect,
  GrTooltip,
} from '@feugene/granularity'

// `GrDatePicker` из companion-пакета подставляется авто-импортом.
const open = ref(false)

const city = ref('berlin')
const cities = [
  { label: 'Berlin', value: 'berlin' },
  { label: 'Lisbon', value: 'lisbon' },
  { label: 'Tbilisi', value: 'tbilisi' },
]

const airport = ref('')
const airports = ['BER', 'LIS', 'TBS', 'AMS', 'IST'].map(code => ({ value: code, label: code }))

const departure = ref<string | null>('2026-08-12')
</script>

<template>
  <div class="grid gap-3">
    <GrButton class="justify-self-start" @click="open = true">
      Open form with poppers
    </GrButton>

    <!-- Панель, открытая изнутри окна, телепортируется в общий портал и лежит
         РЯДОМ с корнем окна, а не внутри него. Высоту ей задаёт стек слоёв:
         пока окно открыто, панель встаёт над ним. -->
    <GrModal v-model="open" size="md" aria-label="Trip details">
      <div class="grid gap-4">
        <div class="text-sm font-semibold text-[var(--gr-fg)]">
          Trip details
        </div>

        <GrFormField label="City">
          <GrSelect v-model="city" :options="cities" options-view="panel" />
        </GrFormField>

        <GrFormField label="Airport">
          <GrAutocomplete v-model="airport" :options="airports" placeholder="Start typing" />
        </GrFormField>

        <GrFormField label="Departure">
          <GrDatePicker v-model="departure" value-adapter="isoDate" locale="en-US" clearable />
        </GrFormField>

        <div class="flex items-center gap-3">
          <GrDropdown>
            <template #trigger="{ triggerProps }">
              <GrButton v-bind="triggerProps" variant="outline" size="sm">
                Actions
              </GrButton>
            </template>

            <template #content>
              <div class="grid gap-1 p-1 text-sm">
                <button class="rounded-[var(--gr-radius-control)] px-2 py-1 text-left hover:bg-[var(--gr-muted)]">
                  Duplicate trip
                </button>
                <button class="rounded-[var(--gr-radius-control)] px-2 py-1 text-left hover:bg-[var(--gr-muted)]">
                  Export as PDF
                </button>
              </div>
            </template>
          </GrDropdown>

          <GrTooltip text="Подсказка тоже поверх окна: её слой ниже модального">
            <GrButton variant="ghost" size="sm">
              Why so many pickers?
            </GrButton>
          </GrTooltip>
        </div>

        <div class="flex justify-end">
          <GrButton size="sm" @click="open = false">
            Done
          </GrButton>
        </div>
      </div>
    </GrModal>
  </div>
</template>

Esc закрывает сначала панель, потом окно. Пока панель открыта, ловушка фокуса окна считает её своей.

Окна одно поверх другого

Четыре окна лесенкой и диалог поверх них: высоту слоя даёт стек, а не порядок узлов в портале.

Каждое следующее окно меньше предыдущего, поэтому видно все четыре сразу. Открываются они по очереди, а объявлены статически — в портал попали в порядке создания.

Высота открытых слоёв, как её видит браузер:

Пока ничего не открыто.

Stack
<script setup lang="ts">
import { nextTick, ref } from 'vue'

import { GrButton, GrDialog, GrModal } from '@feugene/granularity'

/**
 * Лесенка окон: каждое следующее открывается изнутри предыдущего.
 *
 * Все четыре объявлены статически, то есть в контейнер портала попадают в
 * порядке **создания**. Высоту им даёт не он, а стек слоёв — иначе окно,
 * открытое позже, оказалось бы под соседом, хотя стек считает верхним именно
 * его и гасит остальные `inert`.
 */
/** Размеры по убыванию: так видно все четыре окна разом, а не только верхнее. */
const LEVELS = [
  { level: 1, size: 'xl' },
  { level: 2, size: 'lg' },
  { level: 3, size: 'md' },
  { level: 4, size: 'sm' },
] as const

const open = ref<boolean[]>([false, false, false, false])
const strategyOpen = ref(false)
const layers = ref<string[]>([])

/** Фактическая высота слоёв — читается из DOM, а не пересчитывается заново. */
async function readLayers() {
  await nextTick()
  layers.value = [...document.querySelectorAll<HTMLElement>('[data-gr-overlay-root]')]
    .map(root => root.style.zIndex)
    .filter(Boolean)
}

async function openLevel(index: number) {
  open.value[index] = true
  await readLayers()
}

async function closeLevel(index: number) {
  open.value[index] = false
  await readLayers()
}

async function closeAll() {
  open.value = [false, false, false, false]
  strategyOpen.value = false
  await readLayers()
}
</script>

<template>
  <div class="grid gap-4 lg:grid-cols-2">
    <div class="grid content-start gap-3">
      <GrButton @click="openLevel(0)">
        Открыть лесенку
      </GrButton>
      <GrButton variant="outline" @click="closeAll">
        Закрыть всё
      </GrButton>

      <p class="showcase-demo-text text-sm">
        Каждое следующее окно меньше предыдущего, поэтому видно все четыре сразу. Открываются они
        по очереди, а объявлены статически — в портал попали в порядке создания.
      </p>
    </div>

    <div class="showcase-demo-panel grid content-start gap-2 rounded-[var(--gr-radius-lg)] border p-4">
      <p class="showcase-demo-text text-sm">
        Высота открытых слоёв, как её видит браузер:
      </p>
      <ul v-if="layers.length > 0" class="grid gap-1">
        <li v-for="(z, index) in layers" :key="index">
          <code class="showcase-demo-text text-xs">{{ index + 1 }}: {{ z }}</code>
        </li>
      </ul>
      <p v-else class="showcase-demo-text text-sm">
        Пока ничего не открыто.
      </p>
    </div>

    <GrModal
      v-for="(item, index) in LEVELS"
      :key="item.level"
      v-model="open[index]"
      :size="item.size"
    >
      <template #title>
        Окно {{ item.level }}
      </template>

      <div class="grid gap-3">
        <p class="showcase-demo-text text-sm">
          Уровень {{ item.level }}. Верхнее окно отвечает на клики, нижние ушли в
          <code>inert</code> — и лежат ниже по высоте, а не только в стеке.
        </p>

        <div class="flex flex-wrap gap-2">
          <GrButton
            v-if="index + 1 < LEVELS.length"
            size="sm"
            @click="openLevel(index + 1)"
          >
            Открыть окно {{ item.level + 1 }}
          </GrButton>
          <GrButton
            v-if="item.level === 1"
            size="sm"
            variant="outline"
            @click="strategyOpen = true; readLayers()"
          >
            Диалог поверх окна
          </GrButton>
          <GrButton size="sm" variant="outline" @click="closeLevel(index)">
            Закрыть
          </GrButton>
        </div>
      </div>
    </GrModal>

    <!--
      Тот самый случай из заявки потребителя: диалог объявлен раньше окон, а
      открывается позже. По порядку узлов в портале он оказался бы под ними.
    -->
    <GrDialog v-model="strategyOpen" title="Выбор стратегии" size="sm">
      <p class="showcase-demo-text text-sm">
        Диалог объявлен в шаблоне раньше окон, а открыт позже — и всё равно виден поверх.
        Раньше он попадал под окно и оставался невидимым, хотя именно он отвечал на клики.
      </p>

      <template #footer>
        <GrButton size="sm" @click="strategyOpen = false; readLayers()">
          Понятно
        </GrButton>
      </template>
    </GrDialog>
  </div>
</template>

Все окна объявлены статически, то есть в контейнер портала попадают в порядке создания, а открываются по очереди. Пока высота была одна на всех, порядок отрисовки решал портал: диалог, объявленный раньше, оказывался под окном, открытым позже, — и оставался невидимым, хотя стек считал верхним именно его и гасил окно inert. Теперь высота считается от позиции в стеке, и «верхний» для отрисовки совпадает с «верхним» для Esc и inert.

Прокрутка длинного содержимого и события жизненного цикла

scrollBehavior решает, кто скроллится — панель или весь оверлей; opened/closed приходят после анимации, и только по closed безопасно размонтировать содержимое.

Not opened yet

Scroll Lifecycle
<script setup lang="ts">
import { computed, ref } from 'vue'

import type { GrModalScrollBehavior } from '@feugene/granularity'
import { GrBadge, GrButton, GrModal, GrSegmented } from '@feugene/granularity'

const open = ref(false)
const scrollBehavior = ref<GrModalScrollBehavior>('inside')
const phase = ref<'idle' | 'opened' | 'closed'>('idle')

const rows = Array.from({ length: 24 }, (_, index) => index + 1)

const phaseTone = computed(() => (phase.value === 'opened' ? 'success' : 'neutral'))

const phaseLabel = computed(() => ({
  idle: 'Not opened yet',
  opened: 'opened — enter animation finished',
  closed: 'closed — content is safe to unmount',
}[phase.value]))
</script>

<template>
  <div class="grid gap-3">
    <div class="flex flex-wrap items-center gap-3">
      <GrSegmented
        v-model="scrollBehavior"
        size="sm"
        :options="[
          { value: 'inside', label: 'inside' },
          { value: 'outside', label: 'outside' },
        ]"
      />
      <GrButton class="justify-self-start" @click="open = true">
        Open a long dialog
      </GrButton>
      <GrBadge :tone="phaseTone">
        {{ phaseLabel }}
      </GrBadge>
    </div>

    <GrModal
      v-model="open"
      size="md"
      :scroll-behavior="scrollBehavior"
      @opened="phase = 'opened'"
      @closed="phase = 'closed'"
    >
      <!-- Слот #title — рекомендуемый путь: он и виден, и даёт окну имя. -->
      <template #title>
        <div class="border-b border-[var(--gr-brd)] px-4 py-3 text-sm font-semibold text-[var(--gr-fg)]">
          Terms of use
        </div>
      </template>

      <div class="grid gap-2 p-4">
        <div class="text-sm text-[var(--gr-muted-fg)]">
          With `scrollBehavior="inside"` the panel scrolls itself and the title stays put. With `outside` the whole overlay scrolls.
        </div>
        <div
          v-for="row in rows"
          :key="row"
          class="rounded-xl border border-[var(--gr-brd)] px-3 py-2 text-sm"
        >
          Clause {{ row }}
        </div>
        <GrButton class="justify-self-start" @click="open = false">
          Accept
        </GrButton>
      </div>
    </GrModal>
  </div>
</template>

Документация компонентаВсе компоненты