GrDialogService
import { GrDialogService } from '@feugene/granularity/components/GrDialogService'Overview
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 |
Examples 3 each with a live demo and its code
A bulk action asked from the handler
confirmreturns a promise, so the answer is read on the next line of the handler — no window state, no component in the template. It never rejects: cancelling is an ordinaryfalse. Title, button names and tone are options, like the props of GrConfirmDialog underneath.ProjectsWebsite relaunch3 months agoQ2 campaign4 months agoBilling migrationyesterdayA rename checked by the server
promptresolves to the string or tonull— a refusal is not an empty line. The field takes the same options as GrPromptDialog:value,maxlengthwithshow-count,rules.onConfirmgets a context:setLoadingwhile the server checks,setFieldErrorputs «already used» under the field, and returningfalsekeeps the window open;ctx.signalaborts if it is closed.Workspace nameNorthwind Traders An expired session from a request interceptor
An API module has no template, and the dialog does not need one: the host mounts itself on the first call.
alerthas a single answer and resolves when it is read, so the interceptor continues after it;close-on-backdropis off so a stray click does not dismiss the warning.The report request comes back 401
API generated from the package · cannot drift
Types
Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.
import type { DialogServiceState, DialogCloseAction, DialogErrorOptions } from '@feugene/granularity/components/GrDialogService'Filedist/types/components/GrDialogService/useDialogService.d.ts
const granularityDialogServicePlugin: {
install(app: App): void;
}function teardownDialogService(): void;function useDialogService(defaults?: DialogServiceDefaults): DialogService;const dialogService: DialogService;Filedist/types/components/GrDialogService/store.d.ts
interface DialogServiceState {
queue: DialogRequest[];
mounted: boolean;
container: HTMLElement | null;
appContext: AppContext | null;
lastCapturedContext: CapturedDialogContext | null;
contextlessWarned: boolean;
inFlight: DialogRequest[];
}Filedist/types/components/GrDialogService/config.d.ts
const grDialogServiceConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrDialogService">;Filedist/types/components/GrDialogService/types.d.ts
type DialogCloseAction = 'confirm' | 'cancel' | 'close';interface DialogErrorOptions {
errorParsers?: ResponseErrorParser[] | ((presets: ResponseErrorParserPresets) => ResponseErrorParser[]);
errorTexts?: Partial<ResponseErrorTexts>;
errorMessageKey?: string;
fieldLabels?: Record<string, string>;
}
export interface DialogConfirmContext<V = unknown> {
/** Введённое значение (для `prompt` — строка;interface DialogConfirmContext<V = unknown> {
readonly value: V;
readonly signal: AbortSignal;
setError: (message: string | null) => void;type DialogOnConfirm<V = unknown> = (ctx: DialogConfirmContext<V>) => void | boolean | Promise<void | boolean>;
export interface DialogBaseOptions extends DialogErrorOptions {interface DialogBaseOptions extends DialogErrorOptions {
title?: string;
message?: string;
description?: string;
size?: GrDialogSize;
closeOnBackdrop?: boolean;
closeOnEsc?: boolean;
showHeader?: boolean;
showCloseButton?: boolean;
headerConfig?: GrDialogSectionConfig;
footerConfig?: GrDialogSectionConfig;
bodyConfig?: GrDialogSectionConfig;
closeLabel?: string;
buttonSize?: GrButtonSize;
confirmText?: string;
confirmVariant?: GrButtonVariant;
confirmTone?: GrButtonTone;
signal?: AbortSignal;
priority?: number;
appContext?: AppContext | null;
}interface DialogConfirmOptions extends DialogBaseOptions {
cancelText?: string;
onConfirm?: DialogOnConfirm<undefined>;
}interface DialogAlertOptions extends DialogBaseOptions {
onConfirm?: DialogOnConfirm<undefined>;
}interface DialogPromptOptions extends DialogBaseOptions {
cancelText?: string;
value?: string;
label?: string;
placeholder?: string;
required?: boolean;
requiredErrorText?: string;
inputType?: GrPromptDialogProps['inputType'];
inputmode?: InputHTMLAttributes['inputmode'];
maxlength?: number;
showCount?: boolean;
autocomplete?: GrPromptDialogProps['autocomplete'];
name?: GrPromptDialogProps['name'];
autocapitalize?: GrPromptDialogProps['autocapitalize'];
spellcheck?: GrPromptDialogProps['spellcheck'];
multiline?: boolean;
rows?: number;
autosize?: boolean;
rules?: GrFormRule | GrFormRule[];
onConfirm?: DialogOnConfirm<string>;
}interface DialogResult<V = unknown> {
action: DialogCloseAction;
value?: V;
}type DialogKind = 'confirm' | 'alert' | 'prompt';type DialogPromise<T> = Promise<T> & {
close: () => void;interface DialogServiceDefaults extends DialogErrorOptions {
size?: GrDialogSize;
confirmText?: string;
cancelText?: string;
confirmVariant?: GrButtonVariant;
confirmTone?: GrButtonTone;
buttonSize?: GrButtonSize;
closeOnBackdrop?: boolean;
closeOnEsc?: boolean;
}interface DialogService {
confirm: (message: string, options?: DialogConfirmOptions) => DialogPromise<boolean>;Filedist/types/components/GrDialogService/GrDialogServiceHost.vue.d.ts
interface GrDialogServiceHostProps {
state?: DialogServiceState | null;
}Usage from the library prose
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.
Props in this example