GrDialog
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 markup —
v-modelin the template rather than a call from code; - the focus has to land on a particular element —
initialFocusinstead of the first control that comes along.
When to take something else
| Need | Take |
|---|---|
| The layout is non-standard: full screen, no header, a frame of your own | GrModal |
| Ask a yes/no question | GrConfirmDialog |
| Ask for a single value | GrPromptDialog |
| Call a window from code, with no markup in the template | GrDialogService |
| A panel at the edge of the screen | GrDrawer |
| The content is tied to a button rather than to the centre of the screen | GrPopover |
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;inside— the 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…
<GrDialog />Install
npm i @feugene/granularityImport
import { GrDialog } from '@feugene/granularity/components/GrDialog'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
title | string | undefined | undefined | — |
size | "sm" | "md" | "lg" | "xl" | "full" | undefined | undefined | — |
ariaLabel | string | undefined | undefined | The 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. |
closeOnBackdrop | boolean | undefined | true | — |
closeOnEsc | boolean | undefined | true | — |
showHeader | boolean | undefined | true | — |
showCloseButton | boolean | undefined | true | — |
headerConfig | GrDialogSectionConfig | undefined | undefined | — |
footerConfig | GrDialogSectionConfig | undefined | undefined | — |
bodyConfig | GrDialogSectionConfig | undefined | undefined | — |
closeLabel | string | undefined | undefined | The a11y label of the close button (i18n). |
scrollBehavior | GrModalScrollBehavior | undefined | "outside" | What scrolls when the content is long. With `inside` the header and the footer are pinned, and only the body moves. |
initialFocus | HTMLElement | null | undefined | null | The 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`. |
modelValuerequired | boolean | — | — |
Slots
| Slot | Type | Description |
|---|---|---|
default | any | — |
header | { title?: string | undefined; } | — |
footer | any | — |
Events
| Event | Type | Description |
|---|---|---|
update:modelValue | [value: boolean] | — |
opened | [] | — |
closed | [] | — |
Methods / Expose
| Methods / Expose | Type | Description |
|---|---|---|
open | () => void | — |
close | () => void | — |
toggle | () => void | — |
Examples 4
Basic dialog shell
<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
<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
<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
<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>