GrDialogService
Берут, когда окно вызывается из кода.
import { GrDialogService } from '@feugene/granularity/components/GrDialogService'Обзор
Когда брать
- окно вызывается из кода — из обработчика, из стора, из перехватчика запросов: разметки в шаблоне не нужно вовсе;
- результат нужен значением —
confirm/promptвозвращают Promise, и ветвление пишется линейно; - окно открывается из места без шаблона —
.ts-модуль, роутер-гард, обработчик ошибки; - окон много и они одинаковы — общий хост вместо копии
v-modelв каждом компоненте.
Когда взять другое
| Нужно | Берите |
|---|---|
| Окно открывается разметкой и живёт на экране | GrDialog |
| Подтверждение объявлено в шаблоне | GrConfirmDialog |
| Запрос значения объявлен в шаблоне | GrPromptDialog |
| Сообщение без вопроса | GrToaster |
| Своя раскладка модального слоя | GrModal |
Примеры 3 у каждого — живое демо и его код
Массовое действие, спрошенное из обработчика
confirmвозвращает промис, и ответ читается на следующей строке обработчика — без состояния окна и компонента в шаблоне. Он никогда не реджектит: отмена — обычноеfalse. Заголовок, названия кнопок и тон — опции, как пропы GrConfirmDialog, который лежит под ним.ProjectsWebsite relaunch3 months agoQ2 campaign4 months agoBilling migrationyesterdayПереименование с проверкой на сервере
promptрезолвится строкой илиnull— отказ не равен пустой строке. Поле принимает те же опции, что GrPromptDialog:value,maxlengthсshow-count,rules.onConfirmполучает контекст:setLoading, пока проверяет сервер,setFieldErrorставит «уже занято» под поле, а возвратfalseдержит окно открытым;ctx.signalобрывает запрос, если окно закрыли.Workspace nameNorthwind Traders Истёкшая сессия из перехватчика запросов
У модуля API нет шаблона, и диалогу он не нужен: хост монтируется сам при первом вызове. У
alertодин ответ, и он резолвится, когда сообщение прочитано, поэтому перехватчик продолжает после него;close-on-backdropвыключен, чтобы случайный клик не смахнул предупреждение.The report request comes back 401
API порождается из пакета · разойтись не может
Типы
Все экспортируемые типы подпути, по файлам, где они объявлены. Имена, дженерики и тела читаются из файлов деклараций пакета и разойтись не могут.
import type { DialogServiceState, DialogCloseAction, DialogErrorOptions } from '@feugene/granularity/components/GrDialogService'Файлdist/types/components/GrDialogService/useDialogService.d.ts
Vue-плагин: даёт каждому приложению собственное состояние сервиса и снимает
его хост вместе с приложением. Обязателен для нескольких Vue-приложений на
одной странице (микрофронтенды — иначе они делят одну очередь и один хост) и
для HMR, где контейнер прошлого приложения иначе остаётся в document.body.
``ts
app.use(granularityDialogServicePlugin)
``
const granularityDialogServicePlugin: {
install(app: App): void;
}Тестовая/служебная очистка смонтированного хоста. Работает над тем же
состоянием, что и сам сервис: app-scoped при вызове из setup или при
единственном зарегистрированном приложении, иначе — модульным.
function teardownDialogService(): void;Создаёт сервис диалогов. Опциональные defaults применяются ко всем
вызовам (можно переопределить в каждом конкретном вызове).
Контекст приложения кэшируется автоматически при первом вызове внутри
setup. Для вызовов вне компонента используйте setAppContext.
function useDialogService(defaults?: DialogServiceDefaults): DialogService;Готовый синглтон-сервис с дефолтными настройками.
const dialogService: DialogService;Файлdist/types/components/GrDialogService/store.d.ts
Состояние одного инстанса сервиса: очередь, смонтированный хост и захваченный
контекст приложения. Живёт либо в приложении (через
granularityDialogServicePlugin), либо в ленивом модульном фолбэке для
простых SPA — см. useDialogService.ts.
interface DialogServiceState {
/** Очередь заявок. Видимую часть выбирает хост, остальные ждут. */
queue: DialogRequest[];
mounted: boolean;
container: HTMLElement | null;
appContext: AppContext | null;
lastCapturedContext: CapturedDialogContext | null;
contextlessWarned: boolean;
/**
* Заявки, чей `onConfirm` сейчас в полёте, от внешней к самой глубокой.
* По ним определяется вложенность нового вызова.
*/
inFlight: DialogRequest[];
}Файлdist/types/components/GrDialogService/config.d.ts
const grDialogServiceConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrDialogService">;Файлdist/types/components/GrDialogService/types.d.ts
Действие, которым был закрыт диалог.
type DialogCloseAction = 'confirm' | 'cancel' | 'close';Конфигурация разбора серверных ошибок внутри onConfirm.
По умолчанию используется универсальное «ядро» парсеров
(coreResponseErrorParsers) — безопасное для любого бэкенда. Подключение
серверо-специфичных парсеров (Laravel / RFC 7807 / JSON:API) — осознанное,
через errorParsers.
interface DialogErrorOptions {
/**
* Цепочка парсеров для классификации сырого ответа.
* - не задано — `coreResponseErrorParsers`;
* - массив — полная замена цепочки;
* - `(presets) => ResponseErrorParser[]` — композиция из пресетов.
*/
errorParsers?: ResponseErrorParser[] | ((presets: ResponseErrorParserPresets) => ResponseErrorParser[]);
/** Частичный override текстов-фолбэков баннера ошибок (i18n). */
errorTexts?: Partial<ResponseErrorTexts>;
/** Имя поля основного сообщения в теле ответа (для `plainMessageParser`). */
errorMessageKey?: string;
/** Человекочитаемые подписи полей для `fieldErrors`. */
fieldLabels?: Record<string, string>;
}
/**
* Контекст, передаваемый в колбэк `onConfirm`. Позволяет управлять
* жизненным циклом диалога во время асинхронной операции: показывать ошибки,
* управлять лоадером, закрывать диалог.
*/
export interface DialogConfirmContext<V = unknown> {
/** Введённое значение (для `prompt` — строка;Контекст, передаваемый в колбэк onConfirm. Позволяет управлять
жизненным циклом диалога во время асинхронной операции: показывать ошибки,
управлять лоадером, закрывать диалог.
interface DialogConfirmContext<V = unknown> {
/** Введённое значение (для `prompt` — строка; для `confirm`/`alert` — `undefined`). */
readonly value: V;
/** `AbortSignal`, прерывающийся при закрытии/отмене диалога. */
readonly signal: AbortSignal;
/** Показать общую ошибку текстом. `null` — скрыть. */
setError: (message: string | null) => void;Колбэк, выполняемый при клике Confirm.
Возврат:
- resolved / true / void — успех: диалог закрывается;
- false — оставить диалог открытым (без ошибки);
- throw / rejected — ошибка: диалог остаётся открытым, исключение
автоматически прогоняется через setRawError.
type DialogOnConfirm<V = unknown> = (ctx: DialogConfirmContext<V>) => void | boolean | Promise<void | boolean>;
/** Базовые опции, проксируемые в `GrDialog`/`GrModal`. */
export interface DialogBaseOptions extends DialogErrorOptions {Базовые опции, проксируемые в GrDialog/GrModal.
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;
/** Внешний `AbortSignal`: при abort диалог закрывается с `action: 'close'`. */
signal?: AbortSignal;
/**
* Место в очереди среди **ожидающих**: больше — раньше, по умолчанию `0`, при
* равенстве порядок FIFO. Уже показанное окно не прерывается — выдёргивать
* фокус-ловушку из-под пользователя хуже, чем задержка на одно окно.
*/
priority?: number;
/** Наследование контекста приложения (i18n / тема / `granular-provider`). */
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;
/** Тип однострочного поля (`email`, `password`, …). */
inputType?: GrPromptDialogProps['inputType'];
/** Программная клавиатура на мобильных. */
inputmode?: InputHTMLAttributes['inputmode'];
/** Ограничение длины; со `showCount` рисуется счётчик. */
maxlength?: number;
showCount?: boolean;
/**
* Что у человека спрашивают — для браузера и менеджера паролей. Из
* `inputType` не выводится: у `password` три разных правильных значения.
*/
autocomplete?: GrPromptDialogProps['autocomplete'];
/** Имя поля: часть менеджеров паролей без него не сохраняет значение. */
name?: GrPromptDialogProps['name'];
/** Автозаглавная у программной клавиатуры. */
autocapitalize?: GrPromptDialogProps['autocapitalize'];
/** Проверка орфографии. */
spellcheck?: GrPromptDialogProps['spellcheck'];
/** Многострочный ввод вместо однострочного. */
multiline?: boolean;
rows?: number;
autosize?: boolean;
/** Правила проверки значения — те же, что у `GrForm`. */
rules?: GrFormRule | GrFormRule[];
onConfirm?: DialogOnConfirm<string>;
}Результат низкоуровневого open().
interface DialogResult<V = unknown> {
action: DialogCloseAction;
value?: V;
}Тип диалога.
type DialogKind = 'confirm' | 'alert' | 'prompt';Промис вызова диалога с возможностью императивного закрытия.
type DialogPromise<T> = Promise<T> & {
/** Закрыть диалог программно (как `action: 'close'`). */
close: () => void;Дефолты сервиса, общие на приложение.
interface DialogServiceDefaults extends DialogErrorOptions {
size?: GrDialogSize;
confirmText?: string;
cancelText?: string;
confirmVariant?: GrButtonVariant;
confirmTone?: GrButtonTone;
buttonSize?: GrButtonSize;
closeOnBackdrop?: boolean;
closeOnEsc?: boolean;
}Публичный API сервиса диалогов.
interface DialogService {
confirm: (message: string, options?: DialogConfirmOptions) => DialogPromise<boolean>;Файлdist/types/components/GrDialogService/GrDialogServiceHost.vue.d.ts
interface GrDialogServiceHostProps {
/**
* Состояние инстанса сервиса. Приходит пропом при программном монтировании:
* хост живёт вне дерева и своим `inject` до app-scoped состояния не дотянулся
* бы. `inject` остаётся для случая, когда хост поставили в шаблон руками.
*/
state?: DialogServiceState | null;
}Использование из прозы библиотеки
Разрешение промиса
Высокоуровневые методы никогда не реджектят: отмена — штатный исход, а не
исключение. confirm даёт boolean, prompt — string | null, alert —
void. Нужна точная причина закрытия — open(), он резолвит
{ action: 'confirm' | 'cancel' | 'close', value? }.
Каждый промис дополнен методом close(): закрыть диалог из кода.
Асинхронное подтверждение
confirm и prompt принимают onConfirm, который получает контекст и может
быть асинхронным. Пока он в полёте:
- кнопка подтверждения показывает загрузку;
- Esc и клик по бэкдропу отключены — случайное движение не должно оборвать операцию. Кнопка закрытия в шапке остаётся: из окна с зависшим запросом нужен явный выход;
- диалог остаётся открытым, если колбэк вернул
false, выставил ошибку или бросил исключение.
Контекст даёт value, signal (обрывается при закрытии диалога любым
способом — кнопкой, close() промиса, closeAll()), setError /
setFieldError / clearErrors, setRawError, setLoading, close.
setFieldError(field, message) адресный: ошибки складываются в карту по именам
полей. GrPromptDialog показывает ошибку своего поля (value), а если запись
одна — её, каким бы именем она ни была помечена.
Ошибки сервера
Вместо ручного разбора onConfirm передаёт сырой ответ (Response, ошибку
axios/fetch, Error, строку, JSON) в ctx.setRawError. Сервис прогоняет его
через ту же цепочку парсеров, что и GrResponseErrorBanner: общее сообщение
рисуется в теле диалога, fieldErrors раскладываются по полям, окно остаётся
открытым для повторной попытки. Исключение из onConfirm классифицируется
автоматически, если ошибка не выставлена руками.
Цепочка по умолчанию — универсальное ядро (coreResponseErrorParsers): abort,
network, HTTP-статус, plain-message. Они работают на транспортном уровне и не
делают формат-специфичных допущений, поэтому не срабатывают ложно на
почти-Laravel ответах. Серверо-специфичные парсеры (Laravel, RFC 7807,
JSON:API) подключаются осознанно через errorParsers — массивом или
builder-функцией, получающей именованные пресеты.
Контекст приложения
Хост — GrDialogServiceHost — монтируется отдельным render() в
document.body, вне дерева компонентов. Его экспорт нужен плагину и тестам:
ставить хост в шаблон руками не надо, сервис делает это сам.
Vue берёт provides только из appContext, куда попадает лишь app.provide(),
— значения от <GrConfigProvider> до хоста не доходят.
Поэтому конфиг и i18n захватываются в месте вызова и едут вместе с
заявкой.
Приоритет: опция appContext вызова → явный setAppContext →
автокэш из первого вызова useDialogService() внутри setup.
Готовый синглтон dialogService создаётся на импорте модуля, где inject не
работает вовсе. Он берёт контекст последнего вызова useDialogService()
из setup — этого хватает обычному приложению, но при двух поддеревьях с
разными провайдерами выбор будет за последним. Нужна точность — вызывайте
useDialogService() в setup или задайте setAppContext. Открытый без
всякого контекста диалог в dev-сборке печатает предупреждение.
Изоляция приложений
app.use(granularityDialogServicePlugin)Плагин даёт приложению собственную очередь и собственный хост и снимает их по
app.unmount(). Он обязателен там, где приложений на странице несколько
(микрофронтенды — иначе они делят одну очередь), и полезен при HMR, где
контейнер прошлого приложения иначе остаётся висеть в document.body.
Без плагина сервис работает как раньше — на ленивом модульном состоянии; для обычного SPA это ровно то, что нужно.
Синглтон dialogService inject не умеет, поэтому состояние выбирает так:
единственное зарегистрированное плагином — его; несколько — dev-предупреждение
и модульный фолбэк, потому что выбрать за автора тут нечего. Второй случай
лечится вызовом useDialogService() в setup нужного приложения.
teardownDialogService() остаётся ручным выходом и работает над тем же
состоянием, что и сам сервис.
Очередь
Обычные вызовы сериализуются очередью FIFO: показывается голова, следующий
диалог — после закрытия предыдущего. Три алерта из цикла не лягут стопкой, и
за фокус-ловушку никто не конкурирует. closeAll() разбирает очередь в том же
порядке FIFO — промисы резолвятся в порядке вызовов.
priority (по умолчанию 0) двигает заявку среди ожидающих: больше —
раньше, при равенстве порядок вызовов. Показанное окно при этом не
прерывается — выдёргивать фокус-ловушку из-под пользователя хуже, чем задержка
на одно окно.
Завершить заявку могут трое: кнопка в окне, close() её промиса и
closeAll(). Путь один и идемпотентный: повторное завершение — no-op, промис
резолвится ровно один раз, а всё заведённое под заявку (подписка на внешний
signal, AbortController) сворачивается, когда окно уходит с экрана.
Вложенные диалоги
Диалог, открытый из onConfirm другого диалога, показывается поверх него,
а не встаёт в очередь:
await dialog.confirm('Удалить проект?', {
async onConfirm() {
return await dialog.confirm('Точно? Восстановить будет нечем.')
},
})Иначе вложенный вызов ждал бы в очереди того, кто ждёт его: внешнее окно зависало бы в загрузке, а промис не резолвился бы никогда. Стопка окон здесь — не украшение, а единственный способ не получить дедлок.
Esc, inert и возврат фокуса достаются даром от общего стека слоёв: закрывается
верхнее окно, нижнее помечено inert, фокус возвращается в него.
Жизненный цикл и SSR
Хост монтируется лениво при первом вызове и живёт, пока живёт его состояние: с
плагином — до app.unmount(), без плагина — до teardownDialogService().
Сервис клиент-only: без window/document методы бросают ошибку, а не
работают вхолостую — иначе модульная очередь мутировалась бы на сервере и текла
между запросами. Императивные вызовы прячьте за клиентской проверкой.
Доступность
Все гарантии — от переиспользуемого стека GrConfirmDialog/GrPromptDialog/
GrDialog/GrModal: единственный заголовок на окно, фокус-ловушка, возврат
фокуса. Esc и бэкдроп следуют closeOnEsc/closeOnBackdrop и уходят в ветку
cancel/close.
Пропы этого примера