UNPKG

@stainless-code/layers

Version:

Headless, UI-agnostic layer/stack manager for modal/dialog/drawer/popover/toast UI. Call a layer like an async function and await its response.

254 lines (227 loc) 7.44 kB
import type { Validator } from "./validators"; /** * Logical identity for a layer (`find` / `upsert` / `gcTime`). * * Must be JSON-safe: `string` | `boolean` | `null` | finite `number`, or * plain objects / arrays of those. Compared via sorted `JSON.stringify` — * two keys with the same values in a different object-property order are equal. * Invalid keys throw `LayerKeyError` from any path that hashes the key * (`hashKey`, `open`, `find`, `cancelQueued`, `createLayer`). */ export type LayerKey = ReadonlyArray<unknown>; export type LayerPhase = | "pending" | "queued" | "active" | "dismissed" | "error"; /** Animation state, independent of resolution phase. */ export type LayerTransition = "entering" | "settled" | "exiting"; export type LayerActionStatus = "idle" | "running"; /** Instance-scoped predicate — `true` allows dismissal. */ export type BlockerFn = () => boolean | Promise<boolean>; /** Stack-scoped predicate — `true` allows dismissal. */ export type StackBlockerFn = (layer: LayerState) => boolean | Promise<boolean>; /** * How {@link LayerStack#dismissAll} treats blockers. All modes still * **resolve** `open()` with the dismiss response — unlike {@link LayerStack#cancelAll}, * which rejects with {@link LayerCancelledError}. * * - `skipBlocked` — soft-dismiss each layer; leave blocked ones * - `stopAtBlocked` — soft-dismiss until the first veto, then stop * - `force` — bypass blockers; still completes with the response */ export type DismissAllMode = "skipBlocked" | "stopAtBlocked" | "force"; export interface DismissOptions { force?: boolean; } export interface DismissAllOptions { mode?: DismissAllMode; } /** Immutable layer snapshot consumed by selectors. */ export interface LayerState< P = unknown, R = void, E = DefaultLayerError, D = unknown, > { id: string; key: LayerKey; payload: P; data?: D; response?: R; error?: E; phase: LayerPhase; transition: LayerTransition; /** `true` while a user-intent dismiss is consulting blockers. */ dismissing: boolean; actionStatus: LayerActionStatus; ended: boolean; index: number; stackSize: number; } /** Imperative controls available to a rendered layer. */ export interface LayerCallContext<P, R, RootProps = unknown> { end: (response: R, opts?: DismissOptions) => Promise<boolean>; dismiss: (response: R, opts?: DismissOptions) => Promise<boolean>; addBlocker: (fn: BlockerFn) => () => void; update: (patch: Partial<P>) => void; setRunning: (running: boolean) => void; /** Finishes the current transition immediately. */ settle: () => void; ended: boolean; index: number; stackSize: number; root: RootProps; readonly stackId: string; readonly layerId: string; } export interface LayerComponentProps< P = unknown, R = void, E = DefaultLayerError, D = unknown, RootProps = unknown, > { call: LayerCallContext<P, R, RootProps>; payload: P; data?: D; error?: E; phase: LayerPhase; transition: LayerTransition; dismissing: boolean; actionStatus: LayerActionStatus; } /** Framework adapters narrow this to their component type. */ export type LayerComponent = unknown; export interface LayerOptions< P = unknown, R = void, E = DefaultLayerError, D = unknown, RootProps = unknown, > { /** @default "default" */ stack?: string; /** Stable identity; same key + `upsert` → update existing instance. */ key: LayerKey; component?: LayerComponent; /** Enter duration in milliseconds; `call.settle()` finishes it early. @default 0 */ enteringDelay?: number; /** Exit duration in milliseconds; `call.settle()` finishes it early. @default 0 */ exitingDelay?: number; /** When true, reusing an active key updates its payload instead of stacking. */ upsert?: boolean; /** Loads data before activation; dismissal aborts the signal. */ loadFn?: (ctx: { payload: P; signal: AbortSignal }) => Promise<D> | D; /** Validates payload before opening; the parsed output becomes the payload. */ validate?: Validator<P>; /** Props passed to every layer in the stack via `call.root`. */ rootProps?: RootProps; /** * Phantom fields preserve payload/response/error inference without runtime values. * `@internal` is required for `stripInternal` to hide them from published declarations. */ /** @internal */ readonly _payload?: P; /** @internal */ readonly _response?: R; /** @internal */ readonly _error?: E; } /** * Makes `payload` optional only when `P` admits `undefined`. * Optional object properties alone do not make the payload omittable. */ export type PayloadArg<P> = undefined extends P ? { payload?: P } : { payload: P }; export type OpenLayerOptions< P = unknown, R = void, E = DefaultLayerError, D = unknown, RootProps = unknown, > = LayerOptions<P, R, E, D, RootProps> & PayloadArg<P>; /** Rejects keys that do not exist on `T`. */ export type OmitKeyof<T, K extends keyof T> = Omit<T, K>; /** Serial policy when a mounted layer's `loadFn` rejects. */ export type SerialOnLoadError = "block" | "advance"; export interface StackOptions { /** * Serial scope queues unmounted opens until the occupying layer leaves * (`pending` / `active` / `error` for `onLoadError: "block"`). * @default { strategy: "parallel" } */ scope?: { strategy: "serial" | "parallel"; /** * Serial only. `block` — keep `phase: "error"` until dismiss. * `advance` — remove the failed layer and drain the next queued open. * @default "block" */ onLoadError?: SerialOnLoadError; }; /** Retains loaded data for same-key restoration. @default 0 */ gcTime?: number; /** @default "skipBlocked" */ dismissAllMode?: DismissAllMode; } export type StackDefaults = Record<string, StackOptions>; export interface LayerClientOptions { defaultStackOptions?: StackDefaults; } /** * Coarse mutation label for devtools / {@link LayerClient#subscribeNotify}. * `dismissAll` = bulk completion; `cancelAll` = teardown that rejects `open()`. */ export type StackNotifyAction = | "register" | "open" | "queue" | "update" | "setRunning" | "settle" | "dismiss" | "dismissVetoed" | "dismissAll" | "cancelAll" | "cancelQueued" | "phase" | "remove"; /** JSON-safe layer projection on {@link StackNotifyEvent}. */ export interface LayerNotifyView { id: string; /** Display string from {@link keySignature}. */ key: string; phase: LayerPhase; transition: LayerTransition; actionStatus: LayerActionStatus; dismissing: boolean; ended: boolean; index: number; stackSize: number; payload?: unknown; /** `true` when `payload` could not be JSON-cloned and was omitted. */ payloadTruncated?: boolean; } /** Emitted when a stack snapshot changes after a labeled mutation. */ export interface StackNotifyEvent { stackId: string; seq: number; ts: number; action: StackNotifyAction; active: LayerNotifyView[]; queued: LayerNotifyView[]; } /** * Module-augmentation point for app-wide type defaults. * Augment `defaultError` to set the library-wide error type once. * * @example * declare module "@stainless-code/layers" { * interface Register { defaultError: AppError } * } */ export interface Register {} /** App-wide error type configured through {@link Register}. */ export type DefaultLayerError = Register extends { defaultError: infer E } ? E : Error;