UNPKG

@stainless-code/vue-layers

Version:

Vue adapter for @stainless-code/layers — call a layer like an async function and await its response.

407 lines (406 loc) 11.1 kB
import { LayerClient, childStackId, createCallContext, createLayer, createLayerGroup, keySignature, shallowArrayEqual } from "@stainless-code/layers"; import { Fragment, computed, defineComponent, h, inject, onScopeDispose, provide, shallowRef, watch } from "vue"; export * from "@stainless-code/layers"; //#region src/index.ts const LAYER_CLIENT_KEY = Symbol("layers.client"); /** * Provides a {@link LayerClient} to descendant components. * * @param client Client to provide. A new client is created when omitted. * @returns The provided client. */ function provideLayerClient(client) { const c = client ?? new LayerClient(); provide(LAYER_CLIENT_KEY, c); return c; } /** * Reads the nearest {@link LayerClient} from Vue injection context. * * @returns The nearest provided client. */ function useLayerClient() { const c = inject(LAYER_CLIENT_KEY, void 0); if (!c) throw new Error("[layers/vue] No LayerClient provided — call provideLayerClient() in a parent setup()."); return c; } function defaultSelector(states) { return states; } /** * Shared snapshot subscription primitive for mounted and queued stack hooks. * * Selector output is memoized against the stable snapshot reference so refs do * not churn object or array selections. */ function useSnapshot(stack, getSource, select, compare) { let cache = null; const runSelect = (base) => { const prev = cache; if (prev && prev.base === base) return prev.value; const next = select(base); if (prev && compare(prev.value, next)) { cache = { base, value: prev.value }; return prev.value; } cache = { base, value: next }; return next; }; const ref = shallowRef(runSelect(getSource())); onScopeDispose(stack.subscribe(() => { const prev = ref.value; const next = runSelect(getSource()); if (!compare(prev, next)) ref.value = next; })); return ref; } /** * Exposes a {@link LayerClient} stack as a readonly Vue ref. * * Call it inside `setup()` or an `effectScope()` so `onScopeDispose` can clean * up the stack subscription. * * @param opts Options bag: `stack`, `select`, `compare`. * @param client Client to observe. Omit it to use {@link useLayerClient}. * @returns A readonly ref of the selected stack value. * @default `stack` is `"default"`; `select` is identity; `compare` is * `Object.is`. */ function useStack(opts = {}, client) { const stack = (client ?? useLayerClient()).getStack(opts.stack ?? "default"); return useSnapshot(stack, () => stack.getSnapshot(), opts.select ?? defaultSelector, opts.compare ?? Object.is); } /** Exposes a stack's queued snapshot as a readonly Vue ref. */ function useQueuedStack(opts = {}, client) { const stack = (client ?? useLayerClient()).getStack(opts.stack ?? "default"); return useSnapshot(stack, () => stack.getQueuedSnapshot(), opts.select ?? defaultSelector, opts.compare ?? Object.is); } /** * Observe all mounted layers matching a key. * * A {@link DataTag} key infers its response and error types. */ function useLayerState(opts, client) { const sig = keySignature(opts.key); return useStack({ stack: opts.stack, select: (states) => { const filtered = states.filter((s) => keySignature(s.key) === sig); return opts.select ? opts.select(filtered) : filtered; }, compare: opts.compare ?? shallowArrayEqual }, client); } /** Observe all queued layers matching a key. */ function useLayerQueuedState(opts, client) { const sig = keySignature(opts.key); return useQueuedStack({ stack: opts.stack, select: (states) => { const filtered = states.filter((s) => keySignature(s.key) === sig); return opts.select ? opts.select(filtered) : filtered; }, compare: opts.compare ?? shallowArrayEqual }, client); } function useLayerImpl(options, client) { const resolved = client ?? useLayerClient(); const stackId = options.stack ?? "default"; const sig = keySignature(options.key); const selectByKey = (states) => states.filter((s) => keySignature(s.key) === sig); const state = useStack({ stack: stackId, select: selectByKey, compare: shallowArrayEqual }, resolved); const queued = useQueuedStack({ stack: stackId, select: selectByKey, compare: shallowArrayEqual }, resolved); const top = computed(() => state.value.at(-1) ?? null); const base = createLayer(options, resolved); return { ...base, get current() { return base.current; }, state, queued, top }; } function useLayer(options, client) { return useLayerImpl(options, client); } /** Render every active layer in a stack with its registered component. */ const StackOutlet = defineComponent({ name: "StackOutlet", props: { stack: { type: String, default: "default" }, rootProps: { type: null, default: void 0 } }, setup(props) { const stk = useLayerClient().getStack(props.stack); const states = useStack({ stack: props.stack }); return () => h(Fragment, states.value.map((s) => { const layer = stk.getLayer(s.id); const Component = layer?.component; if (!layer || !Component) { if (process.env.NODE_ENV !== "production") console.warn(`[layers/vue] No component for layer ${s.id} (key ${JSON.stringify(s.key)}); StackOutlet renders nothing. Provide a \`component\` or use useStack.`); return null; } const call = createCallContext(stk, layer, s, props.rootProps); return h(Component, { key: s.id, call, payload: s.payload, data: s.data, error: s.error, phase: s.phase, transition: s.transition, actionStatus: s.actionStatus, dismissing: s.dismissing }); })); } }); /** * Render a selected stack value through a default scoped slot (cross-adapter * parity). The slot payload is `{ value: unknown }` — `defineComponent` can't * thread the selector's return type through a slot; prefer * `useStack({ stack, select })` in `setup()` for a fully-typed value. * * @example * ```vue * <StackSubscribe :selector="(s) => s.length"> * <template #default="{ value }">{{ value }} open</template> * </StackSubscribe> * ``` */ const StackSubscribe = defineComponent({ name: "StackSubscribe", props: { stack: { type: String, default: "default" }, selector: { type: Function, required: true } }, slots: Object, setup(props, { slots }) { const value = useStack({ stack: props.stack, select: props.selector }); return () => slots.default?.({ value: value.value }); } }); /** Return the states and call contexts needed to render a stack headlessly. */ function useStackHandles(stack = "default", rootProps) { const stk = useLayerClient().getStack(stack); const states = useStack({ stack }); const getCall = (state) => { const layer = stk.getLayer(state.id); return createCallContext(stk, layer, state, rootProps); }; return { states, getCall }; } /** * Coordinate a layer's pending state with an async mutation and end it on success. * * @example * ```vue * <script setup lang="ts"> * import { type LayerComponentProps, useMutationFlow } from "@stainless-code/vue-layers"; * * const props = defineProps<LayerComponentProps<void, boolean>>(); * const flow = useMutationFlow(props.call); * <\/script> * * <template> * <button * :disabled="flow.pending.value" * @click="flow.run(() => Promise.resolve()).orEnd(true)" * > * Confirm * </button> * </template> * ``` */ function useMutationFlow(call) { const pending = shallowRef(false); const run = (fn) => ({ orEnd: async (response) => { pending.value = true; call.setRunning(true); try { await fn(); call.end(response); } finally { call.setRunning(false); pending.value = false; } } }); return { pending, run }; } /** * Create a child stack scoped to the calling layer's lifetime. * * The child stack is disposed and cleared via `cancelAll` when its parent * layer unmounts (`LayerCancelledError`). */ function useLayerGroup(call, options) { const client = useLayerClient(); const stackId = childStackId(call, options?.name); const group = createLayerGroup(client, call, options); onScopeDispose(() => { group.dispose(); client.cancelAll(group.stackId, { reason: "groupDispose" }); }); const states = useStack({ stack: stackId }); const open = (opts) => client.open({ ...opts, stack: stackId }); const dismissAll = (response) => client.dismissAll(stackId, response); return { open, dismissAll, states, Outlet: defineComponent({ name: "LayerGroupOutlet", props: { rootProps: { type: null, default: void 0 } }, setup(props) { return () => h(StackOutlet, { stack: stackId, rootProps: props.rootProps }); } }), stackId }; } /** Create provider, host, controlled-layer, and access hooks bound to one stack. */ function createStackHook(config = {}) { const stackId = config.stack ?? "default"; const StackProvider = defineComponent({ name: "StackProvider", props: { client: { type: Object, default: void 0 } }, setup(props, { slots }) { provideLayerClient(props.client ?? config.client); return () => slots.default?.(); } }); function useAppStack() { const client = useLayerClient(); const states = useStack({ stack: stackId }); const open = (options) => client.open({ ...options, stack: stackId }); const dismissAll = (response) => client.dismissAll(stackId, response); return { open, dismissAll, states }; } return { StackProvider, useAppStack, AppHost: defineComponent({ name: "AppHost", inheritAttrs: false, setup(_, { attrs }) { return () => { const outlet = h(StackOutlet, { stack: stackId, rootProps: attrs }); return config.Host ? h(config.Host, attrs, { default: () => outlet }) : outlet; }; } }), AppLayer: defineComponent({ name: "AppLayer", props: { options: { type: Object, required: true }, open: { type: Boolean, required: true }, payload: { type: null, default: void 0 }, onResolved: { type: Function, default: void 0 } }, setup(props) { const client = useLayerClient(); let opened = false; watch(() => props.open, (isOpen) => { if (isOpen && !opened) { opened = true; client.open({ ...props.options, stack: stackId, payload: props.payload }).then((response) => { opened = false; props.onResolved?.(response); }); } else if (!isOpen && opened) { const stack = client.getStack(stackId); const layer = stack.find(props.options.key); if (layer) stack.dismiss(layer, void 0); opened = false; } }, { immediate: true }); onScopeDispose(() => { if (opened) { const stack = client.getStack(stackId); const layer = stack.find(props.options.key); if (layer) stack.dismiss(layer, void 0); opened = false; } }); return () => null; } }) }; } //#endregion export { StackOutlet, StackSubscribe, createStackHook, provideLayerClient, useLayer, useLayerClient, useLayerGroup, useLayerQueuedState, useLayerState, useMutationFlow, useQueuedStack, useStack, useStackHandles };