@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
text/typescript
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;