GrDialog

Package: @feugene/granularitycoreGroup: overlays

A dialog for confirmations, forms and focused scenarios.

Machine-translated from the Russian original, not yet reviewed. Read the original

When to take it

  • a window with a header, a body and a footer — the typical layout is already assembled: the heading, the close button, a row of actions;
  • there is a form inside — the body scrolls independently, the footer with the buttons stays in place;
  • the window is opened by markupv-model in the template rather than a call from code;
  • the focus has to land on a particular elementinitialFocus instead of the first control that comes along.

When to take something else

NeedTake
The layout is non-standard: full screen, no header, a frame of your ownGrModal
Ask a yes/no questionGrConfirmDialog
Ask for a single valueGrPromptDialog
Call a window from code, with no markup in the templateGrDialogService
A panel at the edge of the screenGrDrawer
The content is tied to a button rather than to the centre of the screenGrPopover

The sections

headerConfig, bodyConfig and footerConfig set the padding and the border of each section (bordered is ignored for the body: it has no top border of its own). The footer is rendered only if the #footer slot is passed.

The header, the footer and the close button are separate components of the same subpath: GrDialogHeader, GrDialogFooter, GrDialogCloseButton. Inside GrDialog they are already assembled; they are taken directly when the layout is built on GrModal while the header and the footer are wanted unchanged.

The #header slot replaces the content of the header as a whole — the close button stays in place in the process. The accessible name of the window then goes down as an sr-only heading: a header of your own is not obliged to contain a DialogTitle, and a window without a name is announced by a screen reader as a nameless “dialog”.

Scrolling long content

scrollBehavior:

  • outside (the default) — the whole overlay scrolls, and the window moves up together with the page;
  • insidethe header and the footer are pinned, only the body moves.
<GrDialog v-model="open" title="Profile settings" scroll-behavior="inside">
  <ProfileForm />
  <template #footer>
    <GrButton @click="submit">Save</GrButton>
  </template>
</GrDialog>

A form of twenty fields is exactly this case: with outside the buttons move off the screen together with the content, and you have to scroll to reach “Save”. Technically the header and the footer go into the layout slots of GrModal (#header/#footer), which lie outside the scrolling body; the body meanwhile enters the tab order, so that a long text without a single focusable element can be scrolled from the keyboard.

Full screen is size="full": the panel takes up the whole viewport, with no margins and no rounding.

The imperative API

open(), close() and toggle() through a ref on the component — as in the other overlays. The dialog is controlled, and the methods emit update:modelValue: the state lives in the parent’s v-model rather than inside (more on that in GrModal.md).

The focus and the lifecycle

initialFocus sets the element that gets the focus on opening; by default that is the panel of the window. An element from the dialog itself must not be passed here — a prop returning upwards what was born inside the subtree closes the render into a loop. The focus on your own content is set from the content; how that is done can be seen in GrPromptDialog.

opened and closed are emitted after the animation. closed is the only safe moment to unmount the content or reset the form: doing that on update:modelValue means cutting the closing animation short.

Playground 9

Loading…

Code
<GrDialog />

Install

npm i @feugene/granularity

Import

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

API

Props

PropTypedefaultDescription
titlestring | undefinedundefined
size"sm" | "md" | "lg" | "xl" | "full" | undefinedundefined
ariaLabelstring | undefinedundefinedThe accessible name of the window when there is no heading at all: `showHeader: false` without `title` and without a `#header` slot would leave the window nameless.
closeOnBackdropboolean | undefinedtrue
closeOnEscboolean | undefinedtrue
showHeaderboolean | undefinedtrue
showCloseButtonboolean | undefinedtrue
headerConfigGrDialogSectionConfig | undefinedundefined
footerConfigGrDialogSectionConfig | undefinedundefined
bodyConfigGrDialogSectionConfig | undefinedundefined
closeLabelstring | undefinedundefinedThe a11y label of the close button (i18n).
scrollBehaviorGrModalScrollBehavior | undefined"outside"What scrolls when the content is long. With `inside` the header and the footer are pinned, and only the body moves.
initialFocusHTMLElement | null | undefinednullThe element that gets the focus on opening. By default — the panel of the window. An element **from the dialog itself** must not be passed here: a prop returning upwards what was born inside the subtree closes the render into a loop. The focus on your own content is set from the content — that is how it is done in `GrPromptDialog`.
modelValuerequiredboolean

Slots

SlotTypeDescription
defaultany
header{ title?: string | undefined; }
footerany

Events

EventTypeDescription
update:modelValue[value: boolean]
opened[]
closed[]

Methods / Expose

Methods / ExposeTypeDescription
open() => void
close() => void
toggle() => void

Examples 4

Basic dialog shell

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>

Section config and internal state

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>

Guarded backdrop for critical flows

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>

Long form with a pinned header and footer

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

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>

Component documentationAll components