@stainless-code/vue-layers
Version:
Vue adapter for @stainless-code/layers — call a layer like an async function and await its response.
448 lines (350 loc) • 16.4 kB
Markdown
---
name: vue-layers
description: Vue adapter for @stainless-code/vue-layers; open UI from anywhere and manage ordered, named stacks with typed or fire-and-forget `open`
license: MIT
keywords:
- tanstack-intent
- vue
- modal
- dialog
- stack
- typescript
metadata:
library: "@stainless-code/vue-layers"
library_version: "0.2.4"
framework: "vue"
sources:
- https://stainless-code.com/layers/adapters/vue
- https://github.com/stainless-code/layers/blob/main/docs/architecture.md
---
# Vue layer/stack UI with @stainless-code/vue-layers
Open any layer from anywhere and manage modal, dialog, drawer, popover, or toast UI as an ordered, named stack. `@stainless-code/vue-layers` is the Vue 3 adapter for `@stainless-code/layers`; awaiting a typed result is optional, and fire-and-forget invocation (`void client.open(...)`) is equally first-class.
Named stacks, singletons with `upsert` and live `update`, serial queues, nested stacks, transitions, dismissal blockers, payload validation, and headless rendering each provide standalone value. The package re-exports `@stainless-code/layers`, so adapter and core APIs share one import path. For engine internals, use the [`layers` core skill](https://github.com/stainless-code/layers/blob/main/packages/core/skills/layers/SKILL.md).
## When to use this skill
- Reach for `@stainless-code/vue-layers` to open overlay UI imperatively from anywhere—components, composables, route guards, or non-UI code—and manage it as an ordered, named stack instead of prop-drilling `isOpen`, lifting state, or threading `onConfirm` callbacks.
- It fits when you need any of: open/close from anywhere; await a typed result or fire-and-forget (both first-class); named/ordered stacks, singletons (`upsert`) with live `update`, or one-at-a-time queues; nested stacks (`useLayerGroup`), enter/exit animations, dismissal guards (blockers), payload validation, or headless rendering (`useStackHandles`).
**Skip it only when:** you have a single, always-local overlay opened from one component, with no return, stacking, queue, animation, or guard needs and no wish for a global registry.
Full fit matrix: [When to use Layers](https://stainless-code.com/layers/concepts/when-to-use).
## Install
```bash
bun add @stainless-code/vue-layers
```
Core is included. `vue` (>=3.3) is a required peer and should already be installed by the app.
## Declare → Mount → Call
```vue
<!-- ConfirmDialog.vue -->
<script setup lang="ts">
import type { LayerComponentProps } from "@stainless-code/vue-layers";
import type { ConfirmPayload, ConfirmResponse } from "./confirm";
const { call, payload } =
defineProps<LayerComponentProps<ConfirmPayload, ConfirmResponse>>();
</script>
<template>
<div role="dialog">
<h2>{{ payload.title }}</h2>
<button type="button" @click="call.end(true)">Yes</button>
<button type="button" @click="call.end(false)">No</button>
</div>
</template>
```
```ts
// confirm.ts
import { layerOptions } from "@stainless-code/vue-layers";
import ConfirmDialog from "./ConfirmDialog.vue";
export type ConfirmPayload = { title: string };
export type ConfirmResponse = boolean;
export const confirm = layerOptions<ConfirmPayload, ConfirmResponse>({
stack: "confirm",
key: ["confirm", "remove"],
component: ConfirmDialog,
exitingDelay: 200,
});
```
```vue
<!-- App.vue — mount the stack outlet once, high in the tree -->
<script setup lang="ts">
import { provideLayerClient, StackOutlet } from "@stainless-code/vue-layers";
provideLayerClient();
</script>
<template>
<RouterView />
<StackOutlet stack="confirm" />
</template>
```
```vue
<!-- Opener.vue -->
<script setup lang="ts">
import { useLayer } from "@stainless-code/vue-layers";
import { confirm } from "./confirm";
const c = useLayer(confirm);
async function handleRemove() {
const ok = await c.open({ title: "Remove?" });
if (!ok) return;
deleteItem();
}
</script>
<template>
<button type="button" @click="handleRemove()">Remove</button>
</template>
```
Low-level bag-form: `useLayerClient()` + `client.open({ ...confirm, payload })`.
`payload` is optional when its type is `void`, `unknown`, `undefined`, or a union containing `undefined`; responses default to `void`. This no-payload, fire-and-forget layer omits `payload`, does not await the returned promise, and dismisses without a response:
```vue
<!-- NoticeDialog.vue -->
<script setup lang="ts">
import type { LayerComponentProps } from "@stainless-code/vue-layers";
const { call } = defineProps<LayerComponentProps<void>>();
</script>
<template>
<div role="status">
Changes saved.
<button type="button" @click="call.dismiss()">Close</button>
</div>
</template>
```
```ts
// notice.ts
import { layerOptions } from "@stainless-code/vue-layers";
import NoticeDialog from "./NoticeDialog.vue";
export const notice = layerOptions<void>({
stack: "notice",
key: ["notice", "saved"],
component: NoticeDialog,
});
```
```vue
<!-- FireAndForgetOpener.vue -->
<script setup lang="ts">
import { useLayer } from "@stainless-code/vue-layers";
import { notice } from "./notice";
const c = useLayer(notice);
function showNotice() {
void c.open();
}
</script>
<template>
<button type="button" @click="showNotice()">Save</button>
</template>
```
## The `call` context
Each layer component receives `call` (`end`/`dismiss`/`update`/`setRunning`/`settle`/`ended`/`index`/`stackSize`/`root`/`stackId`/`layerId`/`addBlocker`), `payload`, `data`, `error`, `phase`, `transition`, `actionStatus`, `dismissing`. Use `await call.end(response)` to resolve the caller's `await` and dismiss the layer (`Promise<boolean>` — `false` if vetoed). `setRunning(true|false)` flips `actionStatus` manually; `useMutationFlow` (below) wraps `setRunning` + `end` for the common save-then-close case.
**Key vs id:** `key` is the logical identity (`find`/`upsert`/`gcTime`); each mount gets a unique instance `id`. Use `s.id` for `v-for` keys; `parallel` stacks may hold multiple same-key layers.
## Wired handle: useLayer
```ts
const c = useLayer(confirm);
await c.open({ title: "Remove?" });
```
## Subscribing to stacks
### useStack / useQueuedStack
Options-bag + optional trailing `client`; `select` (not `selector`). Returns `Readonly<Ref<T>>`:
```vue
<script setup lang="ts">
import { useStack, useQueuedStack } from "@stainless-code/vue-layers";
const stack = useStack({ stack: "confirm" });
const count = useStack({ stack: "confirm", select: (s) => s.length });
const queued = useQueuedStack({ stack: "confirm" });
</script>
```
### useLayerState / useLayerQueuedState
Observe-only; `Readonly<Ref<LayerState[]>>` for all same-key instances:
```vue
<script setup lang="ts">
import { useLayerState } from "@stainless-code/vue-layers";
import { confirm } from "./confirm";
const states = useLayerState({ key: confirm.key, stack: "confirm" });
const top = computed(() => states.value.at(-1));
</script>
```
Optional `select` and `compare` (default `Object.is`) limit ref updates when only a slice matters.
### StackSubscribe
Isolate a subscription in a scoped-slot component. The slot payload is `{ value: unknown }` — prefer `useStack({ select })` in `setup()` for a fully typed ref:
```vue
<script setup lang="ts">
import { StackSubscribe } from "@stainless-code/vue-layers";
</script>
<template>
<StackSubscribe stack="confirm" :selector="(s) => s.length">
<template #default="{ value }">{{ value }} open</template>
</StackSubscribe>
</template>
```
## Rendering layers
`StackOutlet` is the default host. It maps each state to its registered `component` with full props (`call`, `payload`, `data`, `error`, `phase`, `transition`, `actionStatus`, `dismissing`). A missing component renders nothing and warns in development.
`StackOutlet` renders inline. Wrap it in `<Teleport to="body">` when you need a portal target.
### Headless: useStackHandles
When you need custom hosts (switch by key, your own wrappers, alternate DOM slots):
```vue
<script setup lang="ts">
import { useStackHandles } from "@stainless-code/vue-layers";
const { states, getCall } = useStackHandles("modal");
</script>
<template>
<template v-for="s in states" :key="s.id">
<Sheet
v-if="s.key[0] === 'sheet'"
:call="getCall(s)"
:payload="s.payload"
/>
<Dialog v-else :call="getCall(s)" :payload="s.payload" />
</template>
</template>
```
`states` is `Readonly<Ref<LayerState[]>>`; `getCall(state)` returns the layer's `LayerCallContext`.
## Nested layers
Inside a layer component, `useLayerGroup(call)` owns a child stack scoped to the parent's lifetime — parent dismiss / unmount `cancelAll`s it (`LayerCancelledError`).
```vue
<!-- SettingsDrawer.vue -->
<script setup lang="ts">
import {
useLayerGroup,
layerOptions,
type LayerComponentProps,
} from "@stainless-code/vue-layers";
import AdvancedPanel from "./AdvancedPanel.vue";
const nested = layerOptions<{ label: string }>({
key: ["settings", "advanced"],
component: AdvancedPanel,
});
const { call, payload } =
defineProps<LayerComponentProps<{ title: string }, boolean>>();
const group = useLayerGroup(call);
</script>
<template>
<div role="dialog" :aria-label="payload.title">
<button
type="button"
@click="group.open({ ...nested, payload: { label: 'Advanced' } })"
>
Open nested
</button>
<group.Outlet />
<button type="button" @click="call.dismiss(false)">Close</button>
</div>
</template>
```
Returns `{ open, dismissAll, states, Outlet, stackId }`. `open` pre-binds the child `stack` id.
## Async actions
Drive a layer's `actionStatus` while an async mutation runs, then end the layer on success:
```vue
<!-- ConfirmSave.vue -->
<script setup lang="ts">
import {
useMutationFlow,
type LayerComponentProps,
} from "@stainless-code/vue-layers";
const { call, payload } =
defineProps<LayerComponentProps<{ name: string }, boolean>>();
const flow = useMutationFlow(call);
async function save() {
await flow
.run(async () => {
await saveToServer(payload.name);
})
.orEnd(true);
}
</script>
<template>
<div role="dialog">
<p>Save {{ payload.name }}?</p>
<button type="button" :disabled="flow.pending" @click="save()">
{{ flow.pending ? "Saving…" : "Save" }}
</button>
<button type="button" @click="call.end(false)">Cancel</button>
</div>
</template>
```
If the mutation throws, `orEnd` restores the idle status, leaves the layer open, and rethrows. `pending` is `Readonly<Ref<boolean>>` — use `flow.pending.value` in script or `flow.pending` in templates.
## App chrome: createStackHook
Bind a stack id, optional client, and optional host wrapper once:
```ts
// app-stack.ts
import { createStackHook } from "@stainless-code/vue-layers";
export const { StackProvider, useAppStack, AppHost, AppLayer } =
createStackHook({ stack: "modal" });
```
```vue
<!-- App.vue -->
<script setup lang="ts">
import SettingsDialog from "./SettingsDialog.vue";
import { StackProvider, AppHost } from "./app-stack";
</script>
<template>
<StackProvider>
<AppHost />
<ModalTrigger />
</StackProvider>
</template>
```
```vue
<!-- ModalTrigger.vue -->
<script setup lang="ts">
import SettingsDialog from "./SettingsDialog.vue";
import { useAppStack } from "./app-stack";
const { open } = useAppStack();
</script>
<template>
<button
type="button"
@click="
open({
key: ['modal', 'settings'],
component: SettingsDialog,
})
"
>
Open settings
</button>
</template>
```
- **`useAppStack()`** — `{ open, dismissAll, states }` with `stack` pre-bound; `states` is a ref.
- **`AppHost`** — renders `StackOutlet` for the bound stack. Host props are forwarded via fallthrough attrs as `rootProps`; an optional configured `Host` receives the same attrs and wraps the outlet.
- **`AppLayer`** — controlled open/close via props (`open`, `payload`, `options`, `onResolved`):
```vue
<script setup lang="ts">
import { AppLayer } from "./app-stack";
import { settingsOpts } from "./settings";
defineProps<{ open: boolean }>();
const emit = defineEmits<{ close: [] }>();
</script>
<template>
<AppLayer
:options="settingsOpts"
:open="open"
:payload="{ title: 'Settings' }"
:on-resolved="() => emit('close')"
/>
</template>
```
## Adapter API
| Kind | Name | Signature / shape |
| --------- | ---------------------- | ------------------------------------------------------------------------------ |
| Function | `provideLayerClient` | `(client?: LayerClient) => LayerClient` |
| Hook | `useLayerClient` | `() => LayerClient` |
| Hook | `useLayer` | `(options, client?) => WiredLayerHandle refs + state/queued/top` |
| Hook | `useLayerState` | `({ key, stack?, select?, compare? }, client?) => Readonly<Ref<LayerState[]>>` |
| Hook | `useLayerQueuedState` | `({ key, stack?, select?, compare? }, client?) => Readonly<Ref<LayerState[]>>` |
| Hook | `useStack` | `({ stack?, select?, compare? }, client?) => Readonly<Ref<T>>` |
| Hook | `useQueuedStack` | `({ stack?, select?, compare? }, client?) => Readonly<Ref<T>>` |
| Hook | `useStackHandles` | `(stack?, rootProps?) => StackHandles` |
| Component | `StackOutlet` | `{ stack?, rootProps? }` |
| Component | `StackSubscribe` | `{ stack?, selector }` — scoped slot `{ value: unknown }` |
| Hook | `useMutationFlow` | `<P, R, RootProps?>(call) => MutationFlow<R>` |
| Hook | `useLayerGroup` | `<P, R, RootProps?>(call, options?) => LayerGroup` |
| Factory | `createStackHook` | `<HostProps>({ stack?, client?, Host? }) => StackHook<HostProps>` |
| Type | `StackHandles` | `{ states: Readonly<Ref<LayerState[]>>, getCall }` |
| Type | `MutationRun<R>` | `{ orEnd(response) }` |
| Type | `MutationFlow<R>` | `{ pending: Readonly<Ref<boolean>>, run }` |
| Type | `ScopedOpen` | `open` with `stack` pre-bound |
| Type | `LayerGroup` | `{ open, dismissAll, states, Outlet, stackId }` |
| Type | `AppStack` | `{ open, dismissAll, states }` |
| Type | `AppLayerProps<P, R>` | `{ options, open, payload, onResolved? }` |
| Type | `StackHook<HostProps>` | `{ StackProvider, useAppStack, AppHost, AppLayer }` |
Client-less subscriptions use `useLayerClient()` internally. Call `useStack`, `useLayer`, `useStackHandles`, `useMutationFlow`, and `useLayerGroup` inside `setup()` or `effectScope()`; subscriptions clean up via `onScopeDispose`.
## Core re-exports (same import path)
Core exports are available from `@stainless-code/vue-layers`, including `LayerClient`, `LayerStack`, `layerOptions`, `layerKey`, `LayerState`, `LayerComponentProps`, `LayerCallContext`, `createLayerGroup`, `DataTag`, `ResponseOf`, and `ErrorOf`:
- **Key inference:** `layerOptions` / `layerKey` `DataTag` branding — `await client.open(...)` infers `R`.
- **Singleton + live updates:** `upsert: true` on `open`; `client.getStack(id).update(layer, patch)`.
- **Serial scope:** `new LayerClient({ defaultStackOptions: { confirm: { scope: { strategy: "serial" } } } })`.
- **Validation:** `validate` on `layerOptions` or `open`; narrow `PayloadValidationError` via `isPayloadValidationError`.
- **Blockers:** `call.addBlocker` / `stack.addBlocker`; `dismissing` flag; `dismissAll` modes.
See the [`layers`](https://github.com/stainless-code/layers/blob/main/packages/core/skills/layers/SKILL.md) skill for full engine coverage (transitions, blockers, validation, serial scope, multi-stack).