GrDialogService
Machine-translated from the Russian original, not yet reviewed. Read the original
When to take it
- the window is called from code — from a handler, from a store, from a request interceptor: no markup in the template is needed at all;
- the result is needed as a value —
confirm/promptreturn a Promise, and the branching is written linearly; - the window is opened from a place with no template — a
.tsmodule, a router guard, an error handler; - there are many windows and they are alike — a shared host instead of a copy of
v-modelin every component.
When to take something else
| Need | Take |
|---|---|
| The window is opened by markup and lives on the screen | GrDialog |
| The confirmation is declared in the template | GrConfirmDialog |
| The request for a value is declared in the template | GrPromptDialog |
| A message without a question | GrToaster |
| A layout of your own for the modal layer | GrModal |
How the promise resolves
The high-level methods never reject: a cancellation is a regular outcome, not an
exception. confirm gives a boolean, prompt a string | null, alert a void. If
the exact reason for the closing is needed — open(), which resolves
{ action: 'confirm' | 'cancel' | 'close', value? }.
Every promise is extended with a close() method: closing the dialog from code.
Asynchronous confirmation
confirm and prompt accept an onConfirm, which receives a context and may be
asynchronous. While it is in flight:
- the confirm button shows loading;
- Esc and a click on the backdrop are switched off — an accidental movement must not cut the operation short. The close button in the header remains: a window with a hanging request needs an explicit way out;
- the dialog stays open if the callback returned
false, set an error or threw an exception.
The context provides value, signal (aborted when the dialog is closed any way —
by a button, by the promise’s close(), by closeAll()), setError /
setFieldError / clearErrors, setRawError, setLoading, close.
setFieldError(field, message) is addressed: the errors are collected into a map by
field names. GrPromptDialog shows the error of its own field (value), and if there
is a single entry — that one, whatever name it was marked with.
Server errors
Instead of parsing by hand, onConfirm passes the raw answer (a Response, an
axios/fetch error, an Error, a string, JSON) to ctx.setRawError. The service runs it
through the same chain of parsers as GrResponseErrorBanner: the general message is
drawn in the body of the dialog, fieldErrors are laid out across the fields, and the
window stays open for another attempt. An exception from onConfirm is classified
automatically if the error has not been set by hand.
The default chain is the universal core (coreResponseErrorParsers): abort, network,
HTTP status, plain message. They work at the transport level and make no
format-specific assumptions, so they do not fire falsely on almost-Laravel answers.
Server-specific parsers (Laravel, RFC 7807, JSON:API) are connected deliberately
through errorParsers — as an array or as a builder function that receives the named
presets.
The application context
The host — GrDialogServiceHost — is mounted with a separate render() into
document.body, outside the component tree. Its export is needed by the plugin and by
tests: there is no need to put the host into a template by hand, the service does that
itself.
Vue takes provides only from appContext, which receives nothing but app.provide(),
so the values from <GrConfigProvider> do not reach the host. That is why the config
and the i18n are captured at the point of the call and travel together with the
request.
The priority: the appContext option of the call → an explicit setAppContext → the
auto-cache from the first call to useDialogService() inside setup.
The ready singleton dialogService is created when the module is imported, where
inject does not work at all. It takes the context of the last call to
useDialogService() from setup — that is enough for an ordinary application, but with
two subtrees with different providers the choice will belong to the last one. If
precision is needed, call useDialogService() in setup or set setAppContext. A
dialog opened with no context at all prints a warning in a dev build.
Isolating applications
app.use(granularityDialogServicePlugin)
The plugin gives the application a queue and a host of its own and removes them on
app.unmount(). It is required where there are several applications on the page
(micro-frontends — otherwise they share one queue), and useful with HMR, where the
container of the previous application would otherwise keep hanging in document.body.
Without the plugin the service works as before — on lazy module state; for an ordinary SPA that is exactly what is needed.
The dialogService singleton cannot inject, so it chooses the state like this: if
there is a single one registered by the plugin — that one; if there are several — a dev
warning and the module fallback, because there is nothing here to choose on the author’s
behalf. The second case is cured by calling useDialogService() in the setup of the
application in question.
teardownDialogService() remains a manual way out and works over the same state as the
service itself.
The queue
Ordinary calls are serialised by a FIFO queue: the head is shown, and the next dialog
follows after the previous one is closed. Three alerts from a loop will not lie in a
stack, and nobody competes for the focus trap. closeAll() dismantles the queue in the
same FIFO order — the promises resolve in the order of the calls.
priority (0 by default) moves a request among the waiting ones: higher means
earlier, and on a tie the order of the calls holds. The window already shown is not
interrupted in the process — pulling the focus trap out from under the user is worse
than a delay of one window.
Three things can finish a request: a button in the window, the close() of its promise
and closeAll(). The path is one and idempotent: finishing again is a no-op, the promise
resolves exactly once, and everything started for the request (a subscription to an
external signal, an AbortController) is wound up when the window leaves the screen.
Nested dialogs
A dialog opened from the onConfirm of another dialog is shown on top of it rather
than joining the queue:
await dialog.confirm('Delete the project?', {
async onConfirm() {
return await dialog.confirm('Are you sure? There will be nothing to restore from.')
},
})
Otherwise the nested call would wait in the queue for the one that is waiting for it: the outer window would hang in loading and the promise would never resolve. A stack of windows here is not an ornament but the only way not to get a deadlock.
Esc, inert and the return of focus come for free from the shared layer stack: the top
window closes, the lower one is marked inert, and the focus returns into it.
The lifecycle and SSR
The host is mounted lazily on the first call and lives as long as its state does: with
the plugin — until app.unmount(), without the plugin — until
teardownDialogService().
The service is client-only: without window/document the methods throw rather
than work idly — otherwise the module queue would be mutated on the server and would
leak between requests. Hide imperative calls behind a client-side check.
Accessibility
All of the guarantees come from the reusable stack of
GrConfirmDialog/GrPromptDialog/GrDialog/GrModal: a single heading per window, a
focus trap, the return of focus. Esc and the backdrop follow
closeOnEsc/closeOnBackdrop and go into the cancel/close branch.
Install
npm i @feugene/granularityImport
import { GrDialogService } from '@feugene/granularity/components/GrDialogService'API
The API for this component has not been generated yet: the showcase generator only covers the core so far. Until it does, the reference lives in the package documentation.
Examples 2
Link
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import { GrButton } from '@feugene/granularity'
</script>
<template>
<div class="grid gap-3">
<p class="text-sm text-[var(--gr-muted-fg)]">
Императивные вызовы диалогов (confirm / prompt / alert) из script/ts-секции без вставки
компонента в шаблон вынесены на отдельную страницу сервиса useDialogService.
</p>
<RouterLink to="/composables/use-dialog-service" class="justify-self-start">
<GrButton variant="primary">
Открыть страницу useDialogService
</GrButton>
</RouterLink>
</div>
</template>Link
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import { GrButton } from '@feugene/granularity'
</script>
<template>
<div class="grid gap-3">
<p class="text-sm text-[var(--gr-muted-fg)]">
Императивные вызовы диалогов (confirm / prompt / alert) из script/ts-секции без вставки
компонента в шаблон вынесены на отдельную страницу сервиса useDialogService.
</p>
<RouterLink to="/composables/use-dialog-service" class="justify-self-start">
<GrButton variant="primary">
Открыть страницу useDialogService
</GrButton>
</RouterLink>
</div>
</template>