GrDialog

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

Берут, когда окно с шапкой, телом и подвалом.

Когда брать

  • окно с шапкой, телом и подвалом — типовая раскладка уже собрана: заголовок, кнопка закрытия, ряд действий;
  • внутри форма — тело прокручивается независимо, подвал с кнопками остаётся на месте;
  • окно открывается разметкойv-model в шаблоне, а не вызовом из кода;
  • фокус должен встать на конкретный элементinitialFocus вместо первого попавшегося контрола.

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

НужноБерите
Раскладка нестандартная: во весь экран, без шапки, свой каркасGrModal
Спросить «да/нет»GrConfirmDialog
Запросить одно значениеGrPromptDialog
Вызвать окно из кода, без разметки в шаблонеGrDialogService
Панель у края экранаGrDrawer
Содержимое привязано к кнопке, а не к центру экранаGrPopover

Секции

headerConfig, bodyConfig, footerConfig задают паддинги и рамку каждой секции (bordered у тела игнорируется: отдельной верхней рамки у него нет). Подвал рендерится, только если передан слот #footer.

Шапка, подвал и кнопка закрытия — отдельные компоненты того же subpath: GrDialogHeader, GrDialogFooter, GrDialogCloseButton. Внутри GrDialog они уже собраны; берут их напрямую, когда раскладку строят на GrModal, а шапку и подвал хотят те же самые.

Слот #header заменяет содержимое шапки целиком — кнопка закрытия при этом остаётся на месте. Доступное имя окна в этом случае уходит вниз sr-only заголовком: пользовательская шапка не обязана содержать DialogTitle, а окно без имени диктор озвучит как безымянный «диалог».

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

scrollBehavior:

  • outside (по умолчанию) — скроллится весь оверлей, окно уезжает вверх вместе со страницей;
  • insideшапка и подвал закреплены, едет только тело.
<GrDialog v-model="open" title="Настройки профиля" scroll-behavior="inside">
  <ProfileForm />
  <template #footer>
    <GrButton @click="submit">Сохранить</GrButton>
  </template>
</GrDialog>

Форма на двадцать полей — ровно этот случай: с outside кнопки уезжают за экран вместе с содержимым, и до «Сохранить» надо доскроллить. Технически шапка и подвал уходят в layout-слоты GrModal (#header/#footer), которые лежат вне скроллящегося тела; тело при этом попадает в таб-порядок, чтобы длинный текст без единого фокусируемого элемента можно было прокрутить с клавиатуры.

Во весь экран — size="full": панель занимает вьюпорт целиком, без полей и скруглений.

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

open(), close() и toggle() через ref на компоненте — как у остальных оверлеев. Диалог управляемый, и методы эмитят update:modelValue: состояние живёт в v-model родителя, а не внутри (подробнее — GrModal.md).

Фокус и жизненный цикл

initialFocus задаёт элемент, получающий фокус при открытии; по умолчанию это панель окна. Элемент из самого диалога сюда передавать нельзя — проп, возвращающий наверх то, что рождено внутри поддерева, замыкает рендер в цикл. Фокус на собственном содержимом ставится из содержимого; как это делается — видно в GrPromptDialog.

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

Playground 9

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

Код
<GrDialog />

Установка

npm i @feugene/granularity

Импорт

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

API

Props

PropTypeпо умолчаниюОписание
titlestring | undefinedundefined
size"sm" | "md" | "lg" | "xl" | "full" | undefinedundefined
ariaLabelstring | undefinedundefinedДоступное имя окна, когда заголовка нет вовсе: `showHeader: false` без `title` и без слота `#header` оставил бы окно безымянным.
closeOnBackdropboolean | undefinedtrue
closeOnEscboolean | undefinedtrue
showHeaderboolean | undefinedtrue
showCloseButtonboolean | undefinedtrue
headerConfigGrDialogSectionConfig | undefinedundefined
footerConfigGrDialogSectionConfig | undefinedundefined
bodyConfigGrDialogSectionConfig | undefinedundefined
closeLabelstring | undefinedundefinedA11y-лейбл кнопки закрытия (i18n).
scrollBehaviorGrModalScrollBehavior | undefined"outside"Кто скроллится при длинном содержимом. При `inside` шапка и подвал закреплены, а едет только тело.
initialFocusHTMLElement | null | undefinednullЭлемент, получающий фокус при открытии. По умолчанию — панель окна. Элемент **из самого диалога** сюда передавать нельзя: проп, возвращающий наверх то, что рождено внутри поддерева, замыкает рендер в цикл. Фокус на своём содержимом ставится из содержимого — так это сделано в `GrPromptDialog`.
modelValueобязательныйboolean

Slots

SlotTypeОписание
defaultany
header{ title?: string | undefined; }
footerany

Events

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

Methods / Expose

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

Примеры 4

Базовая оболочка окна

Показываем базовый слой над GrModal: готовый header/footer shell для review, approval и confirm-like сценариев.

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

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

const open = ref(false)
</script>

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

    <GrDialog v-model="open" title="Publish weekly digest" size="sm">
      <div class="grid gap-4 text-sm text-[var(--gr-muted-fg)]">
        <p>
          `GrDialog` assembles a ready header/footer shell on top of `GrModal`, so it is convenient for simple approval flows.
        </p>

        <div class="flex flex-wrap items-center gap-2">
          <GrBadge size="sm" tone="info">
            12 recipients
          </GrBadge>
          <GrBadge size="sm" tone="neutral">
            Draft ready
          </GrBadge>
        </div>
      </div>

      <template #footer>
        <div class="flex justify-end gap-3">
          <GrButton variant="outline" @click="open = false">
            Cancel
          </GrButton>
          <GrButton @click="open = false">
            Publish
          </GrButton>
        </div>
      </template>
    </GrDialog>
  </div>
</template>

Настройка секций и внутреннее состояние

Демонстрируем headerConfig / footerConfig и локальное состояние формы внутри dialog-shell.

Footer action enabled: no

Section Config
<script setup lang="ts">
import { ref } from 'vue'

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

const open = ref(false)
const confirmed = ref(false)

function openDialog() {
  confirmed.value = false
  open.value = true
}
</script>

<template>
  <div class="grid gap-3">
    <GrButton variant="outline" class="justify-self-start" @click="openDialog">
      Open stateful dialog
    </GrButton>

    <div class="text-xs text-[var(--gr-muted-fg)]">
      Footer action enabled: <span class="font-medium text-[var(--gr-fg)]">{{ confirmed ? 'yes' : 'no' }}</span>
    </div>

    <GrDialog
        v-model="open"
        title="Share workspace"
        :header-config="{ paddingX: 'px-4', paddingY: 'py-3' }"
        :footer-config="{ paddingX: 'px-4', paddingY: 'py-3', bordered: false }"
    >
      <div class="grid gap-4 text-sm text-[var(--gr-muted-fg)]">
        <p>
          The internal form state keeps living inside the dialog shell, while section config helps adapt density to compact workflows.
        </p>

        <div class="flex items-start gap-3 rounded-lg border border-[var(--gr-brd)] p-3 text-[var(--gr-fg)]">
          <GrCheckbox v-model="confirmed">I reviewed access levels and notification scope.</GrCheckbox>
        </div>
      </div>

      <template #footer>
        <div class="flex justify-end gap-3">
          <GrButton variant="outline" @click="open = false">
            Later
          </GrButton>
          <GrButton :disabled="!confirmed" @click="open = false">
            Share workspace
          </GrButton>
        </div>
      </template>
    </GrDialog>
  </div>
</template>

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

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

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

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

const open = ref(false)
</script>

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

    <GrDialog v-model="open" title="Resolve blockers" :close-on-backdrop="false" :show-close-button="false">
      <div class="grid gap-3 text-sm text-[var(--gr-muted-fg)]">
        <p>
          In critical flows you can disable backdrop close and leave only explicit footer actions.
        </p>
        <ul class="list-disc pl-5">
          <li>2 approvals are still pending</li>
          <li>1 issue is waiting for legal review</li>
        </ul>
      </div>

      <template #footer>
        <div class="flex justify-end gap-3">
          <GrButton variant="outline" @click="open = false">
            Keep draft
          </GrButton>
          <GrButton @click="open = false">
            Continue review
          </GrButton>
        </div>
      </template>
    </GrDialog>
  </div>
</template>

Сценарий полезен для финальных шагов publish/delete/release flows.

Длинная форма с закреплённой шапкой и подвалом

scrollBehavior: "inside" оставляет шапку и подвал на месте и скроллит только тело — форма на двадцать полей не уносит кнопки за экран. Переключатель показывает разницу с дефолтным outside.

Шапка и подвал закреплены — «Сохранить» на виду с первого кадра

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

import { GrButton, GrDialog, GrFormField, GrInput, GrSegmented, GrSwitch } from '@feugene/granularity'

const open = ref(false)
const scrollBehavior = ref<'inside' | 'outside'>('inside')
const saved = ref(false)

const fields = Array.from({ length: 12 }, (_, index) => `Поле ${index + 1}`)
const model = ref<Record<string, string>>({})

const hint = computed(() =>
  scrollBehavior.value === 'inside'
    ? 'Шапка и подвал закреплены — «Сохранить» на виду с первого кадра'
    : 'Скроллится вся страница окна: до кнопок надо доскроллить',
)

function save() {
  saved.value = true
  open.value = false
}
</script>

<template>
  <div class="grid gap-3">
    <GrSegmented
      v-model="scrollBehavior"
      size="sm"
      class="justify-self-start"
      :options="[
        { value: 'inside', label: 'inside' },
        { value: 'outside', label: 'outside' },
      ]"
    />

    <div class="text-xs text-[var(--gr-muted-fg)]">
      {{ hint }}
    </div>

    <GrButton variant="outline" class="justify-self-start" @click="open = true">
      Настройки профиля
    </GrButton>

    <div v-if="saved" class="text-xs text-[var(--gr-muted-fg)]">
      Сохранено
    </div>

    <GrDialog
      v-model="open"
      title="Настройки профиля"
      :scroll-behavior="scrollBehavior"
    >
      <div class="grid gap-4">
        <GrFormField v-for="field in fields" :key="field" :label="field">
          <GrInput v-model="model[field]" :placeholder="field" />
        </GrFormField>

        <GrSwitch>Присылать уведомления</GrSwitch>
      </div>

      <template #footer>
        <div class="flex justify-end gap-3">
          <GrButton variant="outline" @click="open = false">
            Отмена
          </GrButton>
          <GrButton @click="save">
            Сохранить
          </GrButton>
        </div>
      </template>
    </GrDialog>
  </div>
</template>

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