@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.
231 lines (214 loc) • 7.22 kB
text/typescript
import type { DataTag } from "./dataTag";
import type { LayerCancelReason } from "./errors";
import { LayerStack } from "./layerStack";
import type {
DefaultLayerError,
DismissAllOptions,
LayerClientOptions,
LayerKey,
OmitKeyof,
OpenLayerOptions,
StackDefaults,
StackNotifyEvent,
StackOptions,
} from "./types";
import type {
InferValidatorOutput,
OpenValidatePayload,
Validator,
} from "./validators";
/** Prevents validated options from falling through to the unvalidated overload. */
type NoValidateOptions<Opts> = Opts extends { validate: Validator<unknown> }
? never
: Opts;
type OpenImplOptions<P, R, E, D, RootProps, V extends Validator<unknown>> =
| NoValidateOptions<OpenLayerOptions<P, R, E, D, RootProps>>
| (OmitKeyof<
OpenLayerOptions<InferValidatorOutput<V>, R, E, D, RootProps>,
"payload" | "validate"
> & { validate: V; payload: OpenValidatePayload<V> });
/** Coordinates named layer stacks. */
export class LayerClient {
#stacks = new Map<string, LayerStack>();
#childStacksByParent = new Map<string, Set<string>>();
#stackListeners = new Set<(stackId: string) => void>();
#notifyListeners = new Set<(event: StackNotifyEvent) => void>();
#defaultStackOptions: StackDefaults;
constructor(opts: LayerClientOptions = {}) {
this.#defaultStackOptions = opts.defaultStackOptions ?? {};
}
/** Returns a stack, applying options only when creating it. */
ensureStack(id: string, options?: StackOptions): LayerStack {
let stack = this.#stacks.get(id);
if (!stack) {
const mergedOptions: StackOptions = {
...this.#defaultStackOptions[id],
...options,
};
stack = new LayerStack(id, mergedOptions);
stack.onLayerDismiss = (layer) => this.#drainChildStacks(layer.id);
stack.onNotify = (event) => {
for (const listener of this.#notifyListeners) {
try {
listener(event);
} catch {
// Isolate listeners so one throw cannot abort open / ensureStack.
}
}
};
this.#stacks.set(id, stack);
stack.emitRegisterNotify();
this.#stackListeners.forEach((l) => l(id));
}
return stack;
}
#stack(id: string): LayerStack {
return this.ensureStack(id);
}
bindChildStack(parentLayerId: string, childStackId: string): () => void {
let set = this.#childStacksByParent.get(parentLayerId);
if (!set) {
set = new Set();
this.#childStacksByParent.set(parentLayerId, set);
}
set.add(childStackId);
return () => {
const childSet = this.#childStacksByParent.get(parentLayerId);
if (!childSet) {
return;
}
childSet.delete(childStackId);
if (childSet.size === 0) {
this.#childStacksByParent.delete(parentLayerId);
}
};
}
#drainChildStacks(parentLayerId: string): void {
const childIds = this.#childStacksByParent.get(parentLayerId);
if (!childIds) {
return;
}
for (const childId of childIds) {
// Swallow cancelAll rejection if onLayerDismiss throws mid-teardown.
void this.#stacks
.get(childId)
?.cancelAll({ reason: "parentDismiss" })
.catch(() => {});
}
this.#childStacksByParent.delete(parentLayerId);
}
/** Accepts validator input while storing its parsed output as the payload. */
open<
V extends Validator<unknown>,
P = InferValidatorOutput<V>,
R = void,
E = DefaultLayerError,
D = unknown,
RootProps = unknown,
>(
options: OmitKeyof<
OpenLayerOptions<P, R, E, D, RootProps>,
"payload" | "validate"
> & {
validate: V;
payload: NoInfer<OpenValidatePayload<V>>;
},
): Promise<R>;
/** Infers response and error types from a {@link DataTag} key. */
open<P, R, E = DefaultLayerError, D = unknown, RootProps = unknown>(
options: OmitKeyof<
OpenLayerOptions<P, R, E, D, RootProps> & {
key: DataTag<LayerKey, R, E>;
},
"validate"
>,
): Promise<R>;
/** Opens a layer and resolves with its dismissal response. */
open<P, R = void, E = DefaultLayerError, D = unknown, RootProps = unknown>(
options: OmitKeyof<OpenLayerOptions<P, R, E, D, RootProps>, "validate">,
): Promise<R>;
open<
P,
R = void,
E = DefaultLayerError,
D = unknown,
RootProps = unknown,
V extends Validator<unknown> = Validator<unknown>,
>(options: OpenImplOptions<P, R, E, D, RootProps, V>): Promise<R> {
const stackId = options.stack ?? "default";
const stack = this.#stack(stackId) as unknown as LayerStack<P, R, E, D>;
const openOptions = options as OpenLayerOptions<P, R, E, D, RootProps>;
const layer = stack.open({
key: openOptions.key,
// Omitted payloads intentionally remain `undefined` when `P` admits it.
payload: openOptions.payload as P,
component: openOptions.component,
exitingDelay: openOptions.exitingDelay,
enteringDelay: openOptions.enteringDelay,
upsert: openOptions.upsert,
loadFn: openOptions.loadFn,
validate: openOptions.validate,
});
return layer.promise.promise as Promise<R>;
}
getStack(id = "default"): LayerStack {
return this.#stack(id);
}
getStackIds(): string[] {
return [...this.#stacks.keys()];
}
/** Subscribes to first-time stack materialization. */
subscribeStacks(listener: (stackId: string) => void): () => void {
this.#stackListeners.add(listener);
return () => this.#stackListeners.delete(listener);
}
/** Subscribes to labeled stack snapshot transitions (devtools). */
subscribeNotify(listener: (event: StackNotifyEvent) => void): () => void {
this.#notifyListeners.add(listener);
return () => this.#notifyListeners.delete(listener);
}
/**
* Re-emits the current snapshot as a `register` notify for one stack, or all
* materialized stacks when `stackId` is omitted (devtools seed).
*/
seedNotify(stackId?: string): void {
if (stackId !== undefined) {
this.#stacks.get(stackId)?.emitRegisterNotify();
return;
}
for (const stack of this.#stacks.values()) {
stack.emitRegisterNotify();
}
}
/**
* Bulk-dismisses a stack, completing every `open()` with `response`
* (including omitted/`undefined` for void layers). Honors
* {@link DismissAllMode}; does not reject — prefer {@link cancelAll} for
* teardown without a completion value.
*/
dismissAll(
stackId = "default",
response?: unknown,
opts?: DismissAllOptions,
): Promise<void> {
return (
this.#stack(stackId) as LayerStack<unknown, unknown, unknown, unknown>
).dismissAll(response, opts);
}
/**
* Force-clears a stack and rejects every open/queued caller with
* {@link LayerCancelledError}. System teardown — prefer {@link dismissAll}
* when completing with a response.
*
* @param opts.reason - Propagated on each rejection.
* @default opts.reason `"cancelAll"`
*/
cancelAll(
stackId = "default",
opts?: { reason?: LayerCancelReason },
): Promise<void> {
return (
this.#stack(stackId) as LayerStack<unknown, unknown, unknown, unknown>
).cancelAll(opts);
}
}