diff --git a/.agents/skills/docs-voice/SKILL.md b/.agents/skills/docs-voice/SKILL.md index 36adf09..282505d 100644 --- a/.agents/skills/docs-voice/SKILL.md +++ b/.agents/skills/docs-voice/SKILL.md @@ -34,7 +34,7 @@ was rejected) and the "when **not** to use it" anti-sell — keep both. `meta.ts` icons and tab icons stay. Nested brand groups (e.g. Svelte): icon on the group only — don't repeat the same SVG on indented children. - **Adapter list order:** core/vanilla → react → preact → solid → angular → - vue → svelte (runes → store). Every multi-adapter listing. + vue → lit → svelte (runes → store). Every multi-adapter listing. ## Don't diff --git a/.agents/skills/harden-pr/LEDGER.md b/.agents/skills/harden-pr/LEDGER.md index 894122c..72fab88 100644 --- a/.agents/skills/harden-pr/LEDGER.md +++ b/.agents/skills/harden-pr/LEDGER.md @@ -14,6 +14,9 @@ By-design or false-positive findings — do not re-raise. - **[correctness]** `packages/core/src/layerStack.ts` — snapshot cached + rebuilt inside `notifyManager.batch`: by-design — `useSyncExternalStore` needs a referentially-stable snapshot between mutations or it infinite-loops. --> +- **[public-api]** `packages/lit/src/index.ts` `layerClientContext` export — by-design; rationale on the const JSDoc. +- **[public-api]** `packages/lit/src/index.ts` `StackController` `queued`/`deferClient`/`bindClient` — by-design; rationale on the class/ctor JSDoc (factories are the supported path). + ## Deferred Capped or out-of-scope-for-now — reconcile re-vets; remove lines when fixed. diff --git a/.agents/skills/improve-codebase-architecture/LANGUAGE.md b/.agents/skills/improve-codebase-architecture/LANGUAGE.md index 0eff3e2..3a7354c 100644 --- a/.agents/skills/improve-codebase-architecture/LANGUAGE.md +++ b/.agents/skills/improve-codebase-architecture/LANGUAGE.md @@ -31,7 +31,7 @@ _Examples in this repo_: the package seam in `docs/architecture.md` — **core v **Adapter** A concrete thing that satisfies an interface at a seam. Describes _role_ (what slot it fills), not substance (what's inside). -_Examples in this repo_: each framework binding (`react`, `svelte`, `vue`, `solid`, `preact`, `angular`) is an adapter at the core/adapter seam. Six adapters across the seam = a real seam (the engine is genuinely framework-agnostic); a single adapter would be hypothetical. +_Examples in this repo_: each framework binding (`react`, `preact`, `solid`, `angular`, `vue`, `lit`, `svelte`) is an adapter at the core/adapter seam. Seven adapters across the seam = a real seam (the engine is genuinely framework-agnostic); a single adapter would be hypothetical. **Leverage** What callers get from depth. More capability per unit of interface they have to learn. One implementation pays back across N call sites and M tests. @@ -51,7 +51,7 @@ _Example_: the `notifyManager.batch` wrapping lives in one place in `packages/co - **The interface is the test surface.** Callers and tests cross the same seam. If you want to test _past_ the interface, the module is probably the wrong shape. Example: the zero-dep core invariant (`packages/core` has no dependencies and imports no framework peer) belongs at the package seam, enforced by package manifests plus dependency/import tooling rather than tests that mock internals. -- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a seam unless something actually varies across it. Example: a core/adapter seam with only a React adapter is hypothetical; add Svelte, Vue, Solid, Preact, and Angular and it's a real seam (each framework's reactivity is genuinely different). The seam is justified because the engine is the same across all six — don't introduce a per-adapter core fork before a second adapter genuinely needs it. +- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a seam unless something actually varies across it. Example: a core/adapter seam with only a React adapter is hypothetical; add Preact, Solid, Angular, Vue, Lit, and Svelte and it's a real seam (each framework's reactivity is genuinely different). The seam is justified because the engine is the same across all seven — don't introduce a per-adapter core fork before a second adapter genuinely needs it. ## Relationships diff --git a/.changeset/lit-adapter.md b/.changeset/lit-adapter.md new file mode 100644 index 0000000..3c33f43 --- /dev/null +++ b/.changeset/lit-adapter.md @@ -0,0 +1,5 @@ +--- +"@stainless-code/lit-layers": minor +--- + +Add `@stainless-code/lit-layers` — full-matrix Lit adapter (Reactive Controllers, `@lit/context`, light-DOM stack CEs via `defineStackElements()`). diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index a1d6a84..cef4f9a 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -3,7 +3,7 @@ `@stainless-code/layers` is a small, freshly extracted library. Before large PRs, please open an issue so we can align on: - **Public surface** — anything exported from a package entry point (`src/index.ts` / React's `src/index.tsx`, plus Svelte's `src/store.ts`) is the public API and must carry JSDoc that reads well in hovers and published typings. See [`docs/architecture.md`](../docs/architecture.md) for the core + adapter model. -- **Runtimes** — **Node** `^20.19.0 || >=22.12.0` and **Bun** `>=1.0.0` (`package.json` **engines**). The core is zero-dep by design; each adapter package declares its required peer (`react`, `preact`, `svelte`, `vue`, `solid-js`, `@angular/core`). +- **Runtimes** — **Node** `^20.19.0 || >=22.12.0` and **Bun** `>=1.0.0` (`package.json` **engines**). The core is zero-dep by design; each adapter package declares its required peer (`react`, `preact`, `solid-js`, `@angular/core`, `vue`, `lit` + `@lit/context`, `svelte`). ## Dev workflow diff --git a/.github/ISSUE_TEMPLATE/1_bug.yml b/.github/ISSUE_TEMPLATE/1_bug.yml index ec581ca..0c2e144 100644 --- a/.github/ISSUE_TEMPLATE/1_bug.yml +++ b/.github/ISSUE_TEMPLATE/1_bug.yml @@ -49,7 +49,7 @@ body: - Node.js - Bun - Browser (React) - - Browser (Svelte / Vue / Solid / Preact / Angular) + - Browser (Svelte / Vue / Solid / Preact / Angular / Lit) - Both / not sure validations: required: true diff --git a/.github/ISSUE_TEMPLATE/2_feature_adapter.yml b/.github/ISSUE_TEMPLATE/2_feature_adapter.yml index f4f26f2..a171c6e 100644 --- a/.github/ISSUE_TEMPLATE/2_feature_adapter.yml +++ b/.github/ISSUE_TEMPLATE/2_feature_adapter.yml @@ -5,7 +5,7 @@ body: - type: markdown attributes: value: | - **Adapters** plug in via one seam — a binding that subscribes to `LayerStack.subscribe`/`getSnapshot` and renders the active stack in that library or framework's reactivity (`useSyncExternalStore`, Svelte runes, Vue refs, Solid accessors, Angular signals, Preact compat). See [`docs/architecture.md`](https://github.com/stainless-code/layers/blob/main/docs/architecture.md). Open an issue here first so we can agree on shape (entry point, optional peer, API) before a large PR. + **Adapters** plug in via one seam — a binding that subscribes to `LayerStack.subscribe`/`getSnapshot` and renders the active stack in that library or framework's reactivity (`useSyncExternalStore`, Svelte runes, Vue refs, Solid accessors, Angular signals, Preact compat, Lit Reactive Controllers). See [`docs/architecture.md`](https://github.com/stainless-code/layers/blob/main/docs/architecture.md). Open an issue here first so we can agree on shape (entry point, optional peer, API) before a large PR. - type: textarea id: problem diff --git a/.size-limit.json b/.size-limit.json index 8ddfa2e..445746e 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -45,6 +45,13 @@ "limit": "3.5 KB", "gzip": true }, + { + "name": "@stainless-code/lit-layers", + "path": "packages/lit/dist/index.mjs", + "ignore": ["@stainless-code/layers", "lit", "@lit/context"], + "limit": "4.5 KB", + "gzip": true + }, { "name": "@stainless-code/solid-layers", "path": "packages/solid/dist/index.mjs", diff --git a/README.md b/README.md index 293c73a..8365605 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Modals are just async functions you forgot to `await`. -Headless layer/stack manager — modals, dialogs, drawers, popovers, toasts are **layers in named stacks**. Open any layer from anywhere and `await` a typed result. A zero-dependency core plus React, Preact, Solid, Angular, Vue, and Svelte adapters. State coordination, not UI ownership: Layers owns ordering, keys, transitions, blockers, and the `await client.open(...)` contract; you own rendering, focus, portals, and a11y. +Headless layer/stack manager — modals, dialogs, drawers, popovers, toasts are **layers in named stacks**. Open any layer from anywhere and `await` a typed result. A zero-dependency core plus React, Preact, Solid, Angular, Vue, Lit, and Svelte adapters. State coordination, not UI ownership: Layers owns ordering, keys, transitions, blockers, and the `await client.open(...)` contract; you own rendering, focus, portals, and a11y. > Experimental — the API may change between minor releases. Pin your version. ([Stability & versioning](https://stainless-code.com/layers/concepts/stability)) @@ -15,15 +15,16 @@ Full docs: [stainless-code.com/layers](https://stainless-code.com/layers). ## Packages -| Package | Peer | Install | -| ---------------------------------------------------- | --------------- | ---------------------------------------- | -| [`@stainless-code/layers`](packages/core) | — | `bun add @stainless-code/layers` | -| [`@stainless-code/react-layers`](packages/react) | `react` | `bun add @stainless-code/react-layers` | -| [`@stainless-code/preact-layers`](packages/preact) | `preact` | `bun add @stainless-code/preact-layers` | -| [`@stainless-code/solid-layers`](packages/solid) | `solid-js` | `bun add @stainless-code/solid-layers` | -| [`@stainless-code/angular-layers`](packages/angular) | `@angular/core` | `bun add @stainless-code/angular-layers` | -| [`@stainless-code/vue-layers`](packages/vue) | `vue` | `bun add @stainless-code/vue-layers` | -| [`@stainless-code/svelte-layers`](packages/svelte) | `svelte` | `bun add @stainless-code/svelte-layers` | +| Package | Peer | Install | +| ---------------------------------------------------- | --------------------- | ---------------------------------------- | +| [`@stainless-code/layers`](packages/core) | — | `bun add @stainless-code/layers` | +| [`@stainless-code/react-layers`](packages/react) | `react` | `bun add @stainless-code/react-layers` | +| [`@stainless-code/preact-layers`](packages/preact) | `preact` | `bun add @stainless-code/preact-layers` | +| [`@stainless-code/solid-layers`](packages/solid) | `solid-js` | `bun add @stainless-code/solid-layers` | +| [`@stainless-code/angular-layers`](packages/angular) | `@angular/core` | `bun add @stainless-code/angular-layers` | +| [`@stainless-code/vue-layers`](packages/vue) | `vue` | `bun add @stainless-code/vue-layers` | +| [`@stainless-code/lit-layers`](packages/lit) | `lit`, `@lit/context` | `bun add @stainless-code/lit-layers` | +| [`@stainless-code/svelte-layers`](packages/svelte) | `svelte` | `bun add @stainless-code/svelte-layers` | Install only the adapter for your framework — it pulls the core in transitively and re-exports it, so adapter APIs (`StackProvider`, `useStack`, …) and core APIs (`LayerClient`, `layerOptions`, …) both come from the one package. Each adapter lists its framework as a required peer. Svelte ships two entries: `@stainless-code/svelte-layers` (runes, 5.7+) and `@stainless-code/svelte-layers/store` (stores, 3+). diff --git a/apps/docs/blume.config.ts b/apps/docs/blume.config.ts index 10d6bcf..e4b66b4 100644 --- a/apps/docs/blume.config.ts +++ b/apps/docs/blume.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from "blume"; export default defineConfig({ title: "Layers", description: - "Headless modal/dialog/drawer/popover/toast manager — open any layer from anywhere. Zero-dep core + React, Preact, Solid, Angular, Vue, and Svelte adapters.", + "Headless modal/dialog/drawer/popover/toast manager — open any layer from anywhere. Zero-dep core + React, Preact, Solid, Angular, Vue, Lit, and Svelte adapters.", logo: { image: "/logo.svg", text: "Layers" }, diff --git a/apps/docs/components/blume/Footer.astro b/apps/docs/components/blume/Footer.astro index 3f7fb67..db6bb97 100644 --- a/apps/docs/components/blume/Footer.astro +++ b/apps/docs/components/blume/Footer.astro @@ -47,6 +47,7 @@ const groups: { label: string; links: { href: string; label: string; external?: { href: contentHref("/adapters/solid"), label: "Solid" }, { href: contentHref("/adapters/angular"), label: "Angular" }, { href: contentHref("/adapters/vue"), label: "Vue" }, + { href: contentHref("/adapters/lit"), label: "Lit" }, { href: contentHref("/adapters/svelte/runes"), label: "Svelte" }, ], }, @@ -153,7 +154,8 @@ const groups: { label: string; links: { href: string; label: string; external?: class="inline-block size-1.5 rounded-full bg-accent" aria-hidden="true" /> - Experimental — the API may change between minor releases. + Experimental — the API may change between minor releases. Pin your + version.

diff --git a/apps/docs/content/adapters/index.mdx b/apps/docs/content/adapters/index.mdx index cf89f11..1d59864 100644 --- a/apps/docs/content/adapters/index.mdx +++ b/apps/docs/content/adapters/index.mdx @@ -12,19 +12,19 @@ Pick a framework binding — same engine, native reactivity. The [core](/adapter ## Adapter ergonomics -Same primitives on every adapter: client context, `useStack`, **drive** handles, **observe** hooks. Engine parity; wrappers (`StackOutlet`, `createStackHook`, …) are sugar and diverge by design. React/Preact/Solid/Angular/Vue re-export the full core; Svelte re-exports selectively. Angular and Svelte ship compiler-free — imperative / primitive render, no shipped host components. +Same primitives on every adapter: client context, `useStack`, **drive** handles, **observe** hooks. Engine parity; wrappers (`StackOutlet`, `createStackHook`, …) are sugar and diverge by design. React/Preact/Solid/Angular/Vue/Lit re-export the full core; Svelte re-exports selectively. Angular and Svelte ship compiler-free — imperative / primitive render, no shipped host components. Lit: `` is shadow + ``; outlet / subscribe / `app-host` are light DOM. -| Capability | React | Preact | Solid | Angular | Vue | Svelte (runes) | Svelte (store) | -| ------------------------------------------------------ | :---: | :----: | :---: | :-----: | :-: | :------------: | :------------: | -| Primitives + core re-export | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Drive: `useLayer` / `injectLayer` / `createLayer` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Observe: `useLayerState` / `injectLayerState` / `createLayerState` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| `StackOutlet` (built-in host) | ✅ | ✅ | ✅ | ✅¹ | ✅ | —⁴ | —⁴ | -| `StackSubscribe` (render-prop / scoped slot) | ✅ | ✅ | ✅ | —² | ✅ | —⁴ | —⁴ | -| `useStackHandles` (headless `{ states, getCall }`) | ✅ | ✅ | ✅ | ✅ | ✅ | —⁵ | —⁵ | -| `useMutationFlow` (async-action helper) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| `useLayerGroup` (nested-stack hook) | ✅ | ✅ | ✅ | ✅³ | ✅ | ✅⁶ | ✅⁶ | -| `createStackHook` (`useAppStack`/`AppHost`/`AppLayer`) | ✅ | ✅ | ✅ | ✅³ | ✅ | —⁴ | —⁴ | +| Capability | React | Preact | Solid | Angular | Vue | Lit | Svelte (runes) | Svelte (store) | +| ------------------------------------------------------ | :---: | :----: | :---: | :-----: | :-: | :-: | :------------: | :------------: | +| Primitives + core re-export | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Drive: `useLayer` / `injectLayer` / `createLayer` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Observe: `useLayerState` / `injectLayerState` / `createLayerState` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `StackOutlet` (built-in host) | ✅ | ✅ | ✅ | ✅¹ | ✅ | ✅ | —⁴ | —⁴ | +| `StackSubscribe` (render-prop / scoped slot) | ✅ | ✅ | ✅ | —² | ✅ | ✅ | —⁴ | —⁴ | +| `useStackHandles` (headless `{ states, getCall }`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | —⁵ | —⁵ | +| `useMutationFlow` (async-action helper) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `useLayerGroup` (nested-stack hook) | ✅ | ✅ | ✅ | ✅³ | ✅ | ✅ | ✅⁶ | ✅⁶ | +| `createStackHook` (`useAppStack`/`AppHost`/`AppLayer`) | ✅ | ✅ | ✅ | ✅³ | ✅ | ✅ | —⁴ | —⁴ | ¹ Angular ships `renderStack(vcr)` — a compiler-free imperative outlet (`ViewContainerRef.createComponent` + `setInput`, id-keyed so state changes update inputs without recreating components). ² Angular has no `StackSubscribe`; `useStack({ stack, select })` as a signal is the idiomatic equivalent. ³ Angular's `useLayerGroup`/`createStackHook` expose `renderInto(vcr)` instead of an `Outlet` component; `createStackHook` returns `{ provideClient, useAppStack, renderInto }` (no `AppHost`/`AppLayer`). ⁴ Svelte ships no `.svelte` components: render with `{#each useStack({ stack }).current as s}` + `callFor(s)` (runes) / `{#each $stack}` + `callFor(client, stackId, s)` (store) in your own markup. ⁵ Svelte's `useStack({ stack })` already returns the headless pair — `.current` (states) + `callFor`. ⁶ Svelte's `useLayerGroup` exposes the child `stack` (`SvelteStack` / `Readable`) instead of an `Outlet`. @@ -75,6 +75,13 @@ Name map: Angular `injectLayer` (`useLayer` alias); Svelte `createLayer` / `crea description="@stainless-code/vue-layers" arrow /> + =3.2.0), `@lit/context` (>=1.1.0) + +## Client + +Call `provideLayerClient(host, client?)` on a host (creates a `LayerClient` when omitted), or mount `` high in the tree. Descendants omit `client`, or read `useLayerClient(host).current`: + +```ts +import { LitElement, html } from "lit"; +import { customElement } from "lit/decorators.js"; +import { defineStackElements, provideLayerClient } from "@stainless-code/lit-layers"; + +defineStackElements(); + +@customElement("my-shell") +class MyShell extends LitElement { + constructor() { + super(); + provideLayerClient(this); + } + + render() { + return html``; + } +} +``` + +:::note +Create controllers as class fields (`useStack(this, …)`). They subscribe in `hostConnected` and tear down in `hostDisconnected`. Omit `client` to resolve from a `provideLayerClient()` / `` ancestor after connect; pass `client` for synchronous construction. +::: + +## Render a stack + +`` is a Lit-idiomatic shadow root + `` (context still reaches light children). `` and `` render in **light DOM** so overlays stack inline where mounted. + +`` (the `StackOutlet` CE from `defineStackElements()`) maps registered `component` entries — a `LitElement` constructor or `(props) => TemplateResult` (no tag strings in v1). Id-keyed `repeat` keeps instances stable. Headless: `useStackHandles(this, stack, rootProps)`. + +`` takes a `(value) => TemplateResult` callback. + +## Confirm dialog + +```ts +import { LitElement, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { + layerOptions, + type LayerCallContext, +} from "@stainless-code/lit-layers"; + +type ConfirmPayload = { title: string }; +type ConfirmResponse = boolean; + +@customElement("confirm-dialog") +class ConfirmDialog extends LitElement { + // Light DOM (Lit: return `this` from createRenderRoot). + createRenderRoot() { + return this; + } + + // Declared reactive — StackOutlet assigns these via LayerElementDirective. + @property({ attribute: false }) + declare call: LayerCallContext; + + @property({ attribute: false }) + declare payload: ConfirmPayload; + + render() { + return html`
+

${this.payload.title}

+ + +
`; + } +} + +export const confirm = layerOptions({ + stack: "confirm", + key: ["confirm", "remove"], + component: ConfirmDialog, +}); +``` + +```ts +import { LitElement, html } from "lit"; +import { customElement } from "lit/decorators.js"; +import { + defineStackElements, + provideLayerClient, + useLayer, +} from "@stainless-code/lit-layers"; +import { confirm } from "./confirm-dialog"; + +defineStackElements(); + +@customElement("remove-button") +class RemoveButton extends LitElement { + #confirm = useLayer(this, confirm); + + render() { + return html``; + } +} + +@customElement("app-shell") +class AppShell extends LitElement { + constructor() { + super(); + provideLayerClient(this); + } + + render() { + return html` + `; + } +} +``` + +## Primitives + +Options bag plus optional trailing `LayerClient`. **Drive** with `useLayer(this, options, client?)`; **observe** with `useLayerState(this, { key, … })`. + +| Export | Role | +| ------ | ---- | +| `useStack(host, { stack?, select?, compare? }, client?)` | `StackController` — `.current` of the selected stack slice | +| `useQueuedStack(host, { ... }, client?)` | `StackController` over the queued snapshot | +| `useLayer(host, options, client?)` | **Drive** — wired handle + reactive `state` / `queued` / `top` | +| `useLayerState(host, { key, stack?, select?, compare? }, client?)` | **Observe** — mounted same-key layers | +| `useLayerQueuedState(host, { key, ... }, client?)` | **Observe** — queued same-key layers | + +## Ergonomic APIs + +| Export | Role | +| ------ | ---- | +| `useStackHandles(host, stack?, rootProps?, client?)` | `{ states, getCall }` for headless rendering | +| `StackSubscribe` | `` (`renderer(value)` → `TemplateResult`) | +| `useMutationFlow(host, call)` | `pending: boolean`; `run(fn).orEnd(response)` | +| `useLayerGroup(host, call, options?, client?)` | Child stack with `outlet(): TemplateResult` + `stackId` | +| `createStackHook(config?)` | `StackProvider`, `useAppStack(host)`, `AppHost` CE, `AppLayer` controller | + +`defineStackElements()` idempotently registers `stack-provider`, `stack-outlet`, `stack-subscribe`, and `app-host`; not auto-invoked on import. + +## Learn more + +- [Lifecycle](/concepts/lifecycle) · [Identity & types](/concepts/identity-and-types) +- [Adapter parity](/adapters) diff --git a/apps/docs/content/adapters/meta.ts b/apps/docs/content/adapters/meta.ts index 97d3f36..7183c12 100644 --- a/apps/docs/content/adapters/meta.ts +++ b/apps/docs/content/adapters/meta.ts @@ -3,5 +3,5 @@ import { defineMeta } from "blume"; export default defineMeta({ title: "Adapters", icon: "plug", - pages: ["core", "react", "preact", "solid", "angular", "vue", "svelte"], + pages: ["core", "react", "preact", "solid", "angular", "vue", "lit", "svelte"], }); diff --git a/apps/docs/content/concepts/comparison.mdx b/apps/docs/content/concepts/comparison.mdx index 4c32eb9..908ba8f 100644 --- a/apps/docs/content/concepts/comparison.mdx +++ b/apps/docs/content/concepts/comparison.mdx @@ -27,7 +27,7 @@ Two views: this matrix contrasts **one exemplar per category**; the [full spectr | Capability | Layers | react-call | nice-modal | overlay-kit | @mantine/modals | overlastic | | --- | :---: | :---: | :---: | :---: | :---: | :---: | | **Category** | Orchestration engine | Orchestration engine | Registry / reducer | Registry / reducer | Batteries-included UI | Orchestration engine | -| Multi-framework | ✅ 6 | ❌ R | ❌ R | ❌ R | ❌ R | ✅ 4 | +| Multi-framework | ✅ 7 | ❌ R | ❌ R | ❌ R | ❌ R | ✅ 4 | | Zero-dep core | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | | Typed await | ✅ DataTag | ✅ | 🟡 | 🟡 | ❌ | 🟡 | | Named stacks | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | @@ -48,13 +48,13 @@ Focus containment, ARIA/dialog semantics, portals/top-layer, backdrop/Escape, sc ## Across the whole landscape -The matrix above contrasts one exemplar per category. The tables below are the full spectrum — every audited library a real evaluator weighs, across all six frameworks, scored on the engine axes Layers targets. One table per category (orchestration engines split into multi-framework and single-framework so no table is wider than the eye can scan). **Layers is the baseline column in every table — the only column green on every row.** `R/V/S/Solid/Ang` = React/Vue/Svelte/Solid/Angular; `Nfw` = N frameworks. +The matrix above contrasts one exemplar per category. The tables below are the full spectrum — every audited library a real evaluator weighs, across all six frameworks, scored on the engine axes Layers targets. One table per category (orchestration engines split into multi-framework and single-framework so no table is wider than the eye can scan). **Layers is the baseline column in every table — the only column green on every row.** `R/V/S/Solid/Ang/Lit` = React/Vue/Svelte/Solid/Angular/Lit; `Nfw` = N frameworks. **Multi-framework orchestration engines** | Capability | Layers | overlastic | ui-call | dialog-fn | @gwn-sheet-stack | | --- | :---: | :---: | :---: | :---: | :---: | -| Multi-framework | ✅ 6 | ✅ 4 | ✅ 5 | 🟡 3 | 🟡 2 | +| Multi-framework | ✅ 7 | ✅ 4 | ✅ 5 | 🟡 3 | 🟡 2 | | Zero-dep core | ✅ | ✅ core | ✅ | ✅ | ✅ | | Typed await | ✅ DataTag | 🟡 generic | 🟡 generic | 🟡 dismiss=undefined | ✅ `push` | | Named stacks | ✅ | ❌ | ❌ | ❌ | ❌ | @@ -69,7 +69,7 @@ The matrix above contrasts one exemplar per category. The tables below are the f | Capability | Layers | react-call | jenesius-vue-modal | ngx-call | @angular-libs/dialog | @svelte-put/async-stack | | --- | :---: | :---: | :---: | :---: | :---: | :---: | -| Multi-framework | ✅ 6 | ❌ React | ❌ Vue | ❌ Angular | ❌ Angular | ❌ Svelte | +| Multi-framework | ✅ 7 | ❌ React | ❌ Vue | ❌ Angular | ❌ Angular | ❌ Svelte | | Zero-dep core | ✅ | ✅ | — | ✅ | — | ✅ | | Typed await | ✅ DataTag | ✅ generic | 🟡 any | ✅ generic | ❌ | ✅ resolution | | Named stacks | ✅ | ❌ | ✅ NamespaceStore | ❌ | ❌ global list | ❌ single | @@ -84,7 +84,7 @@ The matrix above contrasts one exemplar per category. The tables below are the f | Capability | Layers | nice-modal-react | overlay-kit | pushmodal | @ngneat/dialog | | --- | :---: | :---: | :---: | :---: | :---: | -| Multi-framework | ✅ 6 | ❌ React | ❌ React | ❌ React | ❌ Angular | +| Multi-framework | ✅ 7 | ❌ React | ❌ React | ❌ React | ❌ Angular | | Zero-dep core | ✅ | ✅ | ✅ | — | — | | Typed await | ✅ DataTag | 🟡 unknown | 🟡 generic | ❌ void | ✅ afterClosed$ | | Named stacks | ✅ | ❌ id-map | ❌ | ✅ LIFO | ❌ | @@ -99,7 +99,7 @@ The matrix above contrasts one exemplar per category. The tables below are the f | Capability | Layers | @mantine/modals | vue-final-modal | svelte-promise-modals | kolirt/vue-modal | | --- | :---: | :---: | :---: | :---: | :---: | -| Multi-framework | ✅ 6 | ❌ React | ❌ Vue | ❌ Svelte | ❌ Vue | +| Multi-framework | ✅ 7 | ❌ React | ❌ Vue | ❌ Svelte | ❌ Vue | | Zero-dep core | ✅ | ❌ @mantine/core | ❌ @vueuse+focus-trap | ❌ focus-trap+deepmerge | — | | Typed await | ✅ DataTag | ❌ string id | ❌ lifecycle strings | ✅ closeModal prop | ✅ awaitable | | Named stacks | ✅ | ❌ | ❌ | ❌ | ✅ grouped | @@ -114,7 +114,7 @@ The matrix above contrasts one exemplar per category. The tables below are the f | Capability | Layers | @corvu/dialog | Ark / Zag | | --- | :---: | :---: | :---: | -| Multi-framework | ✅ 6 | ❌ Solid | ✅ R/V/S/Solid | +| Multi-framework | ✅ 7 | ❌ Solid | ✅ R/V/S/Solid | | Zero-dep core | ✅ | ✅ | ✅ | | Typed await | ✅ DataTag | ❌ no await | ❌ no await | | Named stacks | ✅ | ❌ | ❌ | diff --git a/apps/docs/content/concepts/glossary.mdx b/apps/docs/content/concepts/glossary.mdx index 97436c5..5ab71f1 100644 --- a/apps/docs/content/concepts/glossary.mdx +++ b/apps/docs/content/concepts/glossary.mdx @@ -48,16 +48,16 @@ From `LayerClient` down to `keySignature` — every term in code, docs, and issu - **createLayer** — core factory: `options` + `client` → headless `LayerHandle` / `ValidatedLayerHandle` (layer ops + `stack`/`client`/`options`/`current` escapes; no reactive field). - **LayerHandle** — return of plain `createLayer`. `open`/`upsert` take payload only; `dismiss` takes `{ id?, force? }`; `update` / `cancelQueued` take `{ id? }` (see **cancelQueued**). - **ValidatedLayerHandle** — when `options.validate` is set: `open`/`upsert` = schema **input**; `current`/`update` = parsed **output**. Wired adapters also expose reactive `state`/`queued`/`top` as output. - - **useLayer** — adapter wired handle (`createLayer` + reactive `state`/`queued`/`top`). Svelte: `createLayer`; Angular: `injectLayer`. + - **useLayer** — adapter wired handle (`createLayer` + reactive `state`/`queued`/`top`). Lit: `useLayer(host, options, client?)`. Svelte: `createLayer`; Angular: `injectLayer`. - **useLayerState** / **useLayerQueuedState** / **useQueuedStack** — observe-only (mounted per-key / queued per-key / queued whole-stack); return `LayerState[]` (or framework wrappers). Renamed/added vs the old singular `useLayer(key, …)`. - **current** — live-checked bound instance on a handle (`Layer | null`); correlation + explicit `{ id }` — not a hidden control default. - **Drive vs observe** — **drive** = wired handle (`useLayer` / `injectLayer` / `createLayer`) for `open`/`upsert`/`dismiss`/`update`/`cancelQueued`; **observe** = `useLayerState` / `useLayerQueuedState` / `useQueuedStack` / `useStack` for reactive snapshots without control. Both paths are first-class; `client.open({ …layerOptions, payload })` remains valid. - - **Observer / selector** — adapters subscribe to `LayerStack.getSnapshot()` and project via a selector (React/Preact `useSyncExternalStore`, Solid `from`, Angular `signal`, Vue `shallowRef`, Svelte runes). + - **Observer / selector** — adapters subscribe to `LayerStack.getSnapshot()` and project via a selector (React/Preact `useSyncExternalStore`, Solid `from`, Angular `signal`, Vue `shallowRef`, Lit reactive controllers, Svelte runes). - **Options helper** (`layerOptions`) — identity function carrying `` generics so `LayerClient.open` infers the response type end-to-end. - - **Outlet** — renders the active stack. React, Preact, and Solid ship `StackOutlet`; Angular exposes `renderStack(vcr)`; Vue ships `StackOutlet`; Svelte renders from `useStack().current` + `callFor`. See [adapter ergonomics](/adapters#adapter-ergonomics). - - **Stack handles** (`useStackHandles`) — headless `{ states, getCall }` for custom hosts (React/Preact/Solid/Angular/Vue; Svelte's `useStack()` already returns `.current` + `callFor`). `StackOutlet` is the registered-component convenience built on it where shipped. + - **Outlet** — renders the active stack. React, Preact, Solid, and Vue ship `StackOutlet`; Angular exposes `renderStack(vcr)`; Lit ships ``; Svelte renders from `useStack().current` + `callFor`. See [adapter ergonomics](/adapters#adapter-ergonomics). + - **Stack handles** (`useStackHandles`) — headless `{ states, getCall }` for custom hosts (React/Preact/Solid/Angular/Vue/Lit; Svelte's `useStack()` already returns `.current` + `callFor`). `StackOutlet` is the registered-component convenience built on it where shipped. - **Validator** (`validate`) — a Standard Schema or sync `(input) => output` fn that parses/validates a layer's `payload` at `open`; the layer stores the parsed output. Failure rejects `open` with a `PayloadValidationError`. diff --git a/apps/docs/content/concepts/overview.mdx b/apps/docs/content/concepts/overview.mdx index 542cc25..7e89e5c 100644 --- a/apps/docs/content/concepts/overview.mdx +++ b/apps/docs/content/concepts/overview.mdx @@ -1,6 +1,6 @@ --- title: Overview -description: Overlay state is cross-cutting; a zero-dep headless engine plus six framework adapters split it from UI rendering. +description: Overlay state is cross-cutting; a zero-dep headless engine plus seven framework adapters split it from UI rendering. search: tags: ["concept"] --- diff --git a/apps/docs/content/concepts/stability.mdx b/apps/docs/content/concepts/stability.mdx index 4959991..909f10a 100644 --- a/apps/docs/content/concepts/stability.mdx +++ b/apps/docs/content/concepts/stability.mdx @@ -11,7 +11,7 @@ While the project is pre-1.0, treat semver as a signal, not a guarantee of zero ## What we ship -- A **zero-dep core** (`@stainless-code/layers`) and **six framework adapters** with engine + primitive parity — React, Preact, Solid, Angular, Vue, and Svelte. +- A **zero-dep core** (`@stainless-code/layers`) and **seven framework adapters** with engine + primitive parity — React, Preact, Solid, Angular, Vue, Lit, and Svelte. - When the engine gains capability, every adapter moves together. - Angular and Svelte diverge by design at the rendering surface (compiler-free builds → imperative / primitive APIs, not shipped outlet components). diff --git a/apps/docs/content/concepts/when-to-use.mdx b/apps/docs/content/concepts/when-to-use.mdx index f57d8a3..1e406e9 100644 --- a/apps/docs/content/concepts/when-to-use.mdx +++ b/apps/docs/content/concepts/when-to-use.mdx @@ -19,7 +19,7 @@ Awaiting a result is **optional** — `void client.open({ ...toast, payload })` | Enter/exit animated overlays | `transition` + `enteringDelay`/`exitingDelay` `settle()` | Ideal | | Guard dismissal ("discard unsaved changes?") | blockers (`addBlocker`, `dismissing`, `dismissAll`) | Ideal | | Validate an untrusted payload at open | `validate` (Standard Schema or a function) | Nice-to-have | -| One overlay system across React/Preact/Solid/Angular/Vue/Svelte | UI-agnostic core + adapters | Ideal | +| One overlay system across React/Preact/Solid/Angular/Vue/Lit/Svelte | UI-agnostic core + adapters | Ideal | | A single, always-local overlay opened from one component — no return, stacking, queue, animation, or guard, and no wish for a global registry | — | Skip → plain controlled component | | Static/inline content with no overlay semantics; full-page navigation | — | Skip → plain markup / a router | diff --git a/apps/docs/content/examples/confirm-dialog.mdx b/apps/docs/content/examples/confirm-dialog.mdx index c18d451..b832cef 100644 --- a/apps/docs/content/examples/confirm-dialog.mdx +++ b/apps/docs/content/examples/confirm-dialog.mdx @@ -14,6 +14,7 @@ import confirmSolidSrc from "../../recipes/confirm-dialog/solid.tsx?raw"; import confirmSvelteRunesSrc from "../../recipes/confirm-dialog/svelte-runes.svelte?raw"; import confirmSvelteStoreSrc from "../../recipes/confirm-dialog/svelte-store.svelte?raw"; import confirmAngularSrc from "../../recipes/confirm-dialog/angular.ts?raw"; +import confirmLitSrc from "../../recipes/confirm-dialog/lit.ts?raw"; A delete button in a sidebar needs a yes/no. `useLayer(confirm)` + `await confirm.open({ title })` returns the typed boolean. @@ -41,6 +42,7 @@ One complete recipe per framework adapter (live demo above is React). + diff --git a/apps/docs/content/guides/headless-rendering.mdx b/apps/docs/content/guides/headless-rendering.mdx index 3861ce6..8325a0f 100644 --- a/apps/docs/content/guides/headless-rendering.mdx +++ b/apps/docs/content/guides/headless-rendering.mdx @@ -7,7 +7,7 @@ search: `StackOutlet` maps a stack snapshot to registered components — the convenience path. When you need custom portals, per-layer switches, or non-component renderers, use the headless primitives. -## useStackHandles +## Map stacks with useStackHandles Returns `{ states, getCall }` — everything `StackOutlet` uses internally: @@ -136,6 +136,32 @@ const { states, getCall } = useStackHandles("modal"); ``` + + + +```ts lineNumbers +import { LitElement, html } from "lit"; +import { useStackHandles } from "@stainless-code/lit-layers"; + +class CustomHost extends LitElement { + #handles = useStackHandles(this, "modal"); + + createRenderRoot() { + return this; + } + + render() { + return html`${this.#handles.states.current.map( + (s) => html``, + )}`; + } +} +``` + @@ -179,7 +205,7 @@ Store entry subscribes via `$stack` + standalone `callFor(client, stackId, state `StackOutlet` deliberately has **no** `render` prop — headless is the escape hatch. -## StackSubscribe +## Subscribe with StackSubscribe Isolate a selector in JSX/template without pulling the full snapshot into your component: @@ -262,6 +288,30 @@ import { StackSubscribe } from "@stainless-code/vue-layers"; ``` + + + +```ts lineNumbers +import { LitElement, html } from "lit"; +import { defineStackElements } from "@stainless-code/lit-layers"; + +defineStackElements(); + +class OpenCount extends LitElement { + createRenderRoot() { + return this; + } + + render() { + return html` s.length} + .renderer=${(count) => html`${count} open`} + >`; + } +} +``` + @@ -302,8 +352,10 @@ For fully-typed selector output in Vue, prefer `useStack({ stack, select })` in | Adapter | Headless path | Built-in outlet | | -------------- | -------------------------------------------- | ------------------------- | | React / Preact | `useStackHandles`, `StackSubscribe` | `StackOutlet` | -| Solid / Vue | `useStackHandles`, `StackSubscribe` | `StackOutlet` | +| Solid | `useStackHandles`, `StackSubscribe` | `StackOutlet` | | Angular | `useStackHandles`, `renderStack(vcr)` | `renderStack` | +| Vue | `useStackHandles`, `StackSubscribe` | `StackOutlet` | +| Lit | `useStackHandles`, `` | `` | | Svelte (runes) | `useStack().current` + `callFor` | — (render in your markup) | | Svelte (store) | `$stack` + `callFor(client, stackId, state)` | — | diff --git a/apps/docs/content/guides/install.mdx b/apps/docs/content/guides/install.mdx index 5308833..be542f9 100644 --- a/apps/docs/content/guides/install.mdx +++ b/apps/docs/content/guides/install.mdx @@ -55,6 +55,14 @@ Peer: `vue` (≥3.3) npm i @stainless-code/vue-layers ``` +## Lit + +Peers: `lit` (≥3.2), `@lit/context` (≥1.1) + +```package-install +npm i @stainless-code/lit-layers +``` + ## Svelte Peer: `svelte` (≥3). Two package entries in one npm package: diff --git a/apps/docs/content/guides/nested-overlays.mdx b/apps/docs/content/guides/nested-overlays.mdx index a3628ca..6033722 100644 --- a/apps/docs/content/guides/nested-overlays.mdx +++ b/apps/docs/content/guides/nested-overlays.mdx @@ -7,9 +7,9 @@ search: A drawer that opens a sub-dialog needs its own stack — but tied to the parent layer's lifetime. Layer groups create a child stack on the same `LayerClient`; when the parent dismisses, children drain automatically. -## Core primitive +## Create a child stack -`createLayerGroup(client, call, options?)` registers a child stack derived from the parent's `stackId` and instance `layerId`. Adapters wrap it as `useLayerGroup(call)`: +`createLayerGroup(client, call, options?)` registers a child stack derived from the parent's `stackId` and instance `layerId`. Adapters wrap it as `useLayerGroup(call)` (Lit: `useLayerGroup(host, call, …)`): ```ts lineNumbers import { createLayerGroup } from "@stainless-code/layers"; @@ -20,9 +20,9 @@ group.dismissAll(); group.dispose(); // usually automatic via adapter lifecycle ``` -## Inline child outlet +## Render the child stack inline -Render the child stack inside the parent layer's DOM. React, Preact, Solid, and Vue ship an `Outlet` component; Angular uses `renderInto`; Svelte exposes the child `stack` directly: +Render the child stack inside the parent layer's DOM. React, Preact, Solid, Angular, Vue, Lit, and Svelte each expose a child render path — `Outlet`, `renderInto`, `outlet()`, or the child `stack` (tabs below): @@ -123,6 +123,46 @@ const group = useLayerGroup(call); ``` + + + +```ts lineNumbers +import { LitElement, html, type PropertyValues } from "lit"; +import { property } from "lit/decorators.js"; +import { useLayerGroup } from "@stainless-code/lit-layers"; +import type { LayerComponentProps } from "@stainless-code/lit-layers"; + +class Drawer extends LitElement { + @property({ attribute: false }) + declare call: LayerComponentProps["call"]; + + #group?: ReturnType; + + createRenderRoot() { + return this; // light DOM — overlays stack where mounted + } + + willUpdate(changed: PropertyValues) { + // `call` is set by StackOutlet after construct — bind once it arrives. + if (changed.has("call") && this.call && !this.#group) { + this.#group = useLayerGroup(this, this.call); + } + } + + render() { + return html`
+ + ${this.#group?.outlet()} +
`; + } +} +``` +
@@ -172,14 +212,14 @@ Store entry renders via `$childStack` + standalone `callFor` — same `group.ope
-## What you get +## Inspect the group handle | Member | Role | | ------ | ---- | | `open` | Opens on the child stack (stack id pre-bound) | | `dismissAll` | Closes all child layers | | `states` / `stack` | Reactive snapshot of child layers | -| `Outlet` / `renderInto` / `stack` | Render surface (adapter-specific) | +| `Outlet` / `outlet()` / `renderInto` / `stack` | Render surface (adapter-specific) | | `stackId` | Derived id — `` `${parentStackId}~${parentLayerId}~${name}` `` | :::warning[Heads up] diff --git a/apps/docs/content/reference/adapter-hooks.mdx b/apps/docs/content/reference/adapter-hooks.mdx index a2c1d5c..776ab90 100644 --- a/apps/docs/content/reference/adapter-hooks.mdx +++ b/apps/docs/content/reference/adapter-hooks.mdx @@ -1,11 +1,11 @@ --- title: Adapter hooks -description: Hook and provider signatures across React, Preact, Solid, Angular, Vue, and Svelte adapters. +description: Hook and provider signatures across React, Preact, Solid, Angular, Vue, Lit, and Svelte adapters. search: tags: ["reference", "adapters"] --- -**Wire** a definition to imperative ops, or **observe** stack snapshots for rendering — same options bags on every adapter, framework-native wrappers only. React/Preact/Solid/Angular/Vue re-export the full core. Svelte re-exports selectively — core's headless `createLayer` is `createLayerHandle`. +**Wire** a definition to imperative ops, or **observe** stack snapshots for rendering — same options bags on every adapter, framework-native wrappers only. React/Preact/Solid/Angular/Vue/Lit re-export the full core. Svelte re-exports selectively — core's headless `createLayer` is `createLayerHandle`. :::note[Svelte entries] `@stainless-code/svelte-layers` (runes, 5.7+) and `@stainless-code/svelte-layers/store` (readable stores, 3+) share client helpers but differ in stack reactivity and rendering. @@ -15,7 +15,7 @@ search: | Role | Hooks | Default return | | ---- | ----- | -------------- | -| **Wire** — `createLayer` + reactive fields | `useLayer` (React / Preact / Solid / Vue), `injectLayer` (Angular), `createLayer` (Svelte) | `WiredLayerHandle` — ops + `state` / `queued` / `top` | +| **Wire** — `createLayer` + reactive fields | `useLayer` (React / Preact / Solid / Vue / Lit); `injectLayer` (Angular); `createLayer` (Svelte) | `WiredLayerHandle` — ops + `state` / `queued` / `top` | | **Observe mounted** — whole stack or per-key filter | `useStack`, `useLayerState` | `LayerState[]` (or selected `T`) | | **Observe queued** — serial-waiting layers | `useQueuedStack`, `useLayerQueuedState` | `LayerState[]` (or selected `T`) | @@ -35,9 +35,11 @@ const layers = useLayerState({ key: "confirm", stack: "default" }); // layers.length === 0 when closed ``` +Lit: pass the element host first (`useLayer(this, options)`, `useLayerState(this, { key })`). + ## Options bags -Shared across React, Preact, Solid, Angular, and Vue: +Shared across React, Preact, Solid, Angular, Vue, and Lit: ```ts interface UseStackOptions { @@ -54,7 +56,7 @@ interface UseLayerStateOptions { } ``` -Optional trailing `client?: LayerClient` on every hook; omit to use context (`useLayerClient` / `setLayerClient` / `provideLayerClient`). +Optional trailing `client?: LayerClient` on every hook; omit to use context (`useLayerClient` / `setLayerClient` / `provideLayerClient`). Lit hooks are `(host, optionsBag, client?)`; context via `provideLayerClient(host)` / ``. Svelte runes/store use the same option shapes; runes entries return `SvelteStack` (`.current` + `callFor`), store entries return `Readable` plus standalone `callFor(client, stackId, state)`. @@ -67,27 +69,28 @@ Svelte runes/store use the same option shapes; runes entries return `SvelteStack | Solid | `` | `useLayerClient(): LayerClient` | | Angular | `provideLayerClient(client?): FactoryProvider` + `LAYER_CLIENT` token | `useLayerClient(): LayerClient` (injection context) | | Vue | `provideLayerClient(client?): LayerClient` | `useLayerClient(): LayerClient` | +| Lit | `provideLayerClient(host, client?): LayerClient` / `` | `useLayerClient(host).current: LayerClient` | | Svelte (runes) | `setLayerClient(client?): LayerClient` | `useLayerClient(): LayerClient` | | Svelte (store) | `setLayerClient(client?): LayerClient` | `useLayerClient(): LayerClient` | ## Hook matrix -| Hook | React / Preact / Solid / Vue | Angular | Svelte (runes) | Svelte (store) | -| ---- | ---------------------------- | ------- | -------------- | -------------- | -| Wired handle | `useLayer(options, client?)` | `injectLayer` (`useLayer` alias) | `createLayer` | `createLayer` | -| Mounted stack | `useStack(opts?, client?)` | `useStack` / `injectStack` | `useStack` | `useStack` | -| Queued stack | `useQueuedStack(opts?, client?)` | `useQueuedStack` / `injectQueuedStack` | `createQueuedStack` | `createQueuedStack` | -| Mounted per-key | `useLayerState(opts, client?)` | `useLayerState` / `injectLayerState` | `createLayerState` | `createLayerState` | -| Queued per-key | `useLayerQueuedState(opts, client?)` | `useLayerQueuedState` / `injectLayerQueuedState` | `createLayerQueuedState` | `createLayerQueuedState` | -| Client | `useLayerClient()` | `useLayerClient()` | `useLayerClient()` | `useLayerClient()` | +| Hook | React / Preact / Solid | Angular | Vue | Lit | Svelte (runes) | Svelte (store) | +| ---- | ---------------------- | ------- | --- | --- | -------------- | -------------- | +| Wired handle | `useLayer(options, client?)` | `injectLayer` (`useLayer` alias) | `useLayer(options, client?)` | `useLayer(host, options, client?)` | `createLayer` | `createLayer` | +| Mounted stack | `useStack(opts?, client?)` | `useStack` / `injectStack` | `useStack(opts?, client?)` | `useStack(host, opts?, client?)` | `useStack` | `useStack` | +| Queued stack | `useQueuedStack(opts?, client?)` | `useQueuedStack` / `injectQueuedStack` | `useQueuedStack(opts?, client?)` | `useQueuedStack(host, opts?, client?)` | `createQueuedStack` | `createQueuedStack` | +| Mounted per-key | `useLayerState(opts, client?)` | `useLayerState` / `injectLayerState` | `useLayerState(opts, client?)` | `useLayerState(host, opts, client?)` | `createLayerState` | `createLayerState` | +| Queued per-key | `useLayerQueuedState(opts, client?)` | `useLayerQueuedState` / `injectLayerQueuedState` | `useLayerQueuedState(opts, client?)` | `useLayerQueuedState(host, opts, client?)` | `createLayerQueuedState` | `createLayerQueuedState` | +| Client | `useLayerClient()` | `useLayerClient()` | `useLayerClient()` | `useLayerClient(host)` → `.current` | `useLayerClient()` | `useLayerClient()` | ### Return shapes -| Hook kind | React / Preact | Solid | Angular | Vue | Svelte (runes) | Svelte (store) | -| --------- | -------------- | ----- | ------- | --- | -------------- | -------------- | -| Wired handle | `WiredLayerHandle` | same | same + `state`/`queued`/`top` as `Signal` | same + refs on fields | `WiredLayerHandle` + runes getters | `WiredLayerStoreHandle` (`Readable` fields) | -| `useStack` / `useQueuedStack` | `T` | `Accessor` | `Signal` | `Readonly>` | `SvelteStack` | `Readable` | -| `useLayerState` / `useLayerQueuedState` | `U` (default `LayerState[]`) | `Accessor` | `Signal` | `Readonly>` | `SvelteStack` | `Readable` | +| Hook kind | React / Preact | Solid | Angular | Vue | Lit | Svelte (runes) | Svelte (store) | +| --------- | -------------- | ----- | ------- | --- | --- | -------------- | -------------- | +| Wired handle | `WiredLayerHandle` | same | same + `state`/`queued`/`top` as `Signal` | same + refs on fields | same + `StackController` fields | `WiredLayerHandle` + runes getters | `WiredLayerStoreHandle` (`Readable` fields) | +| `useStack` / `useQueuedStack` | `T` | `Accessor` | `Signal` | `Readonly>` | `StackController` (`.current`) | `SvelteStack` | `Readable` | +| `useLayerState` / `useLayerQueuedState` | `U` (default `LayerState[]`) | `Accessor` | `Signal` | `Readonly>` | `StackController` (`.current`) | `SvelteStack` | `Readable` | @@ -95,13 +98,13 @@ Wired adapters extend `LayerHandle` with reactive `state`, `queued`, and `top` ( ### Ergonomic wrappers -| Wrapper | React / Preact / Solid / Vue | Angular | Svelte | -| ------- | ---------------------------- | ------- | ------ | -| Stack render | `StackOutlet` | `renderStack(vcr)` | `{#each …current}` + `callFor` | -| Headless render | `useStackHandles` | `useStackHandles` | — | -| Nested stack | `useLayerGroup` → `Outlet` | `useLayerGroup` → `renderInto(vcr)` | `useLayerGroup` → `stack` | -| Async actions | `useMutationFlow` | same | same | -| App factory | `createStackHook` | `createStackHook` → `renderInto` | not shipped | +| Wrapper | React / Preact / Solid | Angular | Vue | Lit | Svelte | +| ------- | ---------------------- | ------- | --- | --- | ------ | +| Stack render | `StackOutlet` | `renderStack(vcr)` | `StackOutlet` | `` (`defineStackElements()`) | `{#each …current}` + `callFor` | +| Headless render | `useStackHandles` | `useStackHandles` | `useStackHandles` | `useStackHandles(host, …)` | — | +| Nested stack | `useLayerGroup` → `Outlet` | `useLayerGroup` → `renderInto(vcr)` | `useLayerGroup` → `Outlet` | `useLayerGroup(host, call)` → `outlet()` | `useLayerGroup` → `stack` | +| Async actions | `useMutationFlow` | same | same | `useMutationFlow(host, call)` | same | +| App factory | `createStackHook` | `createStackHook` → `renderInto` | `createStackHook` | `createStackHook` → CEs | not shipped | ## Stack rendering divergences @@ -126,5 +129,5 @@ function renderStack( Store entry: `{#each $stack as state}` with `callFor(client, stackId, state)`. :::tip[Cross-adapter porting] -Wired/observe parity means anything one adapter can do is reachable in another. Angular and Svelte diverge at the **rendering surface** by design (compiler-free builds). +Wired/observe parity means anything one adapter can do is reachable in another. Angular and Svelte diverge at the **rendering surface** by design (compiler-free builds). Lit is host-first controllers + custom elements (`defineStackElements()`). ::: diff --git a/apps/docs/content/reference/core-api.mdx b/apps/docs/content/reference/core-api.mdx index 90fc50b..f0c88ed 100644 --- a/apps/docs/content/reference/core-api.mdx +++ b/apps/docs/content/reference/core-api.mdx @@ -5,7 +5,7 @@ search: tags: ["reference", "api"] --- -Symbols below are exported from `@stainless-code/layers` (`packages/core`). React/Preact/Solid/Angular/Vue re-export the full core. Svelte re-exports selectively — core's headless `createLayer` is `createLayerHandle`. +Symbols below are exported from `@stainless-code/layers` (`packages/core`). React/Preact/Solid/Angular/Vue/Lit re-export the full core. Svelte re-exports selectively — core's headless `createLayer` is `createLayerHandle`. :::note[Curated overview] This page is a hand-written tour of the core API — method-purpose tables and type tables pulled live from source via ``. For the authoritative, exhaustive reference (every export, including internals like `ControlledPromise`, `Subscribable`, and all type aliases), see the [generated API reference](/reference/api/core). diff --git a/apps/docs/content/reference/index.mdx b/apps/docs/content/reference/index.mdx index ba7b1c2..a7b67b8 100644 --- a/apps/docs/content/reference/index.mdx +++ b/apps/docs/content/reference/index.mdx @@ -10,7 +10,7 @@ The symbols and hook signatures you reach for most often — with minimal exampl | Page | Contents | | ---- | -------- | | [Core API](/reference/core-api) | `LayerClient`, `LayerStack`, `layerOptions`, typing helpers, errors, and utilities — a curated tour; see the [generated reference](/reference/api/core) for every export | -| [Adapter hooks](/reference/adapter-hooks) | Hook and provider matrix across React, Preact, Solid, Angular, Vue, and Svelte | +| [Adapter hooks](/reference/adapter-hooks) | Hook and provider matrix across React, Preact, Solid, Angular, Vue, Lit, and Svelte | | [Roadmap](/reference/roadmap) | Forward-looking work — not a mirror of shipped `src/` | | [Migration](/reference/migration) | Breaking-change policy and migration notes when they ship | | [API reference](/reference/api) | Exhaustive CI-generated TypeDoc reference — every export across `core` and the adapters, regenerated on every docs build | diff --git a/apps/docs/content/reference/migration.mdx b/apps/docs/content/reference/migration.mdx index 629e60c..6fbd2b0 100644 --- a/apps/docs/content/reference/migration.mdx +++ b/apps/docs/content/reference/migration.mdx @@ -73,8 +73,8 @@ On `LayerStack`: `cancelQueued(key, response, opts?: { id? })`. Omit `id` → fi ### Adapter entry names -| Role | React / Preact / Solid / Vue | Angular | Svelte | -| ---- | ---------------------------- | ------- | ------ | +| Role | React / Preact / Solid / Vue / Lit | Angular | Svelte | +| ---- | ---------------------------------- | ------- | ------ | | Wired | `useLayer` | `injectLayer` (`useLayer` alias) | `createLayer` | | Mounted per-key | `useLayerState` | `injectLayerState` | `createLayerState` | | Queued stack | `useQueuedStack` | `injectQueuedStack` | `createQueuedStack` | diff --git a/apps/docs/content/reference/roadmap.mdx b/apps/docs/content/reference/roadmap.mdx index 1123eb6..73c25a6 100644 --- a/apps/docs/content/reference/roadmap.mdx +++ b/apps/docs/content/reference/roadmap.mdx @@ -23,7 +23,6 @@ Shipped features live in [Overview](/concepts/overview) + [Glossary](/concepts/g ## Adapters - **Qwik** + **Alpine** + **Marko** + **Angular SSR** + **React Native** bindings if demand materializes. -- **Lit** (`@stainless-code/lit-layers`) — full adapter parity; Reactive Controllers + `@lit/context` + light-DOM stack outlet. ## Out of scope diff --git a/apps/docs/package.json b/apps/docs/package.json index 9f242b6..a1a6373 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -9,9 +9,10 @@ "preview": "blume preview", "typecheck": "bun run typecheck:recipes", "typecheck:angular": "tsc -p tsconfig.recipes-angular.json --noEmit", + "typecheck:lit": "tsc -p tsconfig.recipes-lit.json --noEmit", "typecheck:preact": "tsc -p tsconfig.recipes-preact.json --noEmit", "typecheck:react": "tsc -p tsconfig.recipes-react.json --noEmit", - "typecheck:recipes": "bun run --parallel typecheck:react typecheck:vue typecheck:svelte typecheck:solid typecheck:preact typecheck:angular", + "typecheck:recipes": "bun run --parallel typecheck:react typecheck:vue typecheck:svelte typecheck:solid typecheck:preact typecheck:angular typecheck:lit", "typecheck:solid": "tsc -p tsconfig.recipes-solid.json --noEmit", "typecheck:svelte": "svelte-check --tsconfig tsconfig.recipes-svelte.json", "typecheck:vue": "vue-tsc -p tsconfig.recipes-vue.json --noEmit", @@ -32,13 +33,16 @@ "@astrojs/svelte": "9.0.1", "@astrojs/vue": "7.0.1", "@base-ui/react": "1.6.0", + "@lit/context": "1.1.6", "@stainless-code/angular-layers": "workspace:*", + "@stainless-code/lit-layers": "workspace:*", "@stainless-code/preact-layers": "workspace:*", "@stainless-code/solid-layers": "workspace:*", "@stainless-code/svelte-layers": "workspace:*", "@stainless-code/vue-layers": "workspace:*", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", + "lit": "3.3.1", "preact": "10.29.4", "react-aria-components": "1.19.0", "solid-js": "1.9.14", diff --git a/apps/docs/pages/_home/FrameworkTabs.astro b/apps/docs/pages/_home/FrameworkTabs.astro index daf76cd..2d7f919 100644 --- a/apps/docs/pages/_home/FrameworkTabs.astro +++ b/apps/docs/pages/_home/FrameworkTabs.astro @@ -28,6 +28,7 @@ const adapters = [ { name: "Solid", icon: "/icons/solid.svg" }, { name: "Angular", icon: "/icons/angular.svg" }, { name: "Vue", icon: "/icons/vue.svg" }, + { name: "Lit", icon: "/icons/lit.svg" }, { name: "Svelte (runes)", icon: "/icons/svelte.svg" }, { name: "Svelte (store)", icon: "/icons/svelte.svg" }, ]; @@ -35,23 +36,23 @@ const adapters = [ const rows = [ { feature: "Primitives + core re-export", - values: [true, true, true, true, true, true, true], + values: [true, true, true, true, true, true, true, true], }, { feature: "StackOutlet (built-in host)", - values: [true, true, true, false, true, false, false], + values: [true, true, true, false, true, true, false, false], }, { feature: "useLayerGroup", - values: [true, true, true, true, true, true, true], + values: [true, true, true, true, true, true, true, true], }, { feature: "useMutationFlow", - values: [true, true, true, true, true, true, true], + values: [true, true, true, true, true, true, true, true], }, { feature: "createStackHook", - values: [true, true, true, true, true, false, false], + values: [true, true, true, true, true, true, false, false], }, ]; @@ -65,7 +66,7 @@ const featureClass =

- One core, six adapters. + One core, seven adapters.

Declare a layer once, then open and

- Every adapter exposes the same primitives and re-exports the full core. - Angular and Svelte diverge by design — imperative render instead of - shipped outlet components. + React, Preact, Solid, Angular, Vue, and Lit re-export the full core; + Svelte re-exports selectively. Angular and Svelte diverge by design — + imperative render instead of shipped outlet components.

- +
diff --git a/apps/docs/pages/_home/Hero.astro b/apps/docs/pages/_home/Hero.astro index d7a2842..0793cd1 100644 --- a/apps/docs/pages/_home/Hero.astro +++ b/apps/docs/pages/_home/Hero.astro @@ -20,7 +20,7 @@ const defaultFramework = "vanilla"; class="inline-flex items-center rounded-full border border-border px-3 py-1 text-muted-foreground text-xs transition-colors hover:border-foreground hover:text-foreground" href={contentHref("/concepts/stability")} > - Experimental — the API may change between minor releases + Experimental — the API may change between minor releases. Pin your version.

- Modals, drawers, toasts, and confirms as named, ordered stacks — open one from any component, effect, or route guard and await a typed result. Headless core, six framework adapters. + Modals, drawers, toasts, and confirms as named, ordered stacks — open one from any component, effect, or route guard and await a typed result. Headless core, seven framework adapters.

-
6
+
7
adapters
diff --git a/apps/docs/pages/_home/Stability.astro b/apps/docs/pages/_home/Stability.astro index 2871049..26e6934 100644 --- a/apps/docs/pages/_home/Stability.astro +++ b/apps/docs/pages/_home/Stability.astro @@ -11,8 +11,8 @@ import { contentHref } from "blume/components/content/base-href.ts";

- Layers is experimental — the API may change between minor releases. Pin - your dependency version and read release notes before upgrading. + Experimental — the API may change between minor releases. Pin your + version.

diff --git a/apps/docs/pages/_home/UseCases.astro b/apps/docs/pages/_home/UseCases.astro index c9d9f79..fea5964 100644 --- a/apps/docs/pages/_home/UseCases.astro +++ b/apps/docs/pages/_home/UseCases.astro @@ -45,7 +45,7 @@ const reachFor = [ }, { useCase: - "One overlay system across React/Preact/Solid/Angular/Vue/Svelte", + "One overlay system across React/Preact/Solid/Angular/Vue/Lit/Svelte", involves: "UI-agnostic core + adapters", fit: "Ideal", }, diff --git a/apps/docs/pages/_home/adapter-snippets.ts b/apps/docs/pages/_home/adapter-snippets.ts index 15c1ba0..10aeff0 100644 --- a/apps/docs/pages/_home/adapter-snippets.ts +++ b/apps/docs/pages/_home/adapter-snippets.ts @@ -217,6 +217,53 @@ async function remove() { `, + }, + { + framework: "lit", + icon: "/icons/lit.svg", + label: "Lit", + installCommand: "bun add @stainless-code/lit-layers", + lang: "ts", + code: `import { + createLayer, + defineStackElements, + layerOptions, + provideLayerClient, + useLayer, +} from "@stainless-code/lit-layers"; +import { LitElement, html } from "lit"; +import { customElement } from "lit/decorators.js"; + +defineStackElements(); + +const confirm = layerOptions({ + stack: "confirm", + key: ["confirm", "remove"], + component: ConfirmDialog, +}); + +@customElement("app-shell") +class AppShell extends LitElement { + // Field order: provide before useLayer (same-host context is not the path). + #client = provideLayerClient(this); + #confirm = useLayer(this, confirm, this.#client); + + createRenderRoot() { + return this; + } + + render() { + return html\` + + + \`; + } +}`, }, { framework: "svelte", diff --git a/apps/docs/recipes/confirm-dialog/lit.ts b/apps/docs/recipes/confirm-dialog/lit.ts new file mode 100644 index 0000000..f6049ac --- /dev/null +++ b/apps/docs/recipes/confirm-dialog/lit.ts @@ -0,0 +1,104 @@ +import { + defineStackElements, + layerOptions, + provideLayerClient, + useLayer, +} from "@stainless-code/lit-layers"; +import type { LayerCallContext } from "@stainless-code/lit-layers"; +import { LitElement, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; + +// Register CE tags once (idempotent). Official Lit: define before first use. +defineStackElements(); + +interface ConfirmPayload { + title: string; +} +type ConfirmResponse = boolean; + +@customElement("example-confirm-dialog") +class ExampleConfirmDialog extends LitElement { + // Light DOM — Lit docs: return `this` from createRenderRoot. + // Overlay hosts avoid shadow so stacking stays where the outlet is mounted. + createRenderRoot(): this { + return this; + } + + // LayerElementDirective assigns these; @property makes them reactive (Lit). + @property({ attribute: false }) + declare call: LayerCallContext; + + @property({ attribute: false }) + declare payload: ConfirmPayload; + + render() { + return html`

`; + } +} + +const confirm = layerOptions({ + stack: "example-confirm", + key: ["example-confirm"], + component: ExampleConfirmDialog, +}); + +@customElement("example-trigger") +export class ExampleTrigger extends LitElement { + createRenderRoot(): this { + return this; + } + + // Resolves LayerClient from an ancestor provideLayerClient / . + #confirm = useLayer(this, confirm); + + @state() + private _result: boolean | null = null; + + render() { + return html`
+ + ${this._result !== null + ? html`Result: ${String(this._result)}` + : null} +
`; + } +} + +@customElement("example-app") +class ExampleApp extends LitElement { + constructor() { + super(); + // Attach ContextProvider before the first update (children resolve in render). + provideLayerClient(this); + } + + createRenderRoot(): this { + return this; + } + + render() { + return html` + `; + } +} + +export default ExampleApp; diff --git a/apps/docs/tsconfig.recipes-lit.json b/apps/docs/tsconfig.recipes-lit.json new file mode 100644 index 0000000..98072fe --- /dev/null +++ b/apps/docs/tsconfig.recipes-lit.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "experimentalDecorators": true, + "useDefineForClassFields": false, + "noEmit": true + }, + "include": ["recipes/*/lit*.ts"] +} diff --git a/bun.lock b/bun.lock index 907278b..3d8c7ac 100644 --- a/bun.lock +++ b/bun.lock @@ -46,13 +46,16 @@ "@astrojs/svelte": "9.0.1", "@astrojs/vue": "7.0.1", "@base-ui/react": "1.6.0", + "@lit/context": "1.1.6", "@stainless-code/angular-layers": "workspace:*", + "@stainless-code/lit-layers": "workspace:*", "@stainless-code/preact-layers": "workspace:*", "@stainless-code/solid-layers": "workspace:*", "@stainless-code/svelte-layers": "workspace:*", "@stainless-code/vue-layers": "workspace:*", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", + "lit": "3.3.1", "preact": "10.29.4", "react-aria-components": "1.19.0", "solid-js": "1.9.14", @@ -89,6 +92,23 @@ "name": "@stainless-code/layers", "version": "0.1.0", }, + "packages/lit": { + "name": "@stainless-code/lit-layers", + "version": "0.1.0", + "dependencies": { + "@stainless-code/layers": "workspace:*", + }, + "devDependencies": { + "@lit/context": "1.1.6", + "jsdom": "29.1.1", + "lit": "3.3.1", + "vitest": "4.1.9", + }, + "peerDependencies": { + "@lit/context": ">=1.1.0", + "lit": ">=3.2.0", + }, + }, "packages/preact": { "name": "@stainless-code/preact-layers", "version": "0.1.0", @@ -630,6 +650,12 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@lit-labs/ssr-dom-shim": ["@lit-labs/ssr-dom-shim@1.6.0", "", {}, "sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ=="], + + "@lit/context": ["@lit/context@1.1.6", "", { "dependencies": { "@lit/reactive-element": "^1.6.2 || ^2.1.0" } }, "sha512-M26qDE6UkQbZA2mQ3RjJ3Gzd8TxP+/0obMgE5HfkfLhEEyYE3Bui4A5XHiGPjy0MUGAyxB3QgVuw2ciS0kHn6A=="], + + "@lit/reactive-element": ["@lit/reactive-element@2.1.2", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.5.0" } }, "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A=="], + "@lmdb/lmdb-darwin-arm64": ["@lmdb/lmdb-darwin-arm64@3.5.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Kk4Kz3iyu1QiLsLZBS9Af1eSKUC8VR2T+/jyE2iAyuGw2VwK08pp5iTbZnXn6sWu0LogO/RFktMxOjiDA2sS3w=="], "@lmdb/lmdb-darwin-x64": ["@lmdb/lmdb-darwin-x64@3.5.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-BEe5Rp3trn26oxoXOVL5HVDoiYmjUDwr8NRPkBOdUdCSBEorKI+7JrZLRKAdxO+G6cGQLgseXk0gR7qIQa7aGw=="], @@ -1092,6 +1118,8 @@ "@stainless-code/layers-docs": ["@stainless-code/layers-docs@workspace:apps/docs"], + "@stainless-code/lit-layers": ["@stainless-code/lit-layers@workspace:packages/lit"], + "@stainless-code/preact-layers": ["@stainless-code/preact-layers@workspace:packages/preact"], "@stainless-code/react-layers": ["@stainless-code/react-layers@workspace:packages/react"], @@ -2226,6 +2254,12 @@ "listr2": ["listr2@10.2.2", "", { "dependencies": { "cli-truncate": "^5.2.0", "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^10.0.0" } }, "sha512-JtNtbZj8q5BnDMR7trpwvwk3RIrANtIVzEUm8w7amp6xelLgyuq+4WZoTH913XaQAoH/cNdYhaNzBPA2U3xbDw=="], + "lit": ["lit@3.3.1", "", { "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", "lit-html": "^3.3.0" } }, "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA=="], + + "lit-element": ["lit-element@4.2.2", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.5.0", "@lit/reactive-element": "^2.1.0", "lit-html": "^3.3.0" } }, "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w=="], + + "lit-html": ["lit-html@3.3.3", "", { "dependencies": { "@types/trusted-types": "^2.0.2" } }, "sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA=="], + "lmdb": ["lmdb@3.5.4", "", { "dependencies": { "@harperfast/extended-iterable": "^1.0.3", "msgpackr": "^1.11.2", "node-addon-api": "^6.1.0", "node-gyp-build-optional-packages": "5.2.2", "ordered-binary": "^1.5.3", "weak-lru-cache": "^1.2.2" }, "optionalDependencies": { "@lmdb/lmdb-darwin-arm64": "3.5.4", "@lmdb/lmdb-darwin-x64": "3.5.4", "@lmdb/lmdb-linux-arm": "3.5.4", "@lmdb/lmdb-linux-arm64": "3.5.4", "@lmdb/lmdb-linux-x64": "3.5.4", "@lmdb/lmdb-win32-arm64": "3.5.4", "@lmdb/lmdb-win32-x64": "3.5.4" }, "bin": { "download-lmdb-prebuilds": "bin/download-prebuilds.js" } }, "sha512-9FKQA6G1MMtqNxfxvSBNXD/axeG2QRjYbNh0/ykRL5xYcRbCm2vXq7B9bhc7nSuKdHzr8/BHIwfPuYYH1UsXXw=="], "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], diff --git a/docs/architecture.md b/docs/architecture.md index 98bf298..5c1d7d1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -22,7 +22,7 @@ The seam is a **published package boundary** in a bun-workspaces monorepo: one z ``` - **Core** (`packages/core` → `@stainless-code/layers`): `LayerClient` (`ensureStack`, `getStackIds`, `subscribeStacks`), `LayerStack` (`dismiss`/`dismissAll`/`addBlocker`/`settle`/`setRunning`/`cancelQueued`/`getQueuedSnapshot`), `Layer`, `Subscribable`, `notifyManager`, `ControlledPromise`, `createCallContext`, `layerOptions`, `layerKey`/`DataTag`, `createLayer`/`LayerHandle`/`ValidatedLayerHandle`, `createLayerGroup`/`childStackId`, `layerGcCache` (internal), `errors`, payload validation (`StandardSchemaV1`, `Validator`, `PayloadValidationError`, `isPayloadValidationError`), `Register`/`DefaultLayerError`, `utils`. Zero deps; `sideEffects: false`. Entry `.` → `src/index.ts`. -- **Adapters** (`packages/` → `@stainless-code/-layers`): each declares the library or framework as a **required peer dependency** and `@stainless-code/layers` as a direct dependency, re-exports core (including `createLayer`), and exposes the wired/observe hooks (`useLayer` / `useLayerState` / `useQueuedStack` / `useLayerQueuedState` — names vary per § Adapter ergonomics), a `useStack`-shaped binding, an idiomatic client-context provider + `useLayerClient` (React/Preact `StackProvider`; Svelte `setLayerClient`; Vue `provideLayerClient`; Solid `LayerClientContext`; Angular `LAYER_CLIENT` + `provideLayerClient`), and a rendering surface (`StackOutlet`, or the imperative/primitive equivalent — see § Adapter ergonomics). **Svelte ships two entries in one package:** `.` (runes, `svelte/reactivity`, 5.7+) and `./store` (stores, `svelte/store`, 3.0+). All adapters have reached ergonomic parity; Angular and Svelte diverge by design (compiler-free builds → imperative / primitive rendering rather than shipped components). +- **Adapters** (`packages/` → `@stainless-code/-layers`): each declares the library or framework as a **required peer dependency** and `@stainless-code/layers` as a direct dependency, re-exports core (including `createLayer`), and exposes the wired/observe hooks (`useLayer` / `useLayerState` / `useQueuedStack` / `useLayerQueuedState` — names vary per § Adapter ergonomics), a `useStack`-shaped binding, an idiomatic client-context provider + `useLayerClient` (React/Preact `StackProvider`; Svelte `setLayerClient`; Vue/Lit `provideLayerClient`; Solid `LayerClientContext`; Angular `LAYER_CLIENT` + `provideLayerClient`), and a rendering surface (`StackOutlet`, or the imperative/primitive equivalent — see § Adapter ergonomics). Lit peers are `lit` + `@lit/context`; CEs register via explicit `defineStackElements()` (not on import). **Svelte ships two entries in one package:** `.` (runes, `svelte/reactivity`, 5.7+) and `./store` (stores, `svelte/store`, 3.0+). All adapters have reached ergonomic parity; Angular and Svelte diverge by design (compiler-free builds → imperative / primitive rendering rather than shipped components); Lit diverges with shadow provider + light-DOM outlet CEs + `LayerGroup.outlet()` (footnote ⁷). Cross-package type resolution in dev uses a `@stainless-code/source` export condition (each package's `exports` maps it to `src`) plus tsconfig `customConditions`, so typecheck resolves core's source without a build-order dependency; published consumers get `dist` via the standard `types`/`import` conditions. @@ -36,17 +36,17 @@ Every adapter exposes the same primitives (client context, `useLayerClient`, wir **Array observe + topmost find:** observe hooks return `LayerState[]` (all same-key). `LayerStack.find` is **topmost** (`findLast`); `cancelQueued` without `{ id }` is first-queued (FIFO), with `{ id }` the exact queued instance — different collections, different defaults. -| Capability | React | Preact | Vue | Solid | Angular | Svelte (runes) | Svelte (store) | -| ------------------------------------------------------ | :---: | :----: | :-: | :---: | :-----: | :------------: | :------------: | -| Primitives + core re-export | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| `StackOutlet` (built-in host) | ✅ | ✅ | ✅ | ✅ | ✅¹ | —⁴ | —⁴ | -| `StackSubscribe` (render-prop / scoped slot) | ✅ | ✅ | ✅ | ✅ | —² | —⁴ | —⁴ | -| `useStackHandles` (headless `{ states, getCall }`) | ✅ | ✅ | ✅ | ✅ | ✅ | —⁵ | —⁵ | -| `useMutationFlow` (async-action helper) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| `useLayerGroup` (nested-stack hook) | ✅ | ✅ | ✅ | ✅ | ✅³ | ✅⁶ | ✅⁶ | -| `createStackHook` (`useAppStack`/`AppHost`/`AppLayer`) | ✅ | ✅ | ✅ | ✅ | ✅³ | —⁴ | —⁴ | +| Capability | React | Preact | Vue | Solid | Angular | Lit | Svelte (runes) | Svelte (store) | +| ------------------------------------------------------ | :---: | :----: | :-: | :---: | :-----: | :-: | :------------: | :------------: | +| Primitives + core re-export | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `StackOutlet` (built-in host) | ✅ | ✅ | ✅ | ✅ | ✅¹ | ✅ | —⁴ | —⁴ | +| `StackSubscribe` (render-prop / scoped slot) | ✅ | ✅ | ✅ | ✅ | —² | ✅ | —⁴ | —⁴ | +| `useStackHandles` (headless `{ states, getCall }`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | —⁵ | —⁵ | +| `useMutationFlow` (async-action helper) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `useLayerGroup` (nested-stack hook) | ✅ | ✅ | ✅ | ✅ | ✅³ | ✅ | ✅⁶ | ✅⁶ | +| `createStackHook` (`useAppStack`/`AppHost`/`AppLayer`) | ✅ | ✅ | ✅ | ✅ | ✅³ | ✅ | —⁴ | —⁴ | -¹ Angular ships `renderStack(vcr)` — a compiler-free imperative outlet (`ViewContainerRef.createComponent` + `setInput`, id-keyed so state changes update inputs without recreating components). ² Angular has no `StackSubscribe`; its `useStack({ select })` signal is the idiomatic equivalent. ³ Angular's `useLayerGroup`/`createStackHook` expose `renderInto(vcr)` instead of an `Outlet` component; `createStackHook` returns `{ provideClient, useAppStack, renderInto }` (no `AppHost`/`AppLayer`). ⁴ Svelte ships no `.svelte` components: render with `{#each useStack().current as s}` + `callFor(s)` (runes) / `{#each $stack}` + `callFor(client, stackId, s)` (store) in your own markup. ⁵ Svelte's `useStack()` already returns the headless pair — `.current` (states) + `callFor`. ⁶ Svelte's `useLayerGroup` exposes the child `stack` (`SvelteStack` / `Readable`) instead of an `Outlet`. +¹ Angular ships `renderStack(vcr)` — a compiler-free imperative outlet (`ViewContainerRef.createComponent` + `setInput`, id-keyed so state changes update inputs without recreating components). ² Angular has no `StackSubscribe`; its `useStack({ select })` signal is the idiomatic equivalent. ³ Angular's `useLayerGroup`/`createStackHook` expose `renderInto(vcr)` instead of an `Outlet` component; `createStackHook` returns `{ provideClient, useAppStack, renderInto }` (no `AppHost`/`AppLayer`). ⁴ Svelte ships no `.svelte` components: render with `{#each useStack().current as s}` + `callFor(s)` (runes) / `{#each $stack}` + `callFor(client, stackId, s)` (store) in your own markup. ⁵ Svelte's `useStack()` already returns the headless pair — `.current` (states) + `callFor`. ⁶ Svelte's `useLayerGroup` exposes the child `stack` (`SvelteStack` / `Readable`) instead of an `Outlet`. ⁷ Lit's `useLayerGroup` exposes `outlet(): TemplateResult` (router `Routes.outlet()`-shaped) instead of an `Outlet` component; `StackProvider` uses default shadow + ``; `StackOutlet`/`StackSubscribe`/`app-host` are light-DOM CEs. Register via explicit `defineStackElements()` (not on import; `"sideEffects": false`). Layer `component` is a `LitElement` constructor or `(props) => TemplateResult` — no tag strings. No `./signals` until Lit first-party signals (Svelte dual-API bar); `@lit/context` `ContextRoot` stays optional for late-upgraded providers (not auto-installed). Per-adapter API detail lives on the [Blume adapters pages](https://stainless-code.com/layers/adapters); this matrix is the cross-adapter comparison for choosing and porting between adapters. @@ -171,6 +171,7 @@ Narrow in a `catch` with the shipped guards (`isPayloadValidationError`) or the - **React / Preact** — `useSyncExternalStore` calls `getServerSnapshot` and **never invokes `subscribe` on the server**, so the store is read once with no listener registered. This is the whole SSR-safety story for these adapters. - **Svelte** — `createSubscriber` is lazy: it only starts the subscription when `.current` is read inside a reactive (effect) context, which doesn't happen during server string rendering — so no eager server subscription. - **Vue / Solid / Angular** — subscribe eagerly but register teardown (`onScopeDispose` / `from` owner / `effect` `onCleanup`) that runs when the library or framework disposes the SSR render scope, so no cross-request listener leak. +- **Lit** — Reactive Controllers subscribe in `hostConnected` and tear down in `hostDisconnected`; `hostConnected` does not fire during SSR string rendering, so no listener is registered server-side. - A **proactive "skip subscribe on the server"** guard for Vue/Solid/Angular was **deliberately not added**: there is no clean, test-safe server detector (bun's test env has no `window` and resolves Solid to its SSR build, so a `typeof window`/`isServer` guard would skip subscription in the adapters' own unit tests and break their reactivity assertions), and the teardown above already prevents leaks. Revisit only if a concrete SSR leak surfaces. ## Headless rendering @@ -200,4 +201,4 @@ Each package owns its tests (`bun run --filter '*' test` fans out). Adapter binding tests mock the library or framework module (`solid-js` → client build; `@angular/core` stub; `preact/compat` stub) or use a real reactive scope (`vue` `effectScope`), and assert the value contract. The reactive auto-update path that needs a full renderer is pinned per adapter by the DOM suites; the `LayerStack` subscription it rides on is pinned in core tests. Package-level import isolation (each adapter only depends on core + its library or framework peer) is enforced by the package boundary + `sherif`/`knip` rather than a per-file test. -**DOM-harness convention:** each DOM-tested package owns a `vitest.config.ts` + its framework's testing library + `jsdom`/`vitest` devDeps (React/Preact/Vue `@testing-library/`; Solid `vite-plugin-solid` + `@solidjs/testing-library`; Angular `@analogjs/vite-plugin-angular` + TestBed with a self-contained `tsconfig.spec.json`; Svelte `@sveltejs/vite-plugin-svelte` + `@testing-library/svelte`), a `test:dom` script, and its `tests-dom/**` registered as a `knip` entry. Test deps are version-pinned to match React's so `sherif` stays green. DOM tests resolve the package by name → its built `dist`, so **build before `test:dom`** (the root `check` builds first). +**DOM-harness convention:** each DOM-tested package owns a `vitest.config.ts` + its framework's testing library + `jsdom`/`vitest` devDeps (React/Preact/Vue `@testing-library/`; Solid `vite-plugin-solid` + `@solidjs/testing-library`; Angular `@analogjs/vite-plugin-angular` + TestBed with a self-contained `tsconfig.spec.json`; Lit vitest + jsdom with real custom elements; Svelte `@sveltejs/vite-plugin-svelte` + `@testing-library/svelte`), a `test:dom` script, and its `tests-dom/**` registered as a `knip` entry. Test deps are version-pinned to match React's so `sherif` stays green. DOM tests resolve the package by name → its built `dist`, so **build before `test:dom`** (the root `check` builds first). diff --git a/docs/glossary.md b/docs/glossary.md index 8ce6b5a..3ee0f70 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -27,10 +27,10 @@ Ubiquitous language for the `@stainless-code/layers` domain. Keep terms stable a - **scope** — per-stack queueing on `StackOptions`: `scope: { strategy: "serial" | "parallel" }` (`parallel` when omitted); `serial` = one active layer at a time, `parallel` = stack freely. - **gcTime** — keep dismissed layers in a cache so re-opening the same key restores `data` without re-running `loadFn`. One slot per key (last-dismissed-wins); evicted after `gcTime` with explicit teardown. - **loadFn** — optional async load run on `open`; cancelable via `AbortController`; `pending` → `active` with `data` on success, `error` on throw. -- **Observer / selector** — adapters subscribe to `LayerStack.getSnapshot()` and project via a selector (React `useSyncExternalStore`, Preact `useSyncExternalStore`, Solid `from`, Angular `signal`, Vue `shallowRef`, Svelte runes). +- **Observer / selector** — adapters subscribe to `LayerStack.getSnapshot()` and project via a selector (React/Preact `useSyncExternalStore`, Solid `from`, Angular `signal`, Vue `shallowRef`, Lit reactive controllers, Svelte runes). - **Options helper** (`layerOptions`) — identity function carrying `` generics so `LayerClient.open` infers the response type end-to-end. -- **Outlet** — renders the active stack. React, Preact, and Solid ship `StackOutlet`; Angular exposes `renderStack(vcr)`; Vue ships `StackOutlet`; Svelte renders from `useStack().current` + `callFor`. See [adapter ergonomics](./architecture.md#adapter-ergonomics). -- **Stack handles** (`useStackHandles`) — headless `{ states, getCall }` for custom hosts (React/Preact/Solid/Angular/Vue; Svelte's `useStack()` already returns `.current` + `callFor`). `StackOutlet` is the registered-component convenience built on it where shipped. +- **Outlet** — renders the active stack. React, Preact, Solid, and Vue ship `StackOutlet`; Angular exposes `renderStack(vcr)`; Lit ships ``; Svelte renders from `useStack().current` + `callFor`. See [adapter ergonomics](./architecture.md#adapter-ergonomics). +- **Stack handles** (`useStackHandles`) — headless `{ states, getCall }` for custom hosts (React/Preact/Solid/Angular/Vue/Lit; Svelte's `useStack()` already returns `.current` + `callFor`). `StackOutlet` is the registered-component convenience built on it where shipped. - **Layer group** — a child stack owned by a parent layer and tied to its lifetime: created via `createLayerGroup` / adapter `useLayerGroup`; when the parent dismisses, the group's stack auto-drains. Same `LayerClient`, no second client. - **Child stack** — the stack a layer group opens onto; id derived from the parent's `stackId` + `layerId` via `childStackId` (`` `${parentStackId}~${parentLayerId}~${name}` ``). - **Validator** (`validate`) — a Standard Schema or sync `(input) => output` fn that parses/validates a layer's `payload` at `open`; the layer stores the parsed output. Failure rejects `open` with a `PayloadValidationError`. diff --git a/docs/plans/lit-adapter.md b/docs/plans/lit-adapter.md deleted file mode 100644 index f9af989..0000000 --- a/docs/plans/lit-adapter.md +++ /dev/null @@ -1,205 +0,0 @@ -# Lit adapter (`@stainless-code/lit-layers`) - -> Plan owner: open. Status: **Ready to implement** (grilled). Linked from [`docs/roadmap.md`](../roadmap.md) § Adapters. - -Seventh framework adapter at **full matrix parity**. Lit research clone: `/Users/sutusebastian/Developer/OSS/lit/lit` ([lit/lit](https://github.com/lit/lit), [`lit@3.3.3`](https://lit.dev/)). - -## Problem - -- Six adapters at parity; Lit/WC consumers need the same binding **Seam** (`LayerStack.subscribe` / `getSnapshot` → host reactivity + render). -- Lit idioms differ ([Reactive Controllers](https://lit.dev/docs/composition/controllers/), [`@lit/context`](https://lit.dev/docs/data/context/), [`repeat`](https://lit.dev/docs/templates/lists/)) — shape must be Lit-native while hitting every matrix cell. -- Default Lit shadow DOM fights overlays; no `HostAdapter` (roadmap) — outlet must render inline where mounted. -- Policy: **every new adapter gets full treatment** (primitives + tier-2 sugar, `tests-dom`, docs, skill, recipes typecheck, size-limit) — not a sparse MVP. - -## Decisions (grilled) - -| Topic | Lock | -| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| When | Consumers in funnel — implement after this plan; no demand-wait | -| Parity bar | Full matrix like React/Vue/Solid (capability), Lit-shaped APIs | -| Binding model | `ReactiveController` + `requestUpdate`; CEs for provider/outlet/subscribe | -| Factory | `createStackHook` name kept; Lit-shaped return | -| Outlet DOM | **Light DOM default** (`createRenderRoot() { return this }`); optional shadow later only if needed | -| `component` | `(props) => TemplateResult` **or** `typeof LitElement`; **no** tag strings in v1 | -| CE tags | `stack-provider` / `stack-outlet` / `stack-subscribe` (+ `app-host` for `createStackHook`) — kebab of matrix names | -| CE registration | Explicit **`defineStackElements()`** on `.`; **`"sideEffects": false`**; no auto-register on import (tenets 2 + 4) | -| Peers | `lit` `^3.2.0`, `@lit/context` `^1.1.0` (required); optional `client?` override everywhere | -| Signals | **Single flavour** now. `./signals` only if Lit promotes signals past Labs (same bar as Svelte runes vs `./store`) | -| `StackSubscribe` | Property **`.renderer`** callback ([virtualizer `.renderItem`](https://github.com/lit/lit/tree/main/packages/labs/virtualizer) pattern) | -| `LayerGroup` | **`outlet(): TemplateResult` + `stackId`** ([router `Routes.outlet()`](https://github.com/lit/lit/tree/main/packages/labs/router); like Angular `renderInto`) | -| `createStackHook` | React-full: `StackProvider`, `useAppStack`, `AppHost` CE, `AppLayerController` | -| Observe/drive names | `*Controller` classes + thin `use*(host, …)` factories (matrix names) | -| SSR | Vue/Solid posture: subscribe in `hostConnected`, teardown `hostDisconnected`; empty until connect; no `typeof window` guard; `initialSnapshot` separate roadmap | -| `ContextRoot` | Optional / documented for late-upgraded providers — **not** auto-installed | -| Docs recipes | Lit = code-only tabs (`?raw`); Blume live `` hard-caps React/Vue/Svelte | - -## Proposed Interface (or Boundary) - -### Package - -| Item | Value | -| ------------- | ------------------------------------------------- | -| Path | `packages/lit` | -| Name | `@stainless-code/lit-layers` | -| Dep | `@stainless-code/layers` `workspace:*` | -| Peers | `lit` `^3.2.0`, `@lit/context` `^1.1.0` | -| `sideEffects` | `false` | -| Exports | `.` only (no `./elements`, no `./signals`) | -| Build | `tsdown`; knip + size-limit + `check:pack` parity | - -### Public surface - -```ts -export * from "@stainless-code/layers"; - -// Context (@lit/context) -export const layerClientContext: Context<…, LayerClient>; -export function provideLayerClient(host, client?: LayerClient): LayerClient; -export function useLayerClient(host): LayerClient; // throws if missing - -// Registration (idempotent customElements.define) -export function defineStackElements(): void; - -// Controllers + use* factories (host-first) — full matrix: -// useStack, useQueuedStack, useLayerState, useLayerQueuedState, -// useLayer, useStackHandles, useMutationFlow, useLayerGroup, useLayerClient -export class StackController implements ReactiveController { /* … */ } -export class LayerController implements ReactiveController { /* wired handle */ } -export class StackHandlesController implements ReactiveController { /* … */ } -export class MutationFlowController implements ReactiveController { /* … */ } -export class LayerGroupController implements ReactiveController { - outlet(): TemplateResult; // router-shaped - readonly stackId: string; - // open, dismissAll, states, … -} - -// CEs (classes exported; define via defineStackElements) -export class StackProvider extends LitElement { /* tag: stack-provider */ } -export class StackOutlet extends LitElement { - /* tag: stack-outlet; light DOM; repeat by state.id; - component = TemplateResult fn | LitElement ctor */ -} -export class StackSubscribe extends LitElement { - /* tag: stack-subscribe; .selector; .renderer(value) => TemplateResult */ -} - -export function createStackHook(config?): { - StackProvider: typeof StackProvider; // bound stack id / client defaults - useAppStack: (host) => AppStackController; - AppHost: typeof LitElement; // tag app-host — host props + stack-outlet - AppLayer: typeof AppLayerController; -}; - -export type LitLayerComponent = - | (new () => LitElement) - | ((props: LayerComponentProps) => TemplateResult); -``` - -Internal: `subscribeStackSnapshot` (pull-model subscribe + selector memo + `requestUpdate`). - -### Usage (default path) - -```ts -import { - LayerClient, - createLayer, - defineStackElements, - provideLayerClient, -} from "@stainless-code/lit-layers"; -import { LitElement, html } from "lit"; -import { customElement } from "lit/decorators.js"; - -defineStackElements(); - -export const client = new LayerClient(); -export const confirm = createLayer({ - key: "confirm", - stack: "modal", - component: ConfirmDialog, // LitElement ctor or (props) => TemplateResult -}); - -@customElement("my-app") -class MyApp extends LitElement { - render() { - return html``; - } -} - -// shell -html` - - -
-
-`; -``` - -### What it hides / forbids - -- Hides: subscribe lifecycle, selector memo, `createCallContext`, context wire-up. -- Forbids: cross-adapter imports; framework peers in core; `HostAdapter` / built-in portal; auto CE registration on `.` import; `@lit-labs/signals` in v1. - -## Dependency Strategy - -| Category | How | -| ----------------------- | --------------------------------------------------------------------------------------------------- | -| **Ports & adapters** | Core port = `subscribe` / `getSnapshot` / `getQueuedSnapshot`. Lit owns Transport + Renderer. | -| **Mock (cat. 4)** | Peers `lit` + `@lit/context`. Never duplicate `lit`. | -| **Local-substitutable** | Fake `ReactiveControllerHost` + real `LayerClient` in bun tests; vitest + real Lit in `tests-dom/`. | - -## Migration - -Greenfield. - -1. Scaffold `packages/lit` (mirror Vue: manifest, tsdown, tsconfig, README, knip, size-limit). -2. Tracer: `subscribeStackSnapshot` + `StackController` / `useStack` + unit test. -3. Context + `provideLayerClient` / `useLayerClient`. -4. `defineStackElements` + `StackOutlet` / `StackProvider` + `tests-dom` (id-keyed `repeat`, light DOM). -5. Remaining matrix: wired `useLayer`, `StackSubscribe`, `useMutationFlow`, `useLayerGroup` (`outlet()`), `useStackHandles`, `createStackHook` + DOM/type tests. -6. Docs: `adapters/lit.mdx`, matrix rows, `adapter-hooks.mdx`, skill, recipes (`lit.ts` `?raw`) + `typecheck:recipes`. -7. Changeset on publish PR. Lift grill locks into [`architecture.md`](../architecture.md) when the package ships (close plan). - -## Testing Strategy - -| Suite | Assert | -| --------------- | ------------------------------------------------------------------------------------------------- | -| `src/*.test.ts` | Subscribe/unsub; selector skip; context; `defineStackElements` idempotent; group `outlet()` | -| `tests-dom/` | Provider → outlet → open/end; no remount on prop churn; light DOM; subscribe `.renderer`; AppHost | -| `*.test-d.ts` | Inference parity | - -## Glossary impact - -None. Framework bindings (`StackController`, `outlet()`, CE tags) are not new domain nouns. Existing glossary (`outlet`, `observer`, `call context`, `useLayer`) stands. - -## Out of scope - -- `@lit-labs/signals` / `./signals` until Lit first-party signals (Svelte dual-API bar). -- `@lit-labs/ssr` package; tag-string `component`; auto-register on import; `HostAdapter` / portal; focus/z-order/a11y chrome. -- Lit 4 peer OR until Lit 4 exists. - -## Rejected alternatives - -| Alternative | Why rejected | -| -------------------------------------- | ---------------------------------------------------------------------------------- | -| Sparse controllers-only v1 | Policy: full treatment for every new adapter | -| Demand-gate before coding | Consumers ready now | -| Auto-register CEs on `.` import | Tenet 4 (surprising side effects); tenet 2 (outlet optional) | -| `./elements` side-effect subpath | Same magic as auto-register; Svelte subpath is dual reactivity, not define helpers | -| `layers-*` / `sc-*` tags | Drifts from `StackOutlet` naming on other adapters | -| Shadow DOM outlet default | Overlay stacking; consumers own portals | -| Bound `Outlet` CE from `useLayerGroup` | Not Lit-native; use `outlet()` like router | -| Signals dual entry now | Labs ≠ Svelte’s two first-party APIs | - -## Open questions - -None blocking implementation. Deferred: Lit 4 peer OR; `mode="shadow"` on outlet; `./signals` if Labs graduates. - -## References - -- Layers: [`docs/architecture.md`](../architecture.md), [`adapter-hooks.mdx`](../../apps/docs/content/reference/adapter-hooks.mdx), Vue/Solid/Angular peers. -- Lit: [lit.dev](https://lit.dev/), [Controllers](https://lit.dev/docs/composition/controllers/), [Context](https://lit.dev/docs/data/context/), [`repeat`](https://lit.dev/docs/templates/lists/), clone `/Users/sutusebastian/Developer/OSS/lit/lit`. -- Tenets: [`.agents/skills/product-tenets/SKILL.md`](../../.agents/skills/product-tenets/SKILL.md). diff --git a/docs/roadmap.md b/docs/roadmap.md index 9897df8..0442431 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -19,7 +19,6 @@ Forward-looking work only — not a mirror of `src/`. Shipped features live in [ ## Adapters - **Qwik** + **Alpine** + **Marko** + **Angular SSR** + **React Native** bindings if demand materializes. -- **[Lit adapter](./plans/lit-adapter.md)** (`@stainless-code/lit-layers`) — full matrix parity; controllers + `@lit/context` + light-DOM ``; grilled, ready to implement. - A **`HostAdapter`** contract is deliberately **not** planned — `StackOutlet` renders inline; consumers wrap it in their own `createPortal` if they need a portal target. Keeping the core agnostic means no host/portal abstraction baked in. ## Docs site (`apps/docs/`, Blume) diff --git a/knip.json b/knip.json index bc407df..6b1c371 100644 --- a/knip.json +++ b/knip.json @@ -42,6 +42,14 @@ ], "project": ["src/**/*.ts", "tests-dom/**/*.ts"] }, + "packages/lit": { + "entry": [ + "src/**/*.test.ts", + "tests-dom/**/*.test.ts", + "tests-dom/**/*.test-d.ts" + ], + "project": ["src/**/*.ts", "tests-dom/**/*.ts"] + }, "packages/solid": { "entry": [ "src/**/*.test.ts", diff --git a/packages/core/skills/layers/SKILL.md b/packages/core/skills/layers/SKILL.md index e41d266..376f8e6 100644 --- a/packages/core/skills/layers/SKILL.md +++ b/packages/core/skills/layers/SKILL.md @@ -22,7 +22,7 @@ sources: `@stainless-code/layers` is the headless, UI-agnostic engine for opening any layer from anywhere and managing it as an ordered, named stack. An app-wide `LayerClient` owns stack state and lifecycle; awaiting a typed result is optional, and `void client.open(...)` is equally first-class. -The [Angular](https://github.com/stainless-code/layers/blob/main/packages/angular/skills/angular-layers/SKILL.md), [Preact](https://github.com/stainless-code/layers/blob/main/packages/preact/skills/preact-layers/SKILL.md), [React](https://github.com/stainless-code/layers/blob/main/packages/react/skills/react-layers/SKILL.md), [Solid](https://github.com/stainless-code/layers/blob/main/packages/solid/skills/solid-layers/SKILL.md), [Svelte](https://github.com/stainless-code/layers/blob/main/packages/svelte/skills/svelte-layers/SKILL.md), and [Vue](https://github.com/stainless-code/layers/blob/main/packages/vue/skills/vue-layers/SKILL.md) adapters bind the engine to their library or framework's rendering model. All adapters have reached ergonomic parity; Angular and Svelte diverge by design — see [adapter ergonomics](https://github.com/stainless-code/layers/blob/main/docs/architecture.md#adapter-ergonomics). +The [React](https://github.com/stainless-code/layers/blob/main/packages/react/skills/react-layers/SKILL.md), [Preact](https://github.com/stainless-code/layers/blob/main/packages/preact/skills/preact-layers/SKILL.md), [Solid](https://github.com/stainless-code/layers/blob/main/packages/solid/skills/solid-layers/SKILL.md), [Angular](https://github.com/stainless-code/layers/blob/main/packages/angular/skills/angular-layers/SKILL.md), [Vue](https://github.com/stainless-code/layers/blob/main/packages/vue/skills/vue-layers/SKILL.md), [Lit](https://github.com/stainless-code/layers/blob/main/packages/lit/skills/lit-layers/SKILL.md), and [Svelte](https://github.com/stainless-code/layers/blob/main/packages/svelte/skills/svelte-layers/SKILL.md) adapters bind the engine to their library or framework's rendering model. All adapters have reached ergonomic parity; Angular and Svelte diverge by design — see [adapter ergonomics](https://github.com/stainless-code/layers/blob/main/docs/architecture.md#adapter-ergonomics). ## When to use this skill diff --git a/packages/lit/README.md b/packages/lit/README.md new file mode 100644 index 0000000..da0dab2 --- /dev/null +++ b/packages/lit/README.md @@ -0,0 +1,90 @@ +# @stainless-code/lit-layers + +

+ Layers +

+ +Modals are just async functions you forgot to `await`. + +Lit adapter — open any layer from anywhere and `await` a typed result. State coordination, not UI ownership: you own rendering, focus, portals, and a11y. + +> Experimental — the API may change between minor releases. Pin your version. + +## Install + +`bun add @stainless-code/lit-layers` + +**Peers:** `lit` (`>=3.2.0`), `@lit/context` (`>=1.1.0`) + +## Taste + +```ts +import { + LayerClient, + createLayer, + defineStackElements, + layerOptions, + type LayerCallContext, +} from "@stainless-code/lit-layers"; +import { LitElement, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +defineStackElements(); + +interface ConfirmPayload { + title: string; +} + +@customElement("confirm-dialog") +class ConfirmDialog extends LitElement { + // Light DOM so the dialog stacks with sibling overlays. + createRenderRoot() { + return this; + } + + @property({ attribute: false }) + declare call: LayerCallContext; + + @property({ attribute: false }) + declare payload: ConfirmPayload; + + render() { + return html`
+

${this.payload.title}

+ + +
`; + } +} + +const confirm = layerOptions({ + stack: "modal", + key: ["confirm", "remove"], + component: ConfirmDialog, + exitingDelay: 200, +}); + +const client = new LayerClient(); +const confirmLayer = createLayer(confirm, client); + +async function remove() { + const ok = await confirmLayer.open({ title: "Remove?" }); + // ^? boolean +} + +// Shell: +// +// +// +``` + +`` is shadow + ``; omit `client` on hooks to resolve from context after connect. + +## Learn more + +- [Adapter parity](https://stainless-code.com/layers/adapters) · [Adapter hooks](https://stainless-code.com/layers/reference/adapter-hooks) +- [Concepts: lifecycle](https://stainless-code.com/layers/concepts/lifecycle) · [identity & types](https://stainless-code.com/layers/concepts/identity-and-types) diff --git a/packages/lit/package.json b/packages/lit/package.json new file mode 100644 index 0000000..f8d453c --- /dev/null +++ b/packages/lit/package.json @@ -0,0 +1,77 @@ +{ + "name": "@stainless-code/lit-layers", + "version": "0.1.0", + "description": "Lit adapter for @stainless-code/layers — call a layer like an async function and await its response.", + "keywords": [ + "dialog", + "drawer", + "headless", + "layer", + "lit", + "modal", + "popover", + "stack", + "tanstack-intent", + "typescript", + "web-components" + ], + "homepage": "https://stainless-code.com/layers/adapters/lit", + "bugs": { + "url": "https://github.com/stainless-code/layers/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/stainless-code/layers.git", + "directory": "packages/lit" + }, + "funding": "https://github.com/sponsors/SutuSebastian", + "files": [ + "dist", + "skills", + "src", + "!src/**/*.test.ts", + "!src/**/*.test.tsx", + "!src/**/*.test-d.ts", + "!src/**/*.test-d.tsx" + ], + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "@stainless-code/source": "./src/index.ts", + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, + "scripts": { + "build": "tsdown", + "check:pack": "attw --pack . --profile esm-only && publint --strict", + "test": "bun test ./src", + "test:coverage": "bun test ./src --coverage --coverage-threshold=0.75", + "test:dom": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@stainless-code/layers": "workspace:*" + }, + "devDependencies": { + "@lit/context": "1.1.6", + "jsdom": "29.1.1", + "lit": "3.3.1", + "vitest": "4.1.9" + }, + "peerDependencies": { + "@lit/context": ">=1.1.0", + "lit": ">=3.2.0" + }, + "engines": { + "bun": ">=1.0.0", + "node": "^20.19.0 || >=22.12.0" + } +} diff --git a/packages/lit/skills/lit-layers/SKILL.md b/packages/lit/skills/lit-layers/SKILL.md new file mode 100644 index 0000000..1cfe11a --- /dev/null +++ b/packages/lit/skills/lit-layers/SKILL.md @@ -0,0 +1,164 @@ +--- +name: lit-layers +description: Lit adapter for @stainless-code/lit-layers; open UI from anywhere and manage ordered, named stacks with typed or fire-and-forget `open` via Reactive Controllers and custom elements +license: MIT +keywords: + - tanstack-intent + - lit + - web-components + - modal + - dialog + - stack + - typescript +metadata: + library: "@stainless-code/lit-layers" + library_version: "0.1.0" + framework: "lit" +sources: + - https://stainless-code.com/layers/adapters/lit + - https://github.com/stainless-code/layers/blob/main/docs/architecture.md +--- + +# Lit layer/stack UI with @stainless-code/lit-layers + +Open any layer from anywhere and manage modal, dialog, drawer, popover, or toast UI as an ordered, named stack. `@stainless-code/lit-layers` is the Lit 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/lit-layers` to open overlay UI imperatively from anywhere — custom elements, controllers, 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/lit-layers +``` + +Core is included. `lit` (>=3.2.0) and `@lit/context` (>=1.1.0) are required peers and should already be installed by the app. + +## Binding model + +Binding: Reactive Controllers + `requestUpdate`. Host-first factories — `useStack(this, { stack })`, `useLayer(this, options, client?)`. `.current` mirrors the selected snapshot and requests a host update on change. `` is shadow + ``; outlet / subscribe / `app-host` are light DOM. + +```ts +import { useStack } from "@stainless-code/lit-layers"; +import { LitElement, html } from "lit"; +import { customElement } from "lit/decorators.js"; + +@customElement("open-count") +class OpenCount extends LitElement { + #stack = useStack(this, { stack: "confirm", select: (s) => s.length }); + render() { + return html`

${this.#stack.current} open

`; + } +} +``` + +## Declare → Mount → Call + +```ts +// confirm-dialog.ts +import { + layerOptions, + type LayerCallContext, +} from "@stainless-code/lit-layers"; +import { LitElement, html } from "lit"; +import { customElement } from "lit/decorators.js"; + +@customElement("confirm-dialog") +export class ConfirmDialog extends LitElement { + // Light DOM so overlays stack inline where mounted. + createRenderRoot() { + return this; + } + declare call: LayerCallContext<{ title: string }, boolean>; + declare payload: { title: string }; + render() { + return html`
+

${this.payload.title}

+ + +
`; + } +} + +export const confirm = layerOptions<{ title: string }, boolean>({ + stack: "confirm", + key: ["confirm", "remove"], + component: ConfirmDialog, + exitingDelay: 200, +}); +``` + +```ts +// shell.ts — register the CEs and mount the outlet once, high in the tree +import { + defineStackElements, + provideLayerClient, + LayerClient, +} from "@stainless-code/lit-layers"; + +defineStackElements(); // stack-provider, stack-outlet, stack-subscribe, app-host +const client = new LayerClient(); +``` + +```html + + + + +``` + +```ts +// opener.ts +import { createLayer } from "@stainless-code/lit-layers"; +import { confirm } from "./confirm-dialog"; + +const c = createLayer(confirm, client); +async function handleRemove() { + const ok = await c.open({ title: "Remove?" }); + if (!ok) return; + deleteItem(); +} +``` + +Low-level bag-form: `client.open({ ...confirm, payload })`. + +`provideLayerClient(host, client?)` / `` supplies the client to descendants via `@lit/context`; omit `client` on any hook to resolve it from context. `useLayerClient(host)` returns a controller whose `.current` throws when no provider has supplied a client. + +## Primitives + +Options bag plus optional trailing `LayerClient`. **Drive** with `useLayer(this, options, client?)`; **observe** with `useLayerState(this, { key, ... }, client?)`. + +| Export | Role | +| ------------------------------------------------------------------ | --------------------------------------------------------------------------------------- | +| `useStack(host, { stack?, select?, compare? }, client?)` | `StackController` — `.current` mirrors the selected stack slice | +| `useQueuedStack(host, { ... }, client?)` | `StackController` over the queued snapshot | +| `useLayer(host, options, client?)` | **Drive** — wired handle + `open()` / `dismiss()` + reactive `state` / `queued` / `top` | +| `useLayerState(host, { key, stack?, select?, compare? }, client?)` | **Observe** — mounted same-key layers | +| `useLayerQueuedState(host, { key, ... }, client?)` | **Observe** — queued same-key layers | + +`useLayer` resolves the client lazily — omit `client` to resolve it from a `provideLayerClient()` / `` ancestor after the host connects; the wired handle builds on first method access. Pass `client` explicitly for synchronous access at construction. + +## Ergonomic APIs + +| Export | Role | +| ---------------------------------------------------- | --------------------------------------------------------------------------------------- | +| `useStackHandles(host, stack?, rootProps?, client?)` | `{ states, getCall }` for headless rendering | +| `StackSubscribe` CE | `` — `.renderer(value)` returns a `TemplateResult` | +| `useMutationFlow(host, call)` | `pending: boolean`; `run(fn).orEnd(response)` | +| `useLayerGroup(host, call, options?, client?)` | Child stack with `outlet(): TemplateResult` + `stackId` | +| `createStackHook(config?)` | `StackProvider`, `useAppStack(host)`, `AppHost` CE, `AppLayer` controller | + +`StackOutlet` and `LayerGroup.outlet()` render each layer with its registered `component` — a `LitElement` constructor or a `(props) => TemplateResult` render function (no tag strings in v1). Id-keyed `repeat` keeps element instances stable across updates. `defineStackElements()` is idempotent and not auto-invoked on import. + +## Learn more + +- [Lifecycle](https://stainless-code.com/layers/concepts/lifecycle) · [Identity & types](https://stainless-code.com/layers/concepts/identity-and-types) +- [Adapter parity](https://stainless-code.com/layers/adapters) · [Adapter hooks](https://stainless-code.com/layers/reference/adapter-hooks) diff --git a/packages/lit/src/index.test.ts b/packages/lit/src/index.test.ts new file mode 100644 index 0000000..a818617 --- /dev/null +++ b/packages/lit/src/index.test.ts @@ -0,0 +1,483 @@ +import { describe, expect, it } from "bun:test"; + +import { + createCallContext, + LayerClient, + layerOptions, +} from "@stainless-code/layers"; +import type { LayerCallContext } from "@stainless-code/layers"; +import type { ReactiveController, ReactiveControllerHost } from "lit"; + +import { + defineStackElements, + MutationFlowController, + StackController, + useLayer, + useLayerGroup, + useLayerQueuedState, + useLayerState, + useMutationFlow, + useQueuedStack, + useStack, +} from "./index"; + +type Host = ReactiveControllerHost & { + removeController(c: ReactiveController): void; +}; + +function createHost() { + const controllers: ReactiveController[] = []; + let updateCount = 0; + + const host: Host = { + addController(c) { + controllers.push(c); + (c as ReactiveController).hostConnected?.(); + }, + removeController(c) { + const i = controllers.indexOf(c); + if (i !== -1) controllers.splice(i, 1); + (c as ReactiveController).hostDisconnected?.(); + }, + requestUpdate() { + updateCount += 1; + }, + updateComplete: Promise.resolve(true), + }; + + return { + host, + get updateCount() { + return updateCount; + }, + disconnect() { + while (controllers.length > 0) { + host.removeController(controllers[0]!); + } + }, + }; +} + +describe("useStack (lit)", () => { + it("returns a controller whose current mirrors the stack and updates on open", () => { + const client = new LayerClient(); + const { host } = createHost(); + const stack = useStack(host, { stack: "confirm", client }); + expect(stack.current).toHaveLength(0); + client.open({ key: ["a"], payload: 1, stack: "confirm" }); + expect(stack.current).toHaveLength(1); + }); + + it("with a select returns the selected slice", () => { + const client = new LayerClient(); + const { host } = createHost(); + const stack = useStack(host, { + stack: "confirm", + select: (states) => states.length, + client, + }); + expect(stack.current).toBe(0); + client.open({ key: ["a"], payload: 1, stack: "confirm" }); + expect(stack.current).toBe(1); + }); + + it("cleans up the subscription on host disconnect", () => { + const client = new LayerClient(); + const { host, disconnect } = createHost(); + useStack(host, { stack: "confirm", client }); + expect(client.getStack("confirm").size).toBe(1); + disconnect(); + expect(client.getStack("confirm").size).toBe(0); + }); + + it("throws when no client is provided", () => { + const { host } = createHost(); + expect(() => useStack(host, { stack: "confirm" })).toThrow("[layers/lit]"); + }); +}); + +describe("StackController bindClient (lit)", () => { + it("with deferClient stays empty until bindClient, then mirrors the stack", () => { + const client = new LayerClient(); + const { host } = createHost(); + const stack = new StackController( + host, + { stack: "confirm" }, + undefined, + false, + true, + ); + expect(stack.current).toHaveLength(0); + stack.bindClient(client); + client.open({ key: ["a"], payload: 1, stack: "confirm" }); + expect(stack.current).toHaveLength(1); + }); + + it("bindClient after connect subscribes without a separate context resolve", () => { + const client = new LayerClient(); + const { host } = createHost(); + const stack = new StackController( + host, + { stack: "confirm", select: (s) => s.length }, + undefined, + false, + true, + ); + stack.bindClient(client); + client.open({ key: ["a"], payload: 1, stack: "confirm" }); + expect(stack.current).toBe(1); + }); +}); + +describe("useQueuedStack (lit)", () => { + it("mirrors the queued snapshot for a serial stack", () => { + const client = new LayerClient({ + defaultStackOptions: { + default: { scope: { strategy: "serial" } }, + }, + }); + const { host } = createHost(); + const queued = useQueuedStack(host, { + stack: "default", + select: (s) => s.length, + client, + }); + expect(queued.current).toBe(0); + void client.open({ key: ["a"], payload: 1 }); + void client.open({ key: ["b"], payload: 2 }); + expect(queued.current).toBe(1); + }); +}); + +describe("useLayerState (lit)", () => { + it("filters mounted states by key", () => { + const client = new LayerClient(); + const { host } = createHost(); + const state = useLayerState( + host, + { + key: ["dup"], + select: (states) => states.map((s) => (s.payload as { n: number }).n), + }, + client, + ); + expect(state.current).toEqual([]); + void client.open({ key: ["dup"], payload: { n: 1 } }); + void client.open({ key: ["other"], payload: { n: 2 } }); + expect(state.current).toEqual([1]); + }); + + it("useLayerQueuedState filters queued states by key", () => { + const client = new LayerClient({ + defaultStackOptions: { + default: { scope: { strategy: "serial" } }, + }, + }); + const { host } = createHost(); + const queued = useLayerQueuedState( + host, + { key: ["b"], select: (s) => s.length }, + client, + ); + void client.open({ key: ["a"], payload: 1 }); + void client.open({ key: ["b"], payload: 2 }); + expect(queued.current).toBe(1); + }); +}); + +const toastOptions = layerOptions<{ msg: string }, void>({ + stack: "default", + key: ["toast"], + component: undefined, + exitingDelay: 0, +}); + +describe("useLayer (lit)", () => { + it("wires open/dismiss/update/state/queued/top/current", async () => { + const client = new LayerClient(); + const harness = createHost(); + const handle = useLayer(harness.host, toastOptions, client); + expect(handle.state.current).toHaveLength(0); + expect(handle.top).toBeNull(); + expect(handle.current).toBeNull(); + + void handle.open({ msg: "hello" }); + expect(handle.state.current).toHaveLength(1); + expect(handle.top?.payload.msg).toBe("hello"); + expect(handle.current).not.toBeNull(); + expect(handle.client).toBe(client); + + handle.update({ msg: "updated" }); + expect(handle.top?.payload.msg).toBe("updated"); + + await handle.dismiss(undefined as void); + await Promise.resolve(); + expect(handle.state.current).toHaveLength(0); + expect(handle.current).toBeNull(); + }); + + it("resolves with the response on dismiss", async () => { + const client = new LayerClient(); + const { host } = createHost(); + const handle = useLayer( + host, + layerOptions<{ n: number }, boolean>({ key: ["dup"], exitingDelay: 0 }), + client, + ); + const pending = handle.open({ n: 1 }); + await handle.dismiss(true); + expect(await pending).toBe(true); + }); + + it("cancelQueued resolves a queued layer", async () => { + const client = new LayerClient({ + defaultStackOptions: { + default: { scope: { strategy: "serial" } }, + }, + }); + const { host } = createHost(); + const a = useLayer( + host, + layerOptions<{ n: number }, boolean>({ key: ["a"], exitingDelay: 0 }), + client, + ); + const b = useLayer( + host, + layerOptions<{ n: number }, boolean>({ key: ["b"], exitingDelay: 0 }), + client, + ); + void a.open({ n: 1 }); + const pending = b.open({ n: 2 }); + expect(b.queued.current).toHaveLength(1); + expect(b.cancelQueued(false)).toBe(true); + expect(await pending).toBe(false); + await a.dismiss(true); + }); + + it("throws without a client when context can't resolve (non-element host)", () => { + const { host } = createHost(); + expect(() => + useLayer(host, toastOptions, undefined as unknown as LayerClient), + ).toThrow("[layers/lit]"); + }); + + it("accepts a trailing client argument", async () => { + const client = new LayerClient(); + const { host } = createHost(); + const handle = useLayer( + host, + layerOptions<{ msg: string }, void>({ + key: ["toast-trail"], + exitingDelay: 0, + }), + client, + ); + void handle.open({ msg: "hi" }); + expect(handle.state.current).toHaveLength(1); + expect(handle.client).toBe(client); + await handle.dismiss(undefined as void); + }); + + it("opens a void-payload layer without an argument", async () => { + const client = new LayerClient(); + const { host } = createHost(); + const handle = useLayer( + host, + layerOptions({ key: ["void-layer"], exitingDelay: 0 }), + client, + ); + void handle.open(); + expect(handle.state.current).toHaveLength(1); + await handle.dismiss(undefined as void); + }); + + it("upsert updates the active same-key layer", async () => { + const client = new LayerClient(); + const { host } = createHost(); + const handle = useLayer(host, toastOptions, client); + void handle.open({ msg: "a" }); + void handle.upsert({ msg: "b" }); + expect(handle.state.current).toHaveLength(1); + expect(handle.top?.payload.msg).toBe("b"); + await handle.dismiss(undefined as void); + }); + + it("dismisses one of two same-key layers by id", async () => { + const client = new LayerClient(); + const { host } = createHost(); + const a = useLayer(host, toastOptions, client); + const b = useLayer(host, toastOptions, client); + void a.open({ msg: "a" }); + void b.open({ msg: "b" }); + expect(a.state.current).toHaveLength(2); + const id = a.current?.id; + expect(id).toBeDefined(); + await a.dismiss(undefined as void, { id }); + await Promise.resolve(); + expect(a.state.current).toHaveLength(1); + expect(b.top?.payload.msg).toBe("b"); + await b.dismiss(undefined as void); + }); + + it("validated handle stores parsed output in state", async () => { + const idSchema = { + "~standard": { + version: 1 as const, + vendor: "test", + validate: (v: unknown) => ({ + value: { id: Number((v as { id: string }).id) }, + }), + types: undefined as unknown as { + input: { id: string }; + output: { id: number }; + }, + }, + }; + const client = new LayerClient(); + const { host } = createHost(); + const handle = useLayer( + host, + { + stack: "default", + key: ["v"], + validate: idSchema, + component: undefined, + exitingDelay: 0, + }, + client, + ); + void handle.open({ id: "42" }); + expect(handle.state.current[0]?.payload).toEqual({ id: 42 }); + await handle.dismiss(undefined as void); + }); +}); + +function makeCall( + client: LayerClient, + stack = "default", +): LayerCallContext { + const stk = client.getStack(stack); + const opts = layerOptions({ + stack, + key: ["__callProbe"], + component: undefined, + exitingDelay: 0, + }); + void client.open({ ...opts, payload: undefined }).catch(() => {}); + const state = stk.getSnapshot().at(-1)!; + const layer = stk.getLayer(state.id)!; + return createCallContext( + stk as never, + layer as never, + state as never, + undefined, + ) as LayerCallContext; +} + +describe("useMutationFlow (lit)", () => { + it("drives pending and ends the layer on success", async () => { + const client = new LayerClient(); + const { host } = createHost(); + const call = makeCall(client); + const flow = useMutationFlow(host, call); + expect(flow.pending).toBe(false); + let resolved = false; + void flow + .run(async () => { + await Promise.resolve(); + }) + .orEnd("ok"); + // pending flips true synchronously, then the layer ends + expect(flow.pending).toBe(true); + await Promise.resolve(); + await Promise.resolve(); + resolved = true; + expect(resolved).toBe(true); + }); + + it("MutationFlowController requests host update on pending change", () => { + const client = new LayerClient(); + const harness = createHost(); + const call = makeCall(client); + const flow = new MutationFlowController(harness.host, call); + const before = harness.updateCount; + void flow.run(() => {}).orEnd(undefined); + expect(harness.updateCount).toBeGreaterThan(before); + }); +}); + +describe("useLayerGroup (lit)", () => { + it("opens a child layer on the child stack and drains on dispose", async () => { + const client = new LayerClient(); + const harness = createHost(); + const call = makeCall(client); + const group = useLayerGroup( + harness.host, + call, + undefined, + client, + ); + const stackId = group.stackId; + expect(stackId).toContain("~"); + + const childOpts = layerOptions<{ label: string }, string>({ + key: ["child"], + component: undefined, + exitingDelay: 0, + }); + const pending: Promise = group.open({ + ...childOpts, + payload: { label: "c" }, + }) as Promise; + expect(client.getStack(stackId).getSnapshot()).toHaveLength(1); + + const childState = client.getStack(stackId).getSnapshot()[0]!; + client + .getStack(stackId) + .dismiss( + client.getStack(stackId).getLayer(childState.id)!, + "done" as never, + ); + expect(await pending).toBe("done"); + + // disconnecting the group drains the child stack (layers dismissed) + harness.disconnect(); + expect(client.getStack(stackId).getSnapshot()).toHaveLength(0); + }); + + it("outlet() returns a TemplateResult-shaped object", () => { + const client = new LayerClient(); + const { host } = createHost(); + const call = makeCall(client); + const group = useLayerGroup( + host, + call, + undefined, + client, + ); + const result = group.outlet(); + // lit TemplateResult carries an `_$litType$` symbol field + expect(result).toBeTypeOf("object"); + expect( + (result as unknown as Record)[ + Symbol.for("lit.litType") + ] ?? + (result as unknown as Record)["_$litType$"], + ).toBeDefined(); + }); + + it("throws without a client when context can't resolve (non-element host)", () => { + const client = new LayerClient(); + const { host } = createHost(); + const call = makeCall(client); + expect(() => + useLayerGroup(host, call, undefined, undefined), + ).toThrow("[layers/lit] useLayerGroup"); + }); +}); + +describe("defineStackElements (lit)", () => { + it("no-ops without a customElements registry (SSR/bun)", () => { + expect(() => defineStackElements()).not.toThrow(); + }); +}); diff --git a/packages/lit/src/index.ts b/packages/lit/src/index.ts new file mode 100644 index 0000000..810a7ae --- /dev/null +++ b/packages/lit/src/index.ts @@ -0,0 +1,1639 @@ +import { ContextConsumer, ContextProvider, createContext } from "@lit/context"; +import type { Context } from "@lit/context"; +import type { + DataTag, + DefaultLayerError, + ErrorOf, + InferValidatorOutput, + LayerCallContext, + LayerComponentProps, + LayerGroupOptions, + LayerHandle, + LayerKey, + LayerOptions, + LayerStack, + LayerState, + OmitKeyof, + OpenLayerOptions, + ResponseOf, + ValidatedLayerHandle, + Validator, +} from "@stainless-code/layers"; +import { + childStackId, + createCallContext, + createLayer, + createLayerGroup, + keySignature, + shallowArrayEqual, + LayerClient, +} from "@stainless-code/layers"; +import { LitElement, nothing } from "lit"; +import type { + PropertyValues, + ReactiveController, + ReactiveControllerHost, + TemplateResult, +} from "lit"; +import { html } from "lit"; +import { directive } from "lit/directive.js"; +import { Directive } from "lit/directive.js"; +import { repeat } from "lit/directives/repeat.js"; + +export * from "@stainless-code/layers"; + +/** + * `@lit/context` key for the nearest {@link LayerClient}. + * Exported for advanced `ContextProvider` wiring; prefer + * {@link provideLayerClient} / {@link useLayerClient}. + */ +export const layerClientContext: Context = createContext< + LayerClient, + string +>("layers-client"); + +/** Lit host that can own reactive controllers and participate in `@lit/context`. */ +export type LitControllerHost = ReactiveControllerHost & HTMLElement; +type ClientConsumer = ContextConsumer< + Context, + LitControllerHost +>; + +function isElementHost( + host: ReactiveControllerHost, +): host is LitControllerHost { + return ( + typeof (host as Partial).addEventListener === "function" && + typeof (host as Partial).dispatchEvent === "function" + ); +} + +/** + * Provides a {@link LayerClient} to descendant consumers via `@lit/context`. + * + * Attaches a {@link ContextProvider} to `host`; descendants resolve it with + * {@link useLayerClient} or by omitting `client` on a hook. A new client is + * created when `client` is omitted. + * + * @param host Lit element host that owns the context provider. + * @param client Optional client to provide; a new {@link LayerClient} is created when omitted. + * @returns The provided client. + */ +export function provideLayerClient( + host: LitControllerHost, + client?: LayerClient, +): LayerClient { + const c = client ?? new LayerClient(); + new ContextProvider(host, { context: layerClientContext, initialValue: c }); + return c; +} + +/** + * Reactive controller that resolves the nearest {@link LayerClient} from + * `@lit/context` and exposes it via {@link LayerClientConsumer.current}. + * + * The value is undefined until the host connects under a provider; `.current` + * throws when accessed before a client is available. + */ +export class LayerClientConsumer implements ReactiveController { + #consumer: ClientConsumer; + #client: LayerClient | undefined; + + constructor(host: LitControllerHost) { + this.#consumer = new ContextConsumer(host, { + context: layerClientContext, + subscribe: true, + callback: (c: LayerClient) => { + this.#client = c; + }, + }); + host.addController(this); + } + + hostConnected(): void { + if (this.#consumer.value !== undefined) { + this.#client = this.#consumer.value as LayerClient; + } + } + + hostDisconnected(): void {} + + /** The resolved client; throws when no provider has supplied one yet. */ + get current(): LayerClient { + const c = this.#client ?? (this.#consumer.value as LayerClient | undefined); + if (!c) { + throw new Error( + "[layers/lit] No LayerClient in context — wrap your tree with provideLayerClient() / or pass `client` explicitly.", + ); + } + return c; + } +} + +/** + * Resolve the nearest {@link LayerClient} from `@lit/context`. + * + * Returns a {@link LayerClientConsumer} controller; read the client via + * `.current` (throws when no provider has supplied one yet). Prefer passing + * `client` explicitly on `useStack` / `useLayer` for synchronous access at + * construction time — context only resolves once the host is connected. + * + * @returns A {@link LayerClientConsumer} controller; read `.current` for the client. + */ +export function useLayerClient(host: LitControllerHost): LayerClientConsumer { + return new LayerClientConsumer(host); +} + +/** + * Lazy {@link LayerClient} resolver. Returns a handle whose `.get()` yields the + * client once it is supplied — synchronously when `client` is passed, or after + * the host connects under a `provideLayerClient()` ancestor via `@lit/context`. + * + * `onResolved` fires for the initial client and again when context pushes a new + * value (synchronously for an explicit client). Throws synchronously when no + * client is supplied and `host` is not an element host context can resolve on; + * `.get()` throws when accessed before context has supplied a client. + */ +function resolveClientLazy( + host: ReactiveControllerHost, + client: LayerClient | undefined, + onResolved?: (client: LayerClient) => void, +): { get(): LayerClient } { + if (client) { + onResolved?.(client); + return { get: () => client }; + } + if (!isElementHost(host)) { + throw new Error( + "[layers/lit] No LayerClient — pass `client` on the options bag, wrap your tree with provideLayerClient() / , or use a LitElement host so context can resolve.", + ); + } + let resolved: LayerClient | undefined; + new ContextConsumer(host, { + context: layerClientContext, + subscribe: true, + callback: (c: LayerClient) => { + resolved = c; + onResolved?.(c); + }, + }); + return { + get() { + if (!resolved) { + throw new Error( + "[layers/lit] LayerClient not resolved yet — access after the host connects under a provideLayerClient() ancestor, or pass `client` explicitly.", + ); + } + return resolved; + }, + }; +} + +function defaultSelector(states: LayerState[]): LayerState[] { + return states; +} + +type NoValidateOptions = Opts extends { validate: Validator } + ? never + : Opts; + +export interface UseStackOptions { + stack?: string; + select?: (states: LayerState[]) => T; + compare?: (a: T, b: T) => boolean; + client?: LayerClient; +} + +export interface UseLayerStateOptions< + Key extends LayerKey, + P = unknown, + D = unknown, + U = LayerState, ErrorOf, D>[], +> { + key: Key; + stack?: string; + select?: (states: LayerState, ErrorOf, D>[]) => U; + compare?: (a: U, b: U) => boolean; +} + +function createSnapshotSelector( + select: (states: LayerState[]) => T, + compare: (a: T, b: T) => boolean, +): { runSelect: (base: LayerState[]) => T } { + let cache: { base: LayerState[]; value: T } | null = null; + + const runSelect = (base: LayerState[]): T => { + 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; + }; + + return { runSelect }; +} + +/** + * Shared snapshot subscription primitive for stack controllers. + * + * Selector output is memoized against the stable snapshot reference so hosts + * do not churn object or array selections. + */ +function subscribeStackSnapshot( + host: ReactiveControllerHost, + stack: LayerStack, + getSource: () => LayerState[], + select: (states: LayerState[]) => T, + compare: (a: T, b: T) => boolean, +): { get value(): T; unsubscribe(): void } { + const { runSelect } = createSnapshotSelector(select, compare); + let current = runSelect(getSource()); + + const unsubscribe = stack.subscribe(() => { + const prev = current; + const next = runSelect(getSource()); + if (!compare(prev, next)) { + current = next; + host.requestUpdate(); + } + }); + + return { + get value() { + return current; + }, + unsubscribe, + }; +} + +const warnedMissingComponent = new Set(); + +function warnMissingLayerComponent(id: string, key: unknown): void { + if (process.env.NODE_ENV === "production") return; + const sig = `${id}:${JSON.stringify(key)}`; + if (warnedMissingComponent.has(sig)) return; + warnedMissingComponent.add(sig); + console.warn( + `[layers/lit] No component for layer ${id} (key ${JSON.stringify(key)}); StackOutlet renders nothing. Provide a \`component\` or use useStackHandles.`, + ); +} + +/** + * Reactive controller that mirrors a {@link LayerClient} stack snapshot. + * Prefer {@link useStack} / {@link useQueuedStack}; `queued` / `deferClient` / + * {@link bindClient} are for adapter internals (shared lazy context resolve). + */ +export class StackController implements ReactiveController { + #host: ReactiveControllerHost; + #snapshot: ReturnType> | null = null; + #getSource: (() => LayerState[]) | null = null; + #select: (states: LayerState[]) => T; + #compare: (a: T, b: T) => boolean; + #stack: LayerStack | null = null; + #client: LayerClient | undefined; + #stackId = "default"; + #initial: T; + #queued: boolean; + #connected = false; + + /** + * @param queued When `true`, observe {@link LayerStack.getQueuedSnapshot} + * instead of the mounted snapshot (`useQueuedStack`). + * @param deferClient When `true`, skip lazy context resolve so a sibling + * controller can call {@link bindClient} after one shared resolve. + */ + constructor( + host: ReactiveControllerHost, + options: UseStackOptions = {}, + client?: LayerClient, + queued = false, + deferClient = false, + ) { + this.#host = host; + this.#queued = queued; + this.#select = + options.select ?? + (defaultSelector as unknown as (states: LayerState[]) => T); + this.#compare = options.compare ?? Object.is; + // Sensible empty selection before a client resolves (lazy context path): + // `[]` for the default selector, `select([])` otherwise. + this.#initial = this.#select([]); + + this.#stackId = options.stack ?? "default"; + if (!deferClient) { + // Rebind on later context pushes (e.g. `.client` setValue). + resolveClientLazy(host, client ?? options.client, (c) => { + this.bindClient(c); + }); + } + host.addController(this); + } + + /** + * Bind a {@link LayerClient} when constructed with `deferClient` (internal: + * {@link LayerController} / {@link LayerGroupController} share one lazy + * context resolve across their stack controllers). + */ + bindClient(client: LayerClient): void { + if (this.#stack !== null) { + if (this.#client === client) return; + this.reconfigure({}, client); + return; + } + this.#initStack(client, this.#stackId); + if (this.#connected) { + this.#setup(); + } + } + + /** + * Tear down the current subscription, apply new options, and re-subscribe when + * the host is connected. + */ + reconfigure(options: UseStackOptions = {}, client?: LayerClient): void { + this.#snapshot?.unsubscribe(); + this.#snapshot = null; + this.#stack = null; + this.#getSource = null; + + if (options.select !== undefined) { + this.#select = options.select; + } + if (options.compare !== undefined) { + this.#compare = options.compare; + } + const stackId = options.stack ?? this.#stackId; + this.#stackId = stackId; + + const c = client ?? options.client ?? this.#client; + if (!c) { + this.#initial = this.#select([]); + this.#host.requestUpdate(); + return; + } + + this.#initStack(c, stackId); + if (this.#connected) { + this.#setup(); + } + this.#host.requestUpdate(); + } + + #initStack(client: LayerClient, stackId: string): void { + this.#client = client; + this.#stackId = stackId; + this.#stack = client.getStack(stackId); + this.#getSource = this.#queued + ? () => this.#stack!.getQueuedSnapshot() + : () => this.#stack!.getSnapshot(); + const { runSelect } = createSnapshotSelector(this.#select, this.#compare); + this.#initial = runSelect(this.#getSource()); + } + + #setup(): void { + if (this.#stack === null || this.#getSource === null) return; + this.#snapshot = subscribeStackSnapshot( + this.#host, + this.#stack, + this.#getSource, + this.#select, + this.#compare, + ); + const synced = this.#snapshot.value; + if (!this.#compare(this.#initial, synced)) { + this.#initial = synced; + this.#host.requestUpdate(); + } + } + + hostConnected(): void { + this.#connected = true; + if (this.#stack !== null) { + this.#setup(); + } + } + + hostDisconnected(): void { + this.#connected = false; + this.#snapshot?.unsubscribe(); + this.#snapshot = null; + } + + /** Selected stack value; updates when the stack publishes a new snapshot. */ + get current(): T { + return this.#snapshot?.value ?? this.#initial; + } +} + +/** + * Subscribe a Lit host to a {@link LayerClient} stack via {@link StackController}. + * + * @param host Reactive controller host (typically `this` on a `LitElement`). + * @param opts `stack`, `select`, `compare`, and optional `client`. + * @param client Client to observe when not passed on `opts`. + * @returns A {@link StackController} whose `.current` mirrors the selected snapshot. + * @default opts.stack `"default"` + * @default opts.select all mounted states (identity) + * @default opts.compare `Object.is` + */ +export function useStack( + host: ReactiveControllerHost, + opts: UseStackOptions = {}, + client?: LayerClient, +): StackController { + return new StackController(host, opts, client, false); +} + +/** + * Subscribe a Lit host to a stack's queued snapshot via {@link StackController}. + * + * @param host Reactive controller host (typically `this` on a `LitElement`). + * @param opts `stack`, `select`, `compare`, and optional `client`. + * @param client Client to observe when not passed on `opts`. + * @returns A {@link StackController} whose `.current` mirrors the queued snapshot. + */ +export function useQueuedStack( + host: ReactiveControllerHost, + opts: UseStackOptions = {}, + client?: LayerClient, +): StackController { + return new StackController(host, opts, client, true); +} + +/** + * Observe all mounted layers matching a key. + * + * A {@link DataTag} key infers its response and error types. + */ +export function useLayerState< + Key extends LayerKey, + P = unknown, + D = unknown, + U = LayerState, ErrorOf, D>[], +>( + host: ReactiveControllerHost, + opts: UseLayerStateOptions, + client?: LayerClient, +): StackController { + const sig = keySignature(opts.key); + return useStack(host, { + stack: opts.stack, + select: (states) => { + const filtered = states.filter( + (s) => keySignature(s.key) === sig, + ) as LayerState, ErrorOf, D>[]; + return opts.select ? opts.select(filtered) : (filtered as unknown as U); + }, + compare: opts.compare ?? (shallowArrayEqual as (a: U, b: U) => boolean), + client, + }); +} + +/** Observe all queued layers matching a key. */ +export function useLayerQueuedState< + Key extends LayerKey, + P = unknown, + D = unknown, + U = LayerState, ErrorOf, D>[], +>( + host: ReactiveControllerHost, + opts: UseLayerStateOptions, + client?: LayerClient, +): StackController { + const sig = keySignature(opts.key); + return useQueuedStack(host, { + stack: opts.stack, + select: (states) => { + const filtered = states.filter( + (s) => keySignature(s.key) === sig, + ) as LayerState, ErrorOf, D>[]; + return opts.select ? opts.select(filtered) : (filtered as unknown as U); + }, + compare: opts.compare ?? (shallowArrayEqual as (a: U, b: U) => boolean), + client, + }); +} + +export type WiredLayerHandle< + P, + R, + E = DefaultLayerError, + D = unknown, + RP = unknown, +> = LayerHandle & { + state: StackController[]>; + queued: StackController[]>; + top: LayerState | null; +}; + +export type WiredValidatedLayerHandle< + V extends Validator, + R, + E = DefaultLayerError, + D = unknown, + RP = unknown, +> = ValidatedLayerHandle & { + state: StackController, R, E, D>[]>; + queued: StackController, R, E, D>[]>; + top: LayerState, R, E, D> | null; +}; + +/** + * Reactive controller wiring `createLayer` with reactive `state` / `queued` / + * `top` for a single layer key. + */ +export class LayerController< + P, + R, + E = DefaultLayerError, + D = unknown, + RP = unknown, +> implements ReactiveController { + #options: LayerOptions & { key: LayerKey }; + #state: StackController[]>; + #queued: StackController[]>; + #client: { get(): LayerClient }; + #handle: LayerHandle | null = null; + + constructor( + host: ReactiveControllerHost, + options: LayerOptions & { key: LayerKey }, + client?: LayerClient, + ) { + this.#options = options; + const stackId = options.stack ?? "default"; + const sig = keySignature(options.key); + const selectByKey = (states: LayerState[]) => + states.filter((s) => keySignature(s.key) === sig) as LayerState< + P, + R, + E, + D + >[]; + const deferClient = !client; + this.#state = new StackController[]>( + host, + { stack: stackId, select: selectByKey, compare: shallowArrayEqual }, + client, + false, + deferClient, + ); + this.#queued = new StackController[]>( + host, + { stack: stackId, select: selectByKey, compare: shallowArrayEqual }, + client, + true, + deferClient, + ); + // `createLayer` needs a client synchronously; defer it until the client is + // resolved (explicit, or from context after the host connects). The handle + // is built lazily on first method access or in `hostConnected`. + this.#client = resolveClientLazy(host, client, (c) => { + this.#state.bindClient(c); + this.#queued.bindClient(c); + // Drop a handle built against a previous client so the next access rebuilds. + this.#handle = null; + }); + host.addController(this); + } + + /** Lazily build the wired handle once the client is available. */ + #getHandle(): LayerHandle { + if (this.#handle === null) { + this.#handle = createLayer(this.#options, this.#client.get()); + } + return this.#handle; + } + + hostConnected(): void { + // Eagerly build the handle if context already resolved; otherwise defer to + // first method access (`.open` / `.current` throw until then). + try { + this.#getHandle(); + } catch { + /* client not resolved yet — built on first access */ + } + } + hostDisconnected(): void {} + + get open(): LayerHandle["open"] { + return this.#getHandle().open; + } + get upsert(): LayerHandle["upsert"] { + return this.#getHandle().upsert; + } + get dismiss(): LayerHandle["dismiss"] { + return this.#getHandle().dismiss; + } + get update(): LayerHandle["update"] { + return this.#getHandle().update; + } + get cancelQueued(): LayerHandle["cancelQueued"] { + return this.#getHandle().cancelQueued; + } + get client(): LayerHandle["client"] { + return this.#getHandle().client; + } + get stack(): LayerHandle["stack"] { + return this.#getHandle().stack; + } + get options(): LayerHandle["options"] { + return this.#getHandle().options; + } + get current(): LayerHandle["current"] { + return this.#getHandle().current; + } + get state(): StackController[]> { + return this.#state; + } + get queued(): StackController[]> { + return this.#queued; + } + get top(): LayerState | null { + return this.#state.current.at(-1) ?? null; + } +} + +/** Wired handle: `createLayer` + reactive `state`/`queued`/`top`. */ +export function useLayer< + V extends Validator, + R, + E = DefaultLayerError, + D = unknown, + RP = unknown, +>( + host: ReactiveControllerHost, + options: LayerOptions, R, E, D, RP> & { + key: LayerKey; + validate: V; + }, + client?: LayerClient, +): WiredValidatedLayerHandle; + +export function useLayer< + P, + R, + E = DefaultLayerError, + D = unknown, + RP = unknown, +>( + host: ReactiveControllerHost, + options: NoValidateOptions & { key: LayerKey }>, + client?: LayerClient, +): WiredLayerHandle; + +export function useLayer< + P, + R, + E = DefaultLayerError, + D = unknown, + RP = unknown, +>( + host: ReactiveControllerHost, + options: LayerOptions & { key: LayerKey }, + client?: LayerClient, +): WiredLayerHandle { + return new LayerController( + host, + options, + client, + ) as unknown as WiredLayerHandle; +} + +export interface StackHandles { + states: StackController; + getCall: (state: LayerState) => LayerCallContext; +} + +/** + * Reactive controller exposing the states and call contexts needed to render + * a stack headlessly (without `StackOutlet`). + */ +export class StackHandlesController implements ReactiveController { + #states: StackController; + #stack: LayerStack | null = null; + #rootProps: unknown; + #stackId: string; + + constructor( + host: ReactiveControllerHost, + stack = "default", + rootProps?: unknown, + client?: LayerClient, + ) { + this.#stackId = stack; + this.#rootProps = rootProps; + this.#states = new StackController(host, { stack }, client, false, !client); + if (client) { + this.#initStack(client); + } else if (isElementHost(host)) { + resolveClientLazy(host, undefined, (c) => { + this.#states.bindClient(c); + this.#initStack(c); + }); + } else { + throw new Error( + "[layers/lit] useStackHandles needs an explicit `client` or a LitElement host so context can resolve.", + ); + } + host.addController(this); + } + + #initStack(client: LayerClient): void { + this.#stack = client.getStack(this.#stackId); + } + + hostConnected(): void {} + hostDisconnected(): void {} + + get states() { + return this.#states; + } + + getCall = (state: LayerState): LayerCallContext => { + if (this.#stack === null) { + throw new Error( + "[layers/lit] useStackHandles: LayerClient not resolved yet — access getCall after the host connects.", + ); + } + return createCallContext( + this.#stack, + this.#stack.getLayer(state.id)!, + state, + this.#rootProps, + ) as LayerCallContext; + }; +} + +/** Return the states and call contexts needed to render a stack headlessly. */ +export function useStackHandles( + host: ReactiveControllerHost, + stack = "default", + rootProps?: unknown, + client?: LayerClient, +): StackHandlesController { + return new StackHandlesController(host, stack, rootProps, client); +} + +export interface MutationRun { + /** On success, end the layer with `response`; on failure, leave it open and rethrow. */ + orEnd: (response: R) => Promise; +} + +export interface MutationFlow { + /** True while a `run(...)` async action is in flight. Mirrors the layer's `actionStatus: "running"`. */ + pending: boolean; + run: (fn: () => Promise | void) => MutationRun; +} + +/** + * Reactive controller coordinating a layer's pending state with an async + * mutation and ending it on success. + */ +export class MutationFlowController implements ReactiveController { + #host: ReactiveControllerHost; + #call: LayerCallContext; + #pending = false; + + constructor( + host: ReactiveControllerHost, + call: LayerCallContext, + ) { + this.#host = host; + this.#call = call; + host.addController(this); + } + + hostConnected(): void {} + hostDisconnected(): void {} + + get pending(): boolean { + return this.#pending; + } + + run = (fn: () => Promise | void): MutationRun => ({ + orEnd: async (response: R) => { + this.#pending = true; + this.#host.requestUpdate(); + this.#call.setRunning(true); + try { + await fn(); + this.#call.end(response); + } finally { + this.#call.setRunning(false); + this.#pending = false; + this.#host.requestUpdate(); + } + }, + }); +} + +/** + * Coordinate a layer's pending state with an async mutation and end it on success. + * + * @example + * ```ts + * @customElement("confirm-dialog") + * class ConfirmDialog extends LitElement { + * @property({ attribute: false }) call!: LayerCallContext; + * #flow = new MutationFlowController(this, this.call); + * render() { + * return html``; + * } + * } + * ``` + */ +export function useMutationFlow( + host: ReactiveControllerHost, + call: LayerCallContext, +): MutationFlowController { + return new MutationFlowController(host, call as LayerCallContext); +} + +/** Open a layer on a pre-bound stack, with {@link DataTag} response and error inference. */ +export interface ScopedOpen { + ( + options: OmitKeyof< + OpenLayerOptions & { + key: DataTag; + }, + "stack" | "validate" + >, + ): Promise; + ( + options: OmitKeyof, "stack">, + ): Promise; +} + +export interface LayerGroup { + open: ScopedOpen; + dismissAll: (response?: unknown) => void; + states: StackController; + /** Renders the child stack inline — place inside the parent layer's DOM. */ + outlet: (rootProps?: unknown) => TemplateResult; + stackId: string; +} + +/** + * Reactive controller for a child stack scoped to the calling layer's lifetime. + * + * The child stack is disposed and dismissed when its parent layer unmounts. + * {@link LayerGroupController.outlet} returns a `TemplateResult` that renders the + * child stack inline (router `Routes.outlet()`-shaped) — share the render helper + * with `StackOutlet`. + */ +export class LayerGroupController< + P, + R, + RootProps = unknown, +> implements ReactiveController { + #host: ReactiveControllerHost; + #client: LayerClient | undefined; + #group: ReturnType | null = null; + #states: StackController; + #stackId: string; + #rootProps: RootProps; + #call: LayerCallContext; + #options: LayerGroupOptions | undefined; + + constructor( + host: ReactiveControllerHost, + call: LayerCallContext, + options?: LayerGroupOptions, + client?: LayerClient, + ) { + this.#host = host; + this.#call = call; + this.#options = options; + this.#rootProps = call.root; + this.#stackId = childStackId(call, options?.name); + + // Same shared-resolve shape as LayerController: one lazy context consumer, + // then bindClient + requestUpdate so outlet() does not depend on #states + // coincidentally re-rendering the host. + const deferClient = !client; + this.#states = new StackController( + host, + { stack: this.#stackId, client }, + client, + false, + deferClient, + ); + + if (client) { + this.#init(client); + } else if (isElementHost(host)) { + resolveClientLazy(host, undefined, (c) => { + if (this.#group === null) { + this.#init(c); + } else if (this.#client !== c) { + this.#group.dispose(); + this.#client?.dismissAll(this.#stackId); + this.#init(c); + } + this.#states.bindClient(c); + this.#host.requestUpdate(); + }); + } else { + throw new Error( + "[layers/lit] useLayerGroup needs an explicit `client` or a LitElement host so context can resolve.", + ); + } + host.addController(this); + } + + #init(client: LayerClient): void { + this.#client = client; + this.#group = createLayerGroup(client, this.#call, this.#options); + } + + hostConnected(): void {} + hostDisconnected(): void { + this.#group?.dispose(); + this.#client?.dismissAll(this.#stackId); + } + + get stackId(): string { + return this.#stackId; + } + + get states(): StackController { + return this.#states; + } + + open = (( + opts: OmitKeyof, "stack">, + ) => { + if (!this.#client) { + throw new Error( + "[layers/lit] useLayerGroup: LayerClient not resolved yet — call open() after the host connects.", + ); + } + return this.#client.open({ + ...opts, + stack: this.#stackId, + } as OpenLayerOptions); + }) as unknown as ScopedOpen; + + dismissAll = (response?: unknown): void => { + this.#client?.dismissAll(this.#stackId, response); + }; + + outlet = (rootProps?: unknown): TemplateResult => { + if (!this.#client) return html``; + return renderStack( + this.#client, + this.#stackId, + rootProps ?? this.#rootProps, + ); + }; +} + +/** + * Create a child stack scoped to the calling layer's lifetime. + * + * The child stack is disposed and dismissed when its parent layer unmounts. + */ +export function useLayerGroup( + host: ReactiveControllerHost, + call: LayerCallContext, + options?: LayerGroupOptions, + client?: LayerClient, +): LayerGroup { + const controller = new LayerGroupController(host, call, options, client); + return { + open: controller.open, + dismissAll: controller.dismissAll, + states: controller.states, + outlet: controller.outlet, + stackId: controller.stackId, + }; +} + +/** A layer component is either a `LitElement` constructor or a render function. */ +export type LitLayerComponent = + | (new () => LitElement) + | ((props: LayerComponentProps) => TemplateResult); + +function isLitElementCtor(c: unknown): c is new () => LitElement { + return typeof c === "function" && c.prototype instanceof LitElement; +} + +interface LayerElementProps { + call: LayerCallContext; + payload: unknown; + data: unknown; + error: unknown; + phase: unknown; + transition: unknown; + dismissing: unknown; + actionStatus: unknown; +} + +/** + * Directive that instantiates a `LitElement` layer component once and updates + * its reactive properties in place across renders. Pair with id-keyed `repeat` + * so each layer keeps its element instance (no remount on prop churn). + * + * Lit 3 forbids dynamic tag bindings (`html`<${Ctor}>``), so we construct the + * element imperatively and render the node. + */ +class LayerElementDirective extends Directive { + #el: LitElement | null = null; + + render(ctor: new () => LitElement, props: LayerElementProps): LitElement { + if (this.#el === null) { + this.#el = new ctor(); + } + const el = this.#el as LitElement & Record; + el.call = props.call; + el.payload = props.payload; + el.data = props.data; + el.error = props.error; + el.phase = props.phase; + el.transition = props.transition; + el.dismissing = props.dismissing; + el.actionStatus = props.actionStatus; + return this.#el; + } +} + +const layerElement = directive(LayerElementDirective); + +function renderLayer( + component: LitLayerComponent, + call: LayerCallContext, + state: LayerState, +): TemplateResult | typeof nothing { + if (isLitElementCtor(component)) { + return html`${layerElement(component, { + call, + payload: state.payload, + data: state.data, + error: state.error, + phase: state.phase, + transition: state.transition, + dismissing: state.dismissing, + actionStatus: state.actionStatus, + })}`; + } + return (component as (props: LayerComponentProps) => TemplateResult)({ + call: call as never, + payload: state.payload as never, + data: state.data as never, + error: state.error as never, + phase: state.phase, + transition: state.transition, + dismissing: state.dismissing, + actionStatus: state.actionStatus, + }); +} + +/** + * Render every active layer in a stack with its registered component. + * + * Shared by {@link StackOutlet} and {@link LayerGroupController.outlet}. Keys by + * `state.id` so prop changes update in place without recreating instances. + */ +function renderStack( + client: LayerClient, + stackId: string, + rootProps?: unknown, +): TemplateResult { + const stack = client.getStack(stackId); + const states = stack.getSnapshot(); + return html`${repeat( + states, + (s) => s.id, + (s) => { + const layer = stack.getLayer(s.id); + const component = layer?.component as LitLayerComponent | undefined; + if (!layer || !component) { + warnMissingLayerComponent(s.id, s.key); + return nothing; + } + const call = createCallContext( + stack, + layer, + s, + rootProps, + ) as LayerCallContext; + return renderLayer(component, call, s); + }, + )}`; +} + +/** + * `` — provides a {@link LayerClient} via `@lit/context`. + * Shadow root + ``; composed `context-request` still reaches light children. + * + * Register with {@link defineStackElements}. Omitting `.client` creates one. + */ +export class StackProvider extends LitElement { + static properties = { + client: { attribute: false }, + }; + declare client: LayerClient | undefined; + #provider: ContextProvider, this> | null = null; + + constructor() { + super(); + this.client = undefined; + } + + connectedCallback(): void { + super.connectedCallback(); + if (this.#provider === null) { + const c = this.client ?? new LayerClient(); + this.#provider = new ContextProvider(this, { + context: layerClientContext, + initialValue: c, + }); + } + } + + updated(changed: PropertyValues): void { + // Only react to an explicit `.client` assignment — do not replace the + // auto-created client when `client` is still undefined. + if ( + !changed.has("client") || + this.#provider === null || + this.client === undefined + ) { + return; + } + this.#provider.setValue(this.client); + } + + render(): TemplateResult { + return html``; + } +} + +/** + * `` — renders every active layer in a stack with its registered + * component. Light DOM (`createRenderRoot()` returns `this`) so overlays stack + * inline where mounted. Id-keyed `repeat` keeps instances stable across updates. + */ +export class StackOutlet extends LitElement { + static properties = { + stack: { type: String }, + rootProps: { attribute: false }, + client: { attribute: false }, + }; + declare stack: string; + declare rootProps: unknown; + declare client: LayerClient | undefined; + #states: StackController | null = null; + #stack: LayerStack | null = null; + #clientRef: LayerClient | undefined; + + constructor() { + super(); + this.stack = "default"; + this.rootProps = undefined; + this.client = undefined; + } + + createRenderRoot(): this { + return this; + } + + #init(client: LayerClient): void { + if (this.#states === null) { + this.#clientRef = client; + this.#stack = client.getStack(this.stack); + this.#states = new StackController( + this, + { stack: this.stack, client }, + undefined, + false, + ); + this.requestUpdate(); + return; + } + // Context provider may push a new client via setValue — rebind. + if (client === this.#clientRef) return; + this.#clientRef = client; + this.#states.reconfigure({ stack: this.stack }, client); + this.#stack = client.getStack(this.stack); + this.requestUpdate(); + } + + updated(changed: PropertyValues): void { + if (this.#stack === null || this.#states === null) return; + + if ( + changed.has("client") && + this.client && + this.client !== this.#clientRef + ) { + this.#clientRef = this.client; + this.#states.reconfigure({ stack: this.stack }, this.client); + this.#stack = this.client.getStack(this.stack); + this.requestUpdate(); + return; + } + + if (changed.has("stack") && this.#clientRef) { + this.#states.reconfigure({ stack: this.stack }, this.#clientRef); + this.#stack = this.#clientRef.getStack(this.stack); + this.requestUpdate(); + } + } + + connectedCallback(): void { + super.connectedCallback(); + if (this.client) { + this.#init(this.client); + } else if (this.#stack === null) { + new ContextConsumer(this, { + context: layerClientContext, + subscribe: true, + callback: (c: LayerClient) => this.#init(c), + }); + } + } + + render(): TemplateResult | typeof nothing { + const stack = this.#stack; + const states = this.#states; + if (!stack || !states) return nothing; + return html`${repeat( + states.current, + (s) => s.id, + (s) => { + const layer = stack.getLayer(s.id); + const component = layer?.component as LitLayerComponent | undefined; + if (!layer || !component) { + warnMissingLayerComponent(s.id, s.key); + return nothing; + } + const call = createCallContext( + stack, + layer, + s, + this.rootProps, + ) as LayerCallContext; + return renderLayer(component, call, s); + }, + )}`; + } +} + +/** + * `` — renders a selected stack value through a `.renderer` + * callback (virtualizer `.renderItem`-shaped). Set `.stack`, `.selector`, and + * `.renderer` (a `(value) => TemplateResult`). + */ +export class StackSubscribe extends LitElement { + static properties = { + stack: { type: String }, + selector: { attribute: false }, + renderer: { attribute: false }, + }; + declare stack: string; + declare selector: (states: LayerState[]) => unknown; + declare renderer: (value: unknown) => TemplateResult; + #controller: StackController | null = null; + + constructor() { + super(); + this.stack = "default"; + this.selector = (s: LayerState[]) => s; + this.renderer = () => html``; + } + + createRenderRoot(): this { + return this; + } + + connectedCallback(): void { + super.connectedCallback(); + if (this.#controller === null) { + this.#controller = useStack(this, { + stack: this.stack, + select: this.selector, + }); + } + } + + updated(changed: PropertyValues): void { + if (this.#controller === null) return; + if (changed.has("stack") || changed.has("selector")) { + this.#controller.reconfigure({ + stack: this.stack, + select: this.selector, + }); + } + } + + render(): TemplateResult { + const value = this.#controller?.current; + return this.renderer(value); + } +} + +export interface AppStack { + open: ScopedOpen; + dismissAll: (response?: unknown) => void; + states: StackController; +} + +/** + * Reactive controller exposing `open` / `dismissAll` / `states` bound to one + * stack. Returned by {@link createStackHook} as `useAppStack`. + */ +export class AppStackController implements ReactiveController { + #client: LayerClient | undefined; + #stackId: string; + #states: StackController; + + constructor( + host: ReactiveControllerHost, + client: LayerClient | undefined, + stackId: string, + ) { + this.#stackId = stackId; + // Pass an explicit client into `#states` so it does not open a second + // context consumer that can diverge from `open` / `dismissAll`. + this.#states = new StackController( + host, + { stack: stackId }, + client, + false, + !client, + ); + if (client) { + this.#client = client; + } else if (isElementHost(host)) { + resolveClientLazy(host, undefined, (c) => { + this.#client = c; + this.#states.bindClient(c); + }); + } else { + throw new Error( + "[layers/lit] useAppStack needs an explicit `client` or a LitElement host so context can resolve.", + ); + } + host.addController(this); + } + + hostConnected(): void {} + hostDisconnected(): void {} + + get states(): StackController { + return this.#states; + } + + open = (( + options: OmitKeyof, "stack">, + ) => { + if (!this.#client) { + throw new Error( + "[layers/lit] useAppStack: LayerClient not resolved yet — call open() after the host connects.", + ); + } + return this.#client.open({ + ...options, + stack: this.#stackId, + } as OpenLayerOptions); + }) as unknown as ScopedOpen; + + dismissAll = (response?: unknown): void => { + this.#client?.dismissAll(this.#stackId, response); + }; +} + +/** + * `` — light-DOM host that renders a `stack-outlet` for its `.stack`. + * Forward host props to layers via `.rootProps` (defaults to the host element). + * Register with {@link defineStackElements}; {@link createStackHook} returns a + * subclass bound to its stack id. + */ +export class AppHostElement extends LitElement { + static properties = { + stack: { type: String }, + rootProps: { attribute: false }, + }; + declare stack: string; + declare rootProps: unknown; + + constructor() { + super(); + this.stack = "default"; + this.rootProps = undefined; + } + + createRenderRoot(): this { + return this; + } + + render(): TemplateResult { + return html``; + } +} + +export interface AppLayerProps { + /** Layer definition with the stack supplied by the factory. */ + options: OmitKeyof, "stack">; + /** Controlled visibility. `true` opens the layer; `false` dismisses it. */ + open: boolean; + payload: P; + /** Called when the layer resolves. */ + onResolved?: (response: R) => void; +} + +/** + * Reactive controller for a controlled layer bound to one stack. Set `.open` to + * `true` to open and `false` to dismiss; `.payload` / `.options` / `.onResolved` + * update the live layer. Dismisses automatically when the host disconnects. + */ +export class AppLayerController< + P = unknown, + R = unknown, +> implements ReactiveController { + #client: LayerClient | undefined; + #stackId: string; + #opened = false; + #connected = false; + #pendingOpen: Promise | null = null; + + options: OmitKeyof, "stack">; + #open: boolean; + payload: P; + onResolved?: (response: R) => void; + + constructor( + host: ReactiveControllerHost, + client: LayerClient | undefined, + stackId: string, + props: AppLayerProps, + ) { + this.#stackId = stackId; + this.options = props.options; + this.#open = props.open; + this.payload = props.payload; + this.onResolved = props.onResolved; + if (client) { + this.#client = client; + } else if (isElementHost(host)) { + resolveClientLazy(host, undefined, (c) => { + this.#client = c; + if (this.#connected) this.#sync(); + }); + } + host.addController(this); + } + + get open(): boolean { + return this.#open; + } + set open(v: boolean) { + this.#open = v; + this.#sync(); + } + + hostConnected(): void { + this.#connected = true; + this.#sync(); + } + + hostDisconnected(): void { + this.#connected = false; + if (!this.#opened || !this.#client) return; + const client = this.#client; + const stackId = this.#stackId; + const key = this.options.key; + const pending = this.#pendingOpen; + this.#opened = false; + this.#pendingOpen = null; + + const dismissIfMounted = (): void => { + const stack = client.getStack(stackId); + const layer = stack.find(key); + if (layer) stack.dismiss(layer, undefined as never); + }; + + dismissIfMounted(); + // Open may still be mounting — dismiss once it lands so the layer cannot leak. + if (pending) { + void pending.then(dismissIfMounted, dismissIfMounted); + } + } + + #sync(): void { + if (!this.#client) return; + if (this.#open && !this.#opened) { + this.#opened = true; + const pending = this.#client.open({ + ...this.options, + stack: this.#stackId, + payload: this.payload, + } as OpenLayerOptions) as Promise; + this.#pendingOpen = pending; + void pending.then((response) => { + if (this.#pendingOpen !== pending) return; + this.#opened = false; + this.#pendingOpen = null; + this.onResolved?.(response); + }); + } else if (!this.#open && this.#opened) { + const stack = this.#client.getStack(this.#stackId); + const layer = stack.find(this.options.key); + if (layer) stack.dismiss(layer, undefined as never); + this.#opened = false; + this.#pendingOpen = null; + } + } +} + +export interface StackHook { + /** Provider CE subclass bound to the hook's default client. */ + StackProvider: typeof StackProvider; + /** Reactive controller for the bound stack's `open` / `dismissAll` / `states`. */ + useAppStack: (host: ReactiveControllerHost) => AppStackController; + /** `` subclass bound to the hook's stack id. */ + AppHost: typeof AppHostElement; + /** Controlled-layer controller constructor bound to the hook's stack. */ + AppLayer: new ( + host: ReactiveControllerHost, + props: AppLayerProps, + ) => AppLayerController; +} + +/** + * Create a provider, app-stack controller, app-host, and controlled-layer + * controller bound to one stack. Mirrors the React/Vue/Solid `createStackHook`. + */ +export function createStackHook( + config: { + client?: LayerClient; + stack?: string; + } = {}, +): StackHook { + const stackId = config.stack ?? "default"; + const defaultClient = config.client; + + class BoundStackProvider extends StackProvider { + connectedCallback(): void { + if (this.client === undefined && defaultClient !== undefined) { + this.client = defaultClient; + } + super.connectedCallback(); + } + } + + function useAppStack(host: ReactiveControllerHost): AppStackController { + return new AppStackController(host, defaultClient, stackId); + } + + class BoundAppHost extends AppHostElement { + constructor() { + super(); + this.stack = stackId; + } + } + + class BoundAppLayer extends AppLayerController { + constructor( + host: ReactiveControllerHost, + props: AppLayerProps, + ) { + super(host, defaultClient, stackId, props); + } + } + + return { + StackProvider: BoundStackProvider, + useAppStack, + AppHost: BoundAppHost, + AppLayer: BoundAppLayer, + }; +} + +const STACK_ELEMENTS = [ + ["stack-provider", StackProvider], + ["stack-outlet", StackOutlet], + ["stack-subscribe", StackSubscribe], + ["app-host", AppHostElement], +] as const; + +/** + * Idempotently register the stack custom elements: `stack-provider`, + * `stack-outlet`, `stack-subscribe`, and `app-host`. Safe to call multiple + * times; not auto-invoked on import (tenets 2 + 4). + */ +export function defineStackElements(): void { + if (typeof customElements === "undefined") return; + for (const [tag, ctor] of STACK_ELEMENTS) { + if (!customElements.get(tag)) customElements.define(tag, ctor); + } +} diff --git a/packages/lit/tests-dom/create-stack-hook.test.ts b/packages/lit/tests-dom/create-stack-hook.test.ts new file mode 100644 index 0000000..67aea7a --- /dev/null +++ b/packages/lit/tests-dom/create-stack-hook.test.ts @@ -0,0 +1,292 @@ +import type { LayerComponentProps } from "@stainless-code/layers"; +import { LayerClient } from "@stainless-code/layers"; +import { LitElement, html } from "lit"; +import { describe, expect, it, vi } from "vitest"; + +import { + AppHostElement, + createStackHook, + defineStackElements, +} from "../src/index"; +import type { AppLayerProps, StackProvider } from "../src/index"; + +defineStackElements(); + +class ModalDialogEl extends LitElement { + static properties = { + call: { attribute: false }, + payload: { attribute: false }, + }; + declare call: LayerComponentProps<{ title: string }, boolean>["call"]; + declare payload: { title: string }; + + createRenderRoot(): this { + return this; + } + + render() { + return html`
+

${this.payload.title}

+ +
`; + } +} +customElements.define("test-modal-dialog", ModalDialogEl); + +const modalLayerOpts = { + key: ["modal", "settings"], + component: ModalDialogEl, + exitingDelay: 0, +}; + +async function updateComplete(el: HTMLElement): Promise { + await (el as unknown as { updateComplete: Promise }).updateComplete; + await Promise.resolve(); +} + +describe("Lit adapter — createStackHook", () => { + it("useAppStack.open binds the stack", async () => { + const client = new LayerClient(); + const { useAppStack } = createStackHook({ + stack: "modal", + }); + let pending: Promise | undefined; + + class Trigger extends LitElement { + #app = useAppStack(this); + + createRenderRoot(): this { + return this; + } + + render() { + return html``; + } + } + customElements.define("test-lit-trigger", Trigger); + + const provider = document.createElement("stack-provider") as StackProvider & + HTMLElement; + provider.client = client; + const host = document.createElement("app-host") as AppHostElement & + HTMLElement; + host.stack = "modal"; + const trigger = document.createElement("test-lit-trigger"); + provider.append(host, trigger); + document.body.appendChild(provider); + await updateComplete(provider); + await updateComplete(host); + + trigger.querySelector("button")!.click(); + await updateComplete(host); + await Promise.resolve(); + + expect(host.querySelector('[role="dialog"]')).toBeTruthy(); + expect(host.textContent).toContain("Settings"); + expect(client.getStack("modal").getSnapshot()).toHaveLength(1); + expect(pending).toBeDefined(); + + (host.querySelector('button[type="button"]') as HTMLButtonElement).click(); + await Promise.resolve(); + await updateComplete(host); + + await expect(pending!).resolves.toBe(true); + await updateComplete(host); + + expect(host.querySelector('[role="dialog"]')).toBeNull(); + expect(client.getStack("modal").getSnapshot()).toHaveLength(0); + + provider.remove(); + }); + + it("AppLayer controlled open/close", async () => { + const client = new LayerClient(); + const onResolved = vi.fn(); + const { AppLayer } = createStackHook({ + stack: "modal", + }); + + const layerOpts = { + key: ["modal", "settings"], + component: ModalDialogEl, + exitingDelay: 0, + } satisfies Omit< + AppLayerProps<{ title: string }, boolean>["options"], + never + >; + + class ControlledLayer extends LitElement { + static properties = { + open: { type: Boolean }, + }; + declare open: boolean; + #layer?: InstanceType; + + constructor() { + super(); + this.open = false; + } + + createRenderRoot(): this { + return this; + } + + connectedCallback(): void { + super.connectedCallback(); + this.#layer = new AppLayer(this, { + options: layerOpts, + open: this.open, + payload: { title: "Controlled" }, + onResolved, + }); + } + + updated(): void { + if (this.#layer) { + this.#layer.open = this.open; + } + } + + render() { + return html` + `; + } + } + customElements.define("test-lit-controlled", ControlledLayer); + + const provider = document.createElement("stack-provider") as StackProvider & + HTMLElement; + provider.client = client; + const host = document.createElement("app-host") as AppHostElement & + HTMLElement; + host.stack = "modal"; + const controlled = document.createElement( + "test-lit-controlled", + ) as ControlledLayer & HTMLElement; + provider.append(host, controlled); + document.body.appendChild(provider); + await updateComplete(provider); + await updateComplete(host); + + (controlled.querySelectorAll("button")[0] as HTMLButtonElement).click(); + await updateComplete(host); + await Promise.resolve(); + + expect(host.querySelector('[role="dialog"]')).toBeTruthy(); + expect(host.textContent).toContain("Controlled"); + + const confirmBtn = host.querySelector( + "test-modal-dialog button", + ) as HTMLButtonElement; + expect(confirmBtn).toBeTruthy(); + confirmBtn.click(); + await Promise.resolve(); + await Promise.resolve(); + await updateComplete(host); + + expect(onResolved).toHaveBeenCalledWith(true); + await updateComplete(host); + + expect(host.querySelector('[role="dialog"]')).toBeNull(); + + provider.remove(); + }); + + it("AppLayer dismisses when open flips true to false", async () => { + const client = new LayerClient(); + const { AppLayer } = createStackHook({ + stack: "modal", + }); + + const layerOpts = { + key: ["modal", "dismiss-test"], + component: ModalDialogEl, + exitingDelay: 0, + }; + + class ControlledLayer extends LitElement { + static properties = { + open: { type: Boolean }, + }; + declare open: boolean; + #layer?: InstanceType; + + constructor() { + super(); + this.open = false; + } + + createRenderRoot(): this { + return this; + } + + connectedCallback(): void { + super.connectedCallback(); + this.#layer = new AppLayer(this, { + options: layerOpts, + open: this.open, + payload: { title: "Dismiss me" }, + }); + } + + updated(): void { + if (this.#layer) { + this.#layer.open = this.open; + } + } + + render() { + return html` + `; + } + } + customElements.define("test-lit-dismiss", ControlledLayer); + + const provider = document.createElement("stack-provider") as StackProvider & + HTMLElement; + provider.client = client; + const host = document.createElement("app-host") as AppHostElement & + HTMLElement; + host.stack = "modal"; + const controlled = document.createElement( + "test-lit-dismiss", + ) as ControlledLayer & HTMLElement; + provider.append(host, controlled); + document.body.appendChild(provider); + await updateComplete(provider); + await updateComplete(host); + + (controlled.querySelectorAll("button")[0] as HTMLButtonElement).click(); + await updateComplete(host); + await Promise.resolve(); + expect(host.querySelector('[role="dialog"]')).toBeTruthy(); + + (controlled.querySelectorAll("button")[1] as HTMLButtonElement).click(); + await updateComplete(host); + await Promise.resolve(); + + expect(host.querySelector('[role="dialog"]')).toBeNull(); + + provider.remove(); + }); +}); diff --git a/packages/lit/tests-dom/headless.test.ts b/packages/lit/tests-dom/headless.test.ts new file mode 100644 index 0000000..690c987 --- /dev/null +++ b/packages/lit/tests-dom/headless.test.ts @@ -0,0 +1,72 @@ +import { LitElement, html } from "lit"; +import { describe, expect, it } from "vitest"; + +import { + defineStackElements, + layerOptions, + LayerClient, + StackProvider, + useStackHandles, +} from "../src/index"; + +defineStackElements(); + +const modalOptions = layerOptions<{ title: string }, boolean>({ + stack: "modal", + key: ["modal", "headless"], + exitingDelay: 0, +}); + +class HeadlessHost extends LitElement { + #handles = useStackHandles(this, "modal"); + + createRenderRoot(): this { + return this; + } + + render() { + return html`${this.#handles.states.current.map( + (s) => html``, + )}`; + } +} +customElements.define("test-headless-host", HeadlessHost); + +describe("Lit adapter — headless render", () => { + it("useStackHandles renders custom close controls and ends the layer", async () => { + const client = new LayerClient(); + const provider = document.createElement("stack-provider") as StackProvider & + HTMLElement; + provider.client = client; + const host = document.createElement( + "test-headless-host", + ) as HeadlessHost & { + updateComplete: Promise; + }; + provider.appendChild(host); + document.body.appendChild(provider); + await host.updateComplete; + + const pending = client.open({ + ...modalOptions, + payload: { title: "Headless modal" }, + }); + await host.updateComplete; + + const button = host.querySelector("button"); + expect(button).toBeTruthy(); + expect(button?.textContent).toContain("Headless modal"); + + button!.click(); + await expect(pending).resolves.toBe(true); + await host.updateComplete; + expect(host.querySelector("button")).toBeNull(); + + document.body.removeChild(provider); + }); +}); diff --git a/packages/lit/tests-dom/inference.test-d.ts b/packages/lit/tests-dom/inference.test-d.ts new file mode 100644 index 0000000..95a9d6f --- /dev/null +++ b/packages/lit/tests-dom/inference.test-d.ts @@ -0,0 +1,239 @@ +import type { LayerState, StandardSchemaV1 } from "@stainless-code/layers"; +import { LayerClient, layerKey, layerOptions } from "@stainless-code/layers"; +/** + * Lit adapter type-level inference tests. Compiled by `tsc --noEmit` + * (tsconfig includes `tests-dom/**`); never executed — vitest's + * `*.test.{ts,tsx}` glob skips `*.test-d.ts`. + * Registered as a knip entry so its exports are not flagged. + */ +import type { + AppLayerProps, + AppStack, + MutationFlow, + StackHandles, + WiredLayerHandle, +} from "@stainless-code/lit-layers"; +import { + createStackHook, + useLayer, + useLayerGroup, + useLayerState, + useMutationFlow, + useStack, + useStackHandles, +} from "@stainless-code/lit-layers"; +import type { ReactiveControllerHost } from "lit"; + +/** Invariant mutual-assignability check. */ +export type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? true + : false; +export type Expect = T; + +declare const host: ReactiveControllerHost; +declare const client: LayerClient; + +const removeKey = layerKey()(["confirm", "remove"]); + +const confirmOpts = layerOptions<{ title: string }, boolean>({ + key: ["confirm", "count"], +}); + +const idSchema = { + "~standard": { + version: 1, + vendor: "test", + validate: (v: unknown) => ({ + value: { id: Number((v as { id: string }).id) }, + }), + types: undefined as unknown as { + input: { id: string }; + output: { id: number }; + }, + }, +} as StandardSchemaV1<{ id: string }, { id: number }>; + +const validatedConfirm = { + key: ["v"], + validate: idSchema, +}; + +declare const nSelector: (states: LayerState[]) => { n: number }; + +// M1 — `useStack` select return flows through; default is `LayerState[]`. +function useStackDefault() { + return useStack(host, { stack: "s" }); +} +export type _UseStackDefaultLayerStates = Expect< + Equal["current"], LayerState[]> +>; +function useStackWithSelect() { + return useStack(host, { stack: "s", select: nSelector }); +} +export type _UseStackSelectorFlows = Expect< + Equal["current"], { n: number }> +>; +function useStackExplicitGeneric() { + return useStack>(host, { + stack: "s", + select: nSelector, + }); +} +export type _UseStackSelectorFromDecl = Expect< + Equal["current"], { n: number }> +>; +function useStackAcceptsCompare() { + useStack(host, { + stack: "s", + select: nSelector, + compare: (a, b) => a.n === b.n, + }); +} +void useStackAcceptsCompare; +function useStackRejectsBadCompare() { + // @ts-expect-error compare must return boolean + useStack(host, { stack: "s", select: nSelector, compare: () => "bad" }); +} +void useStackRejectsBadCompare; + +// M4 — `useLayerState` honors a DataTag key: `R`/`E` inferred from the key alone. +function useLayerStateTagged() { + return useLayerState(host, { key: removeKey }); +} +type _LitUseLayerStateTagged = ReturnType< + typeof useLayerStateTagged +>["current"]; +export type _UseLayerStateInfersResponse = Expect< + Equal<_LitUseLayerStateTagged, LayerState[]> +>; +function useLayerStatePlain() { + return useLayerState(host, { key: ["plain"] as const }); +} +type _LitUseLayerStatePlain = ReturnType["current"]; +export type _UseLayerStatePlainVoid = Expect< + Equal<_LitUseLayerStatePlain, LayerState[]> +>; + +// Wired `useLayer` — open infers `R` from layerOptions; `client` is optional. +function useConfirmLayer() { + return useLayer(host, confirmOpts); +} +function openViaUseLayer() { + return useConfirmLayer().open({ title: "n" }); +} +export type _UseLayerOpenInfersResponse = Expect< + Equal>, boolean> +>; +// `useLayer` without an explicit client typechecks (resolves from context). +function useLayerNoClient() { + return useLayer(host, confirmOpts); +} +void useLayerNoClient; + +// Wired `useLayer` — validated: open accepts INPUT; state uses OUTPUT. +function useValidatedLayer() { + return useLayer(host, validatedConfirm); +} +function openViaValidatedUseLayer() { + return useValidatedLayer().open({ id: "1" }); +} +void openViaValidatedUseLayer; +export type _ValidatedUseLayerOpenAcceptsInput = Expect< + Equal< + Parameters["open"]>[0], + { id: string } + > +>; +function openViaValidatedUseLayerWrongPayload() { + // @ts-expect-error output shape is not the schema input + return useValidatedLayer().open({ id: 1 }); +} +void openViaValidatedUseLayerWrongPayload; +export type _ValidatedUseLayerStatePayload = Expect< + Equal< + ReturnType["state"]["current"][number]["payload"], + { id: number } + > +>; + +// `WiredLayerHandle` exposes reactive `state` / `queued` / `top`. +declare const wired: WiredLayerHandle<{ title: string }, boolean>; +export type _WiredTop = Expect< + Equal<(typeof wired)["top"], LayerState<{ title: string }, boolean> | null> +>; + +// PayloadArg optionality on wired `useLayer`. +const voidOpts = layerOptions({ key: ["void"] }); +function openVoidUseLayerOmitted() { + return useLayer(host, voidOpts).open(); +} +void openVoidUseLayerOmitted; + +const reqOpts = layerOptions<{ title: string }>({ key: ["req"] }); +function openReqUseLayerOmitted() { + // @ts-expect-error payload is required for a payload with required fields + return useLayer(host, reqOpts).open(); +} +void openReqUseLayerOmitted; + +// `useStackHandles` returns the headless `{ states, getCall }` pair. +declare const handles: StackHandles; +export type _StackHandlesStates = Expect< + Equal<(typeof handles)["states"]["current"], LayerState[]> +>; + +declare const call: import("@stainless-code/layers").LayerCallContext< + unknown, + boolean +>; +declare const flow: MutationFlow; +export type _MutationFlowPending = Expect< + Equal<(typeof flow)["pending"], boolean> +>; +export type _MutationFlowRun = Expect< + Equal< + ReturnType<(typeof flow)["run"]>["orEnd"], + (response: boolean) => Promise + > +>; +void useMutationFlow(host, call); + +declare const appStack: AppStack; +function openScopedTagged() { + return appStack.open({ key: removeKey, payload: { title: "Remove?" } }); +} +export type _ScopedOpenInfersResponse = Expect< + Equal>, boolean> +>; +function openScopedPlain() { + return appStack.open({ key: ["plain"], payload: { title: "hi" } }); +} +export type _ScopedOpenPlainVoid = Expect< + Equal>, void> +>; + +declare const group: ReturnType; +declare const groupOpen: (typeof group)["open"]; +function openGroupTagged() { + return groupOpen({ key: removeKey, payload: { title: "Remove?" } }); +} +export type _LayerGroupOpenInfersResponse = Expect< + Equal>, boolean> +>; + +declare const _appHook: ReturnType; +// `AppLayerProps` threads the payload `P` and response `R` through. +type _AppLayerP = AppLayerProps<{ title: string }, boolean>; +export type _AppLayerPropsOpen = Expect>; +export type _AppLayerPropsPayload = Expect< + Equal<_AppLayerP["payload"], { title: string }> +>; +export type _AppLayerPropsOnResolved = Expect< + Equal<_AppLayerP["onResolved"], ((response: boolean) => void) | undefined> +>; + +void client; +void _appHook; +void group; +void useStackHandles; diff --git a/packages/lit/tests-dom/layer-handles.test.ts b/packages/lit/tests-dom/layer-handles.test.ts new file mode 100644 index 0000000..95a0bdf --- /dev/null +++ b/packages/lit/tests-dom/layer-handles.test.ts @@ -0,0 +1,203 @@ +import { ContextEvent } from "@lit/context"; +import { LitElement, html } from "lit"; +import { describe, expect, it } from "vitest"; + +import { + defineStackElements, + layerClientContext, + layerOptions, + LayerClient, + StackProvider, + useLayer, + useStack, +} from "../src/index"; +import type { LayerCallContext } from "../src/index"; + +defineStackElements(); + +class ConfirmDialog extends LitElement { + static properties = { + call: { attribute: false }, + payload: { attribute: false }, + }; + declare call: LayerCallContext<{ title: string }, boolean>; + declare payload: { title: string }; + + createRenderRoot(): this { + return this; + } + + render() { + return html`
+

${this.payload.title}

+ +
`; + } +} +customElements.define("test-layer-handle-dialog", ConfirmDialog); + +const confirmOptions = layerOptions<{ title: string }, boolean>({ + stack: "confirm", + key: ["confirm", "remove"], + component: ConfirmDialog, + exitingDelay: 0, +}); + +class LayerHost extends LitElement { + // No explicit client — must resolve from the ancestor. + #handle = useLayer(this, confirmOptions); + + createRenderRoot(): this { + return this; + } + + openLayer(): Promise { + return this.#handle.open({ title: "Remove?" }); + } + + render() { + return html``; + } +} +customElements.define("test-layer-host", LayerHost); + +async function mountProviderHost(client: LayerClient) { + const provider = document.createElement("stack-provider") as StackProvider & + HTMLElement; + provider.client = client; + const outlet = document.createElement("stack-outlet") as HTMLElement & { + stack: string; + updateComplete: Promise; + }; + outlet.stack = "confirm"; + const host = document.createElement("test-layer-host") as HTMLElement & { + updateComplete: Promise; + openLayer: () => Promise; + }; + provider.appendChild(host); + provider.appendChild(outlet); + document.body.appendChild(provider); + await host.updateComplete; + await outlet.updateComplete; + return { provider, outlet, host }; +} + +describe("Lit adapter — useLayer lazy client", () => { + it("dispatches one layer-client context-request on the useLayer host", async () => { + const client = new LayerClient(); + const host = document.createElement("test-layer-host") as HTMLElement & { + updateComplete: Promise; + }; + let layerClientRequests = 0; + host.addEventListener("context-request", (e) => { + const ev = e as InstanceType; + if (ev.context === layerClientContext) layerClientRequests += 1; + }); + + const provider = document.createElement("stack-provider") as StackProvider & + HTMLElement; + provider.client = client; + provider.appendChild(host); + document.body.appendChild(provider); + await host.updateComplete; + + expect(layerClientRequests).toBe(1); + + document.body.removeChild(provider); + }); + + it("resolves the client from context and opens/dismisses without an explicit client", async () => { + const client = new LayerClient(); + const { outlet, host, provider } = await mountProviderHost(client); + + expect(outlet.querySelector('[role="dialog"]')).toBeNull(); + + void host.openLayer(); + await outlet.updateComplete; + await host.updateComplete; + await Promise.resolve(); + + expect(outlet.querySelector('[role="dialog"]')).toBeTruthy(); + expect(outlet.textContent).toContain("Remove?"); + + (outlet.querySelector("button") as HTMLButtonElement).click(); + await outlet.updateComplete; + await Promise.resolve(); + + expect(outlet.querySelector('[role="dialog"]')).toBeNull(); + + document.body.removeChild(provider); + }); + + it("throws on .open when no provider supplies a client", async () => { + const host = document.createElement("test-layer-host") as HTMLElement & { + updateComplete: Promise; + openLayer: () => Promise; + }; + document.body.appendChild(host); + await host.updateComplete; + + expect(() => host.openLayer()).toThrow("[layers/lit]"); + document.body.removeChild(host); + }); + + it("rebinds useStack when pushes a new client", async () => { + class StackProbe extends LitElement { + #stack = useStack(this, { + stack: "confirm", + select: (s) => s.length, + }); + + createRenderRoot(): this { + return this; + } + + get count(): number { + return this.#stack.current; + } + + render() { + return html`${this.#stack.current}`; + } + } + customElements.define("test-stack-probe", StackProbe); + + const first = new LayerClient(); + const second = new LayerClient(); + const provider = document.createElement("stack-provider") as StackProvider & + HTMLElement; + provider.client = first; + const probe = document.createElement("test-stack-probe") as StackProbe & { + updateComplete: Promise; + count: number; + }; + provider.appendChild(probe); + document.body.appendChild(provider); + await probe.updateComplete; + + void first.open({ + ...confirmOptions, + payload: { title: "on first" }, + }); + await probe.updateComplete; + expect(probe.count).toBe(1); + + provider.client = second; + await provider.updateComplete; + await probe.updateComplete; + expect(probe.count).toBe(0); + + void second.open({ + ...confirmOptions, + payload: { title: "on second" }, + }); + await probe.updateComplete; + expect(probe.count).toBe(1); + + document.body.removeChild(provider); + }); +}); diff --git a/packages/lit/tests-dom/stack-outlet.test.ts b/packages/lit/tests-dom/stack-outlet.test.ts new file mode 100644 index 0000000..0e70edf --- /dev/null +++ b/packages/lit/tests-dom/stack-outlet.test.ts @@ -0,0 +1,517 @@ +import { ContextEvent } from "@lit/context"; +import { LitElement, html } from "lit"; +import { describe, expect, it, vi } from "vitest"; + +import { + AppHostElement, + defineStackElements, + layerClientContext, + layerOptions, + LayerClient, + StackOutlet, + StackProvider, + StackSubscribe, + useLayerGroup, + useMutationFlow, +} from "../src/index"; +import type { LayerCallContext, LayerComponentProps } from "../src/index"; + +defineStackElements(); + +class ConfirmDialog extends LitElement { + static properties = { + call: { attribute: false }, + payload: { attribute: false }, + }; + declare call: LayerCallContext<{ title: string }, boolean>; + declare payload: { title: string }; + + createRenderRoot(): this { + return this; + } + + render() { + return html`
+

${this.payload.title}

+ +
`; + } +} +customElements.define("test-confirm-dialog", ConfirmDialog); + +const confirmOptions = layerOptions<{ title: string }, boolean>({ + stack: "confirm", + key: ["confirm", "remove"], + component: ConfirmDialog, + exitingDelay: 0, +}); + +const noComponentOptions = layerOptions<{ title: string }, boolean>({ + stack: "modal", + key: ["modal", "no-component"], + exitingDelay: 0, +}); + +async function mountProvider(client: LayerClient, stack = "confirm") { + const provider = document.createElement("stack-provider") as StackProvider & + HTMLElement; + provider.client = client; + const outlet = document.createElement("stack-outlet") as StackOutlet & + HTMLElement; + outlet.stack = stack; + provider.appendChild(outlet); + document.body.appendChild(provider); + await (outlet as unknown as { updateComplete: Promise }) + .updateComplete; + return { provider, outlet }; +} + +describe("Lit adapter — StackOutlet", () => { + it("renders on open and removes on close (light DOM)", async () => { + const client = new LayerClient(); + const { outlet } = await mountProvider(client); + + const pending = client.open({ + ...confirmOptions, + payload: { title: "Remove export" }, + }); + await (outlet as unknown as { updateComplete: Promise }) + .updateComplete; + await Promise.resolve(); + + expect(outlet.querySelector('[role="dialog"]')).toBeTruthy(); + expect(outlet.textContent).toContain("Remove export"); + expect(outlet.shadowRoot).toBeNull(); + const dialog = outlet.querySelector('[role="dialog"]'); + expect(dialog).toBeTruthy(); + expect(outlet.contains(dialog)).toBe(true); + + (outlet.querySelector("button") as HTMLButtonElement).click(); + await expect(pending).resolves.toBe(true); + await (outlet as unknown as { updateComplete: Promise }) + .updateComplete; + await Promise.resolve(); + + expect(outlet.querySelector('[role="dialog"]')).toBeNull(); + }); + + it("does not remount the layer component when state changes", async () => { + let mountCount = 0; + class StatusDialog extends LitElement { + static properties = { + call: { attribute: false }, + payload: { attribute: false }, + actionStatus: { type: String }, + }; + declare call: LayerCallContext<{ title: string }, boolean>; + declare payload: { title: string }; + declare actionStatus: string; + + createRenderRoot(): this { + return this; + } + + connectedCallback(): void { + super.connectedCallback(); + mountCount += 1; + } + + render() { + return html`
+ ${this.actionStatus} + +
`; + } + } + customElements.define("test-status-dialog", StatusDialog); + + const statusOptions = layerOptions<{ title: string }, boolean>({ + stack: "confirm", + key: ["confirm", "status"], + component: StatusDialog, + exitingDelay: 0, + }); + + const client = new LayerClient(); + const { outlet } = await mountProvider(client); + + void client.open({ + ...statusOptions, + payload: { title: "Status modal" }, + }); + await (outlet as unknown as { updateComplete: Promise }) + .updateComplete; + await Promise.resolve(); + + expect(outlet.querySelector('[role="dialog"]')).toBeTruthy(); + expect(mountCount).toBe(1); + expect(outlet.querySelector('[data-testid="status"]')?.textContent).toBe( + "idle", + ); + + (outlet.querySelector("button") as HTMLButtonElement).click(); + await (outlet as unknown as { updateComplete: Promise }) + .updateComplete; + await Promise.resolve(); + + expect(mountCount).toBe(1); + expect(outlet.querySelector('[data-testid="status"]')?.textContent).toBe( + "running", + ); + }); + + it("dev-warns on missing component and renders nothing", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const client = new LayerClient(); + const { outlet } = await mountProvider(client, "modal"); + + void client.open({ + ...noComponentOptions, + payload: { title: "No component" }, + }); + await (outlet as unknown as { updateComplete: Promise }) + .updateComplete; + await Promise.resolve(); + + const warnMessage = warnSpy.mock.calls.find(([msg]) => + String(msg).includes("[layers/lit]"), + ); + expect(warnMessage).toBeTruthy(); + expect(String(warnMessage?.[0])).toContain("No component for layer"); + expect(outlet.querySelector('[role="dialog"]')).toBeNull(); + expect(outlet.querySelector("button")).toBeNull(); + warnSpy.mockRestore(); + }); + + it("renders a TemplateResult function component", async () => { + const fnOptions = layerOptions<{ title: string }, boolean>({ + stack: "fn", + key: ["fn", "template"], + component: (props: LayerComponentProps<{ title: string }, boolean>) => + html`
${props.payload.title}
`, + exitingDelay: 0, + }); + const client = new LayerClient(); + const { outlet } = await mountProvider(client, "fn"); + + void client.open({ ...fnOptions, payload: { title: "Fn layer" } }); + await (outlet as unknown as { updateComplete: Promise }) + .updateComplete; + await Promise.resolve(); + + expect(outlet.querySelector("[data-fn]")?.textContent).toBe("Fn layer"); + }); + + it("stops updating after disconnect", async () => { + const client = new LayerClient(); + const { provider, outlet } = await mountProvider(client); + + void client.open({ + ...confirmOptions, + payload: { title: "Mounted" }, + }); + await (outlet as unknown as { updateComplete: Promise }) + .updateComplete; + await Promise.resolve(); + + provider.remove(); + await Promise.resolve(); + + expect(() => { + void client.open({ + ...confirmOptions, + payload: { title: "After disconnect" }, + }); + }).not.toThrow(); + }); + + it("switches stacks when the stack property changes", async () => { + const stackAOpts = layerOptions<{ title: string }, boolean>({ + stack: "a", + key: ["a", "dialog"], + component: (props: LayerComponentProps<{ title: string }, boolean>) => + html`
${props.payload.title}
`, + exitingDelay: 0, + }); + const stackBOpts = layerOptions<{ title: string }, boolean>({ + stack: "b", + key: ["b", "dialog"], + component: (props: LayerComponentProps<{ title: string }, boolean>) => + html`
${props.payload.title}
`, + exitingDelay: 0, + }); + + const client = new LayerClient(); + const { outlet } = await mountProvider(client, "a"); + + void client.open({ ...stackAOpts, payload: { title: "On A" } }); + await (outlet as unknown as { updateComplete: Promise }) + .updateComplete; + await Promise.resolve(); + expect(outlet.querySelector('[data-stack="a"]')?.textContent).toBe("On A"); + + outlet.stack = "b"; + await (outlet as unknown as { updateComplete: Promise }) + .updateComplete; + + void client.open({ ...stackBOpts, payload: { title: "On B" } }); + await (outlet as unknown as { updateComplete: Promise }) + .updateComplete; + await Promise.resolve(); + + expect(outlet.querySelector('[data-stack="b"]')?.textContent).toBe("On B"); + expect(outlet.querySelector('[data-stack="a"]')).toBeNull(); + }); +}); + +describe("Lit adapter — StackSubscribe", () => { + it("renders the selected value through .renderer", async () => { + const client = new LayerClient(); + const provider = document.createElement("stack-provider") as StackProvider & + HTMLElement; + provider.client = client; + const sub = document.createElement("stack-subscribe") as StackSubscribe & + HTMLElement; + sub.stack = "s"; + sub.selector = (states) => states.length; + sub.renderer = (value: unknown) => + html`${String(value)}`; + provider.appendChild(sub); + document.body.appendChild(provider); + await (sub as unknown as { updateComplete: Promise }) + .updateComplete; + + expect(sub.querySelector('[data-testid="count"]')?.textContent).toBe("0"); + + void client.open({ key: ["a"], payload: 1, stack: "s" }); + await (sub as unknown as { updateComplete: Promise }) + .updateComplete; + await Promise.resolve(); + + expect(sub.querySelector('[data-testid="count"]')?.textContent).toBe("1"); + }); +}); + +class SaveDialog extends LitElement { + static properties = { + call: { attribute: false }, + actionStatus: { attribute: false }, + }; + declare call: LayerCallContext<{ title: string }, boolean>; + declare actionStatus: string; + + createRenderRoot(): this { + return this; + } + + #flow?: ReturnType>; + render() { + if (!this.#flow) + this.#flow = useMutationFlow<{ title: string }, boolean>(this, this.call); + return html`
+ ${this.actionStatus} + +
`; + } +} +customElements.define("test-save-dialog", SaveDialog); + +const saveOptions = layerOptions<{ title: string }, boolean>({ + stack: "save", + key: ["save", "export"], + component: SaveDialog, + exitingDelay: 0, +}); + +describe("Lit adapter — useMutationFlow", () => { + it("drives actionStatus to running then ends on success", async () => { + const client = new LayerClient(); + const { outlet } = await mountProvider(client, "save"); + + void client.open({ ...saveOptions, payload: { title: "Save" } }); + await (outlet as unknown as { updateComplete: Promise }) + .updateComplete; + await Promise.resolve(); + + expect(outlet.querySelector('[data-testid="status"]')?.textContent).toBe( + "idle", + ); + + (outlet.querySelector("button") as HTMLButtonElement).click(); + await (outlet as unknown as { updateComplete: Promise }) + .updateComplete; + await Promise.resolve(); + + expect(outlet.querySelector('[data-testid="status"]')?.textContent).toBe( + "running", + ); + + await Promise.resolve(); + await (outlet as unknown as { updateComplete: Promise }) + .updateComplete; + await Promise.resolve(); + + expect(outlet.querySelector('[role="dialog"]')).toBeNull(); + }); +}); + +class ParentDrawer extends LitElement { + static properties = { + call: { attribute: false }, + payload: { attribute: false }, + }; + declare call: LayerCallContext<{ title: string }, boolean>; + declare payload: { title: string }; + #group?: ReturnType>; + + createRenderRoot(): this { + return this; + } + + render() { + if (!this.#group) { + this.#group = useLayerGroup<{ title: string }, boolean>(this, this.call); + } + return html`
+

${this.payload.title}

+ + ${this.#group.outlet()} +
`; + } +} +customElements.define("test-parent-drawer", ParentDrawer); + +class ChildDialog extends LitElement { + static properties = { + call: { attribute: false }, + payload: { attribute: false }, + }; + declare call: LayerCallContext<{ label: string }, string>; + declare payload: { label: string }; + + createRenderRoot(): this { + return this; + } + + render() { + return html`
+

${this.payload.label}

+
`; + } +} +customElements.define("test-child-dialog", ChildDialog); + +const childOptions = layerOptions<{ label: string }, string>({ + key: ["drawer", "child"], + component: ChildDialog, + exitingDelay: 0, +}); + +const parentOptions = layerOptions<{ title: string }, boolean>({ + stack: "drawer", + key: ["drawer", "parent"], + component: ParentDrawer, + exitingDelay: 0, +}); + +describe("Lit adapter — useLayerGroup", () => { + it("renders a nested layer through group.outlet()", async () => { + const client = new LayerClient(); + const { outlet } = await mountProvider(client, "drawer"); + + void client.open({ ...parentOptions, payload: { title: "Parent" } }); + await (outlet as unknown as { updateComplete: Promise }) + .updateComplete; + await Promise.resolve(); + await Promise.resolve(); + + expect(outlet.querySelector('[aria-label="Parent"]')).toBeTruthy(); + + ( + Array.from(outlet.querySelectorAll("button")).find((b) => + b.textContent?.includes("Open child"), + ) as HTMLButtonElement + ).click(); + await (outlet as unknown as { updateComplete: Promise }) + .updateComplete; + await Promise.resolve(); + + expect(outlet.querySelector('[aria-label="Child"]')).toBeTruthy(); + expect(outlet.textContent).toContain("Child"); + }); + + it("dispatches one layer-client context-request on the useLayerGroup host", async () => { + const client = new LayerClient(); + const { outlet } = await mountProvider(client, "drawer"); + + // Count requests on the parent host: capture bubbling context-request + // before/during connect by listening on the outlet (composed events). + let layerClientRequests = 0; + outlet.addEventListener("context-request", (e) => { + const ev = e as InstanceType; + if (ev.context === layerClientContext) layerClientRequests += 1; + }); + + void client.open({ ...parentOptions, payload: { title: "Parent" } }); + await (outlet as unknown as { updateComplete: Promise }) + .updateComplete; + await Promise.resolve(); + await Promise.resolve(); + + const parent = outlet.querySelector("test-parent-drawer"); + expect(parent).toBeTruthy(); + // ParentDrawer: one shared resolve for the group (not group + #states). + expect(layerClientRequests).toBe(1); + }); +}); + +describe("Lit adapter — StackProvider", () => { + it("uses shadow DOM + slot (context still reaches light children)", async () => { + const client = new LayerClient(); + const { provider, outlet } = await mountProvider(client); + + expect(provider.shadowRoot).toBeTruthy(); + expect(provider.shadowRoot?.querySelector("slot")).toBeTruthy(); + // Light-DOM child remains a direct child; composed context still resolves. + expect(outlet.parentElement).toBe(provider); + expect(outlet.shadowRoot).toBeNull(); + + void client.open({ + ...confirmOptions, + payload: { title: "Via shadow provider" }, + }); + await (outlet as unknown as { updateComplete: Promise }) + .updateComplete; + await Promise.resolve(); + + expect(outlet.textContent).toContain("Via shadow provider"); + }); +}); + +describe("Lit adapter — defineStackElements", () => { + it("is idempotent (no double-register)", () => { + expect(() => defineStackElements()).not.toThrow(); + expect(customElements.get("stack-provider")).toBe(StackProvider); + expect(customElements.get("stack-outlet")).toBe(StackOutlet); + expect(customElements.get("stack-subscribe")).toBe(StackSubscribe); + expect(customElements.get("app-host")).toBe(AppHostElement); + }); +}); diff --git a/packages/lit/tsconfig.json b/packages/lit/tsconfig.json new file mode 100644 index 0000000..750c792 --- /dev/null +++ b/packages/lit/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*.ts", "tests-dom/**/*.ts", "tsdown.config.ts"] +} diff --git a/packages/lit/tsdown.config.ts b/packages/lit/tsdown.config.ts new file mode 100644 index 0000000..a86ec69 --- /dev/null +++ b/packages/lit/tsdown.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: { index: "src/index.ts" }, + outDir: "dist", + format: "esm", + dts: true, + clean: true, +}); diff --git a/packages/lit/vitest.config.ts b/packages/lit/vitest.config.ts new file mode 100644 index 0000000..c1fd542 --- /dev/null +++ b/packages/lit/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; + +// Lit custom elements + @lit/context need a DOM and the customElements +// registry that bun:test can't provide. Scoped to `tests-dom/**`. +export default defineConfig({ + test: { + environment: "jsdom", + include: ["tests-dom/**/*.test.ts"], + globals: false, + clearMocks: true, + restoreMocks: true, + }, +});

Capability