@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
JavaScript
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 };