GrModal

  • ядро
  • Слои
  • API · 7 props
  • ⌨ клавиши

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

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

Обзор

Когда брать

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

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

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

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

Playground контролы выводятся из API, а не пишутся руками

Сцена

Окно откроется поверх страницы с пропами, заданными справа.

0 изменено· состояние — в адресе

Примеры 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

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

Слоты

SlotTypeОписание
defaultanyСодержимое окна. При `scrollBehavior: 'inside'` скроллится только оно.
titleanyЗаголовок окна. Даёт ему доступное имя через `aria-labelledby` — сильнее `ariaLabel`.
descriptionanyПояснение к окну, связанное с ним через `aria-describedby`.
headeranyЗакреплённая шапка: при `inside` остаётся на месте, скроллится только тело.
footeranyЗакреплённый подвал: там же, где и шапка, — вне скроллящегося тела.

События

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

GrModalPropsinterface
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;
}
GrModalEmitsinterface
interface GrModalEmits {
    /** Окно просит открыться или закрыться (`v-model`): Esc, подложка или методы инстанса. */
    (e: 'update:modelValue', value: boolean): void;
    /** Окно открылось и анимация закончилась. */
    (e: 'opened'): void;
    /** Окно закрылось и анимация закончилась — содержимое можно размонтировать. */
    (e: 'closed'): void;
}

Файлdist/types/components/GrModal/config.d.ts

grModalConfigconst
const grModalConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrModal">;

Файлdist/types/components/GrModal/grModalStyles.d.ts

GrModalSizetype
type GrModalSize = GrOverlaySize;
GrModalScrollBehaviortype

Кто скроллится при длинном содержимом: весь оверлей (outside, окно уезжает вверх вместе со страницей) или сама панель (inside, окно остаётся на месте, а шапка и подвал GrDialog — на виду).

type GrModalScrollBehavior = 'outside' | 'inside';

Файлdist/types/components/GrModal/safelist.d.ts

grModalSafelistconst
const grModalSafelist: string[];

Файлdist/types/components/GrModal/index.d.ts

GrModalInstancetype
type GrModalInstance = ComponentExposed<typeof GrModalComponent>;

Доступность

Клавиши

EscTab / Shift+Tab

КлавишаДействие
Escзакрыть верхнее окно (если closeOnEsc)
Tab / Shift+Tabцикл внутри фокус-ловушки

Использование из прозы библиотеки

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

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

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

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

vue
<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.

vue
<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 значит оборвать анимацию закрытия на полпути.