GrDialogService

  • ядро
  • Слои

Берут, когда окно вызывается из кода.

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

Обзор

Когда брать

  • окно вызывается из кода — из обработчика, из стора, из перехватчика запросов: разметки в шаблоне не нужно вовсе;
  • результат нужен значением — confirm/prompt возвращают Promise, и ветвление пишется линейно;
  • окно открывается из места без шаблона — .ts-модуль, роутер-гард, обработчик ошибки;
  • окон много и они одинаковы — общий хост вместо копии v-model в каждом компоненте.

Когда взять другое

НужноБерите
Окно открывается разметкой и живёт на экранеGrDialog
Подтверждение объявлено в шаблонеGrConfirmDialog
Запрос значения объявлен в шаблонеGrPromptDialog
Сообщение без вопросаGrToaster
Своя раскладка модального слояGrModal

Примеры 3 у каждого — живое демо и его код

  • Массовое действие, спрошенное из обработчика

    confirm возвращает промис, и ответ читается на следующей строке обработчика — без состояния окна и компонента в шаблоне. Он никогда не реджектит: отмена — обычное false. Заголовок, названия кнопок и тон — опции, как пропы GrConfirmDialog, который лежит под ним.

    Projects
    Website relaunch3 months ago
    Q2 campaign4 months ago
    Billing 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

granularityDialogServicePluginconst

Vue-плагин: даёт каждому приложению собственное состояние сервиса и снимает его хост вместе с приложением. Обязателен для нескольких Vue-приложений на одной странице (микрофронтенды — иначе они делят одну очередь и один хост) и для HMR, где контейнер прошлого приложения иначе остаётся в document.body. ``ts app.use(granularityDialogServicePlugin) ``

const granularityDialogServicePlugin: {
    install(app: App): void;
}
teardownDialogServicefunction

Тестовая/служебная очистка смонтированного хоста. Работает над тем же состоянием, что и сам сервис: app-scoped при вызове из setup или при единственном зарегистрированном приложении, иначе — модульным.

function teardownDialogService(): void;
useDialogServicefunction

Создаёт сервис диалогов. Опциональные defaults применяются ко всем вызовам (можно переопределить в каждом конкретном вызове). Контекст приложения кэшируется автоматически при первом вызове внутри setup. Для вызовов вне компонента используйте setAppContext.

function useDialogService(defaults?: DialogServiceDefaults): DialogService;
dialogServiceconst

Готовый синглтон-сервис с дефолтными настройками.

const dialogService: DialogService;

Файлdist/types/components/GrDialogService/store.d.ts

DialogServiceStateinterface

Состояние одного инстанса сервиса: очередь, смонтированный хост и захваченный контекст приложения. Живёт либо в приложении (через 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

grDialogServiceConfigconst
const grDialogServiceConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrDialogService">;

Файлdist/types/components/GrDialogService/types.d.ts

DialogCloseActiontype

Действие, которым был закрыт диалог.

type DialogCloseAction = 'confirm' | 'cancel' | 'close';
DialogErrorOptionsinterface

Конфигурация разбора серверных ошибок внутри 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` — строка;
DialogConfirmContextinterface

Контекст, передаваемый в колбэк onConfirm. Позволяет управлять жизненным циклом диалога во время асинхронной операции: показывать ошибки, управлять лоадером, закрывать диалог.

interface DialogConfirmContext<V = unknown> {
    /** Введённое значение (для `prompt` — строка; для `confirm`/`alert` — `undefined`). */
    readonly value: V;
    /** `AbortSignal`, прерывающийся при закрытии/отмене диалога. */
    readonly signal: AbortSignal;
    /** Показать общую ошибку текстом. `null` — скрыть. */
    setError: (message: string | null) => void;
DialogOnConfirmtype

Колбэк, выполняемый при клике 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 {
DialogBaseOptionsinterface

Базовые опции, проксируемые в 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;
}
DialogConfirmOptionsinterface
interface DialogConfirmOptions extends DialogBaseOptions {
    cancelText?: string;
    onConfirm?: DialogOnConfirm<undefined>;
}
DialogAlertOptionsinterface
interface DialogAlertOptions extends DialogBaseOptions {
    onConfirm?: DialogOnConfirm<undefined>;
}
DialogPromptOptionsinterface
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>;
}
DialogResultinterface

Результат низкоуровневого open().

interface DialogResult<V = unknown> {
    action: DialogCloseAction;
    value?: V;
}
DialogKindtype

Тип диалога.

type DialogKind = 'confirm' | 'alert' | 'prompt';
DialogPromisetype

Промис вызова диалога с возможностью императивного закрытия.

type DialogPromise<T> = Promise<T> & {
    /** Закрыть диалог программно (как `action: 'close'`). */
    close: () => void;
DialogServiceDefaultsinterface

Дефолты сервиса, общие на приложение.

interface DialogServiceDefaults extends DialogErrorOptions {
    size?: GrDialogSize;
    confirmText?: string;
    cancelText?: string;
    confirmVariant?: GrButtonVariant;
    confirmTone?: GrButtonTone;
    buttonSize?: GrButtonSize;
    closeOnBackdrop?: boolean;
    closeOnEsc?: boolean;
}
DialogServiceinterface

Публичный API сервиса диалогов.

interface DialogService {
    confirm: (message: string, options?: DialogConfirmOptions) => DialogPromise<boolean>;

Файлdist/types/components/GrDialogService/GrDialogServiceHost.vue.d.ts

GrDialogServiceHostPropsinterface
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-сборке печатает предупреждение.

Изоляция приложений
ts
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 другого диалога, показывается поверх него, а не встаёт в очередь:

ts
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.