GrModal
Берут, когда своя раскладка поверх страницы.
Обзор
Когда брать
- своя раскладка поверх страницы — галерея, конструктор, мастер во весь экран: шапки и подвала здесь нет намеренно;
- нужен только модальный слой — бэкдроп, блокировка скролла, ловушка фокуса,
Escи стек слоёв; - строится свой компонент-оверлей — это тот же примитив, на котором стоят
GrDialog,GrDrawerи палитра команд; - прокрутка ведёт себя нестандартно —
scrollBehaviorпереносит её с тела на весь слой.
Когда взять другое
| Нужно | Берите |
|---|---|
| Обычное окно «шапка — тело — подвал» | GrDialog |
| Спросить «да/нет» | GrConfirmDialog |
| Запросить одно значение | GrPromptDialog |
| Панель у края экрана | GrDrawer |
| Просмотр изображения во весь экран | GrImageViewer |
Второй реализации модального слоя в пакете нет и заводить её не нужно: стек,
inert для фона и возврат фокуса на триггер приезжают отсюда во все оверлеи
сразу.
Playground контролы выводятся из API, а не пишутся руками
Окно откроется поверх страницы с пропами, заданными справа.
0 изменено· состояние — в адресе
<GrModal v-model="value">
<GrDialogHeader title="Rename the report" :showCloseButton="false" />
<GrDialogFooter>GrModal draws no paddings: this header and footer bring their own. Esc or a click outside closes it.</GrDialogFooter>
</GrModal>Примеры 4 у каждого — живое демо и его код
Своя раскладка
Панель — рамка и ничего больше: полей нет, и цветная половина доходит до краёв и углов. Без слота
#titleокно называетaria-label; остальное — подложка, ловушка фокуса, Esc — забота примитива.Single sign-onLet your team sign in with Okta, Entra ID or Google Workspace.Во всю ширину, с именем из слотов
График доходит до обоих краёв панели, а у текста свои поля.
#titleи#descriptionстановятсяaria-labelledbyиaria-describedby, и окно объявляется заголовком и итогом.Net revenue, Q3€55,963.80Окно, на которое надо ответить
:close-on-backdrop="false"и:close-on-esc="false"оставляют один выход — кнопки: случайный клик не должен оставить пространство в режиме только для чтения.scroll-behavior="inside"держит заголовок и ответ на виду, пока прокручивается текст.Terms of ServiceUpdated on 1 November 2026 · needs an owner’s answerВесь экран для мастера
size="full"— «во весь экран», а не «очень широко»: без полей и скруглений, и у импорта в три шага своя верхняя полоса, тело и нижняя полоса.closedвозвращает мастер к первому шагу, когда панель уже скрылась.No customers yetBring them over from your old tool in one go.
API порождается из пакета · разойтись не может
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
size | "sm" | "md" | "lg" | "xl" | "full" | undefined | undefined | Ширина панели: от `sm` до `xl` меняет только максимальную ширину, `full` — весь вьюпорт, без полей и скруглений. |
ariaLabel | string | undefined | undefined | Доступное имя окна, когда заголовка в слоте `#title` нет. Слот сильнее: при нём имя даёт `aria-labelledby`. |
closeOnBackdrop | boolean | undefined | true | Закрывать по клику на подложку. Считается только клик, начавшийся на ней: выделение текста, отпущенное за краем панели, окно не закрывает. |
closeOnEsc | boolean | undefined | true | Закрывать по Esc. Закрывается верхний слой стека, а не тот, что ниже по документу. |
scrollBehavior | GrModalScrollBehavior | undefined | "outside" | Кто скроллится при длинном содержимом: весь оверлей (`outside`) или сама панель (`inside` — окно остаётся на месте, шапка и подвал на виду). |
initialFocus | HTMLElement | null | undefined | null | Элемент, получающий фокус при открытии. По умолчанию — сама панель: она фокусируема программно (`tabindex="-1"`), диктор объявляет окно целиком, а первый Tab приводит в начало содержимого. |
modelValueобязательныйv-model | boolean | — | Открыто ли окно (`v-model`). Своего состояния у окна нет: открывает и закрывает родитель. |
Слоты
| Slot | Type | Описание |
|---|---|---|
default | any | Содержимое окна. При `scrollBehavior: 'inside'` скроллится только оно. |
title | any | Заголовок окна. Даёт ему доступное имя через `aria-labelledby` — сильнее `ariaLabel`. |
description | any | Пояснение к окну, связанное с ним через `aria-describedby`. |
header | any | Закреплённая шапка: при `inside` остаётся на месте, скроллится только тело. |
footer | any | Закреплённый подвал: там же, где и шапка, — вне скроллящегося тела. |
События
| Event | Сигнатура | Описание |
|---|---|---|
update:modelValue | (event: "update:modelValue", value: boolean): void | — |
opened | (event: "opened"): void | — |
closed | (event: "closed"): void | — |
Методы
| Methods | Сигнатура | Описание |
|---|---|---|
open | () => void | Открыть окно: шлёт `update:modelValue` с `true` — открывает его родитель. |
close | () => void | Закрыть окно: шлёт `update:modelValue` с `false`. |
toggle | () => void | Переключить окно: шлёт `update:modelValue` с обратным значением. |
Типы
Все экспортируемые типы подпути, по файлам, где они объявлены. Имена, дженерики и тела читаются из файлов деклараций пакета и разойтись не могут.
import type { GrModalProps, GrModalEmits, GrModalSize } from '@feugene/granularity/components/GrModal'Файлdist/types/components/GrModal/GrModal.vue.d.ts
interface GrModalProps {
/** Открыто ли окно (`v-model`). Своего состояния у окна нет: открывает и закрывает родитель. */
modelValue: boolean;
/**
* Закрывать по клику на подложку. Считается только клик, начавшийся на ней:
* выделение текста, отпущенное за краем панели, окно не закрывает.
*/
closeOnBackdrop?: boolean;
/** Закрывать по Esc. Закрывается верхний слой стека, а не тот, что ниже по документу. */
closeOnEsc?: boolean;
/**
* Ширина панели: от `sm` до `xl` меняет только максимальную ширину, `full` —
* весь вьюпорт, без полей и скруглений.
*/
size?: GrModalSize;
/**
* Кто скроллится при длинном содержимом: весь оверлей (`outside`) или сама
* панель (`inside` — окно остаётся на месте, шапка и подвал на виду).
*/
scrollBehavior?: GrModalScrollBehavior;
/**
* Доступное имя окна, когда заголовка в слоте `#title` нет. Слот сильнее:
* при нём имя даёт `aria-labelledby`.
*/
ariaLabel?: string;
/**
* Элемент, получающий фокус при открытии. По умолчанию — сама панель: она
* фокусируема программно (`tabindex="-1"`), диктор объявляет окно целиком, а
* первый Tab приводит в начало содержимого.
*/
initialFocus?: HTMLElement | null;
}interface GrModalEmits {
/** Окно просит открыться или закрыться (`v-model`): Esc, подложка или методы инстанса. */
(e: 'update:modelValue', value: boolean): void;
/** Окно открылось и анимация закончилась. */
(e: 'opened'): void;
/** Окно закрылось и анимация закончилась — содержимое можно размонтировать. */
(e: 'closed'): void;
}Файлdist/types/components/GrModal/config.d.ts
const grModalConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrModal">;Файлdist/types/components/GrModal/grModalStyles.d.ts
type GrModalSize = GrOverlaySize;Кто скроллится при длинном содержимом: весь оверлей (outside, окно уезжает
вверх вместе со страницей) или сама панель (inside, окно остаётся на месте,
а шапка и подвал GrDialog — на виду).
type GrModalScrollBehavior = 'outside' | 'inside';Файлdist/types/components/GrModal/safelist.d.ts
const grModalSafelist: string[];Файлdist/types/components/GrModal/index.d.ts
type GrModalInstance = ComponentExposed<typeof GrModalComponent>;Доступность
Клавиши
EscTab / Shift+Tab
| Клавиша | Действие |
|---|---|
| Esc | закрыть верхнее окно (если closeOnEsc) |
| Tab / Shift+Tab | цикл внутри фокус-ловушки |
Полный контракт пакета: docs/keyboard.md ↗
Использование из прозы библиотеки
Имя окна обязательно
Модальный слой без доступного имени — нарушение, которое диктор озвучивает как
безымянный «диалог», а 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 значит оборвать анимацию закрытия на полпути.
Пропы этого примера