GrModal
Берут, когда своя раскладка поверх страницы.
Когда брать
- своя раскладка поверх страницы — галерея, конструктор, мастер во весь экран: шапки и подвала здесь нет намеренно;
- нужен только модальный слой — бэкдроп, блокировка скролла, ловушка фокуса,
Escи стек слоёв; - строится свой компонент-оверлей — это тот же примитив, на котором стоят
GrDialog,GrDrawerи палитра команд; - прокрутка ведёт себя нестандартно —
scrollBehaviorпереносит её с тела на весь слой.
Когда взять другое
| Нужно | Берите |
|---|---|
| Обычное окно «шапка — тело — подвал» | GrDialog |
| Спросить «да/нет» | GrConfirmDialog |
| Запросить одно значение | GrPromptDialog |
| Панель у края экрана | GrDrawer |
| Просмотр изображения во весь экран | GrImageViewer |
Второй реализации модального слоя в пакете нет и заводить её не нужно: стек,
inert для фона и возврат фокуса на триггер приезжают отсюда во все оверлеи
сразу.
Имя окна обязательно
Модальный слой без доступного имени — нарушение, которое диктор озвучивает как
безымянный «диалог», а axe ловит правилом aria-dialog-name. Порядок такой:
- есть
#title(или заголовокGrDialogглубже по дереву) — имя даёт он черезaria-labelledby; - нет
#title, но естьariaLabel— имя берётся из пропа; - нет ничего — подставляется обобщённое имя из локали (
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-modal — calc(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
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
size | "sm" | "md" | "lg" | "xl" | "full" | undefined | undefined | — |
ariaLabel | string | undefined | undefined | Доступное имя окна, когда заголовка в слоте `#title` нет. Слот сильнее: при нём имя даёт `aria-labelledby`. |
closeOnBackdrop | boolean | undefined | true | — |
closeOnEsc | boolean | undefined | true | — |
scrollBehavior | GrModalScrollBehavior | undefined | "outside" | Кто скроллится при длинном содержимом: весь оверлей (`outside`) или сама панель (`inside` — окно остаётся на месте, шапка и подвал на виду). |
initialFocus | HTMLElement | null | undefined | null | Элемент, получающий фокус при открытии. По умолчанию — сама панель: она фокусируема программно (`tabindex="-1"`), диктор объявляет окно целиком, а первый Tab приводит в начало содержимого. |
modelValueобязательный | boolean | — | — |
Slots
| Slot | Type | Описание |
|---|---|---|
default | any | — |
title | any | — |
description | any | — |
header | any | Закреплённая шапка: при `inside` остаётся на месте, скроллится только тело. |
footer | any | Закреплённый подвал: там же, где и шапка, — вне скроллящегося тела. |
Events
| Event | Type | Описание |
|---|---|---|
update:modelValue | [value: boolean] | — |
opened | [] | — |
closed | [] | — |
Methods / Expose
| Methods / Expose | Type | Описание |
|---|---|---|
open | () => void | — |
close | () => void | — |
toggle | () => void | — |
Примеры 7
Голый модальный слой
Базовый сценарий для GrModal: минимальный контейнер, открытие по кнопке и явное закрытие из пользовательского контента.
<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 для кейсов, где нельзя случайно потерять прогресс черновика или подтверждения.
<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-панель.
<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`.
<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 не закрывает исходную модалку — это удобно для подтверждений и быстрых вводов внутри сложных форм.
Поповеры внутри окна
Селект, автокомплит, дата и меню внутри окна: их панели телепортируются в общий портал и лежат рядом с корнем окна, а высоту получают из стека слоёв.
<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 закрывает сначала панель, потом окно. Пока панель открыта, ловушка фокуса окна считает её своей.
Окна одно поверх другого
Четыре окна лесенкой и диалог поверх них: высоту слоя даёт стек, а не порядок узлов в портале.
Каждое следующее окно меньше предыдущего, поэтому видно все четыре сразу. Открываются они по очереди, а объявлены статически — в портал попали в порядке создания.
Высота открытых слоёв, как её видит браузер:
Пока ничего не открыто.
<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 безопасно размонтировать содержимое.
<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>