Skip to content

Commit a4d033c

Browse files
Raillywotnak
andauthored
feat(vue): support named slots (#323)
Recreate the Vue named slots implementation from #322 and add regression coverage for raw registries, lazy slots, diagnostics, loading, repeat scope, and prompt output. Co-authored-by: wotnak <wotnak@pm.me>
1 parent ea4b361 commit a4d033c

9 files changed

Lines changed: 442 additions & 37 deletions

File tree

apps/web/app/(main)/docs/api/vue/page.mdx

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ store.set("/count", 1);
9595

9696
## defineRegistry
9797

98-
Create a type-safe component registry from a catalog. Components receive `props`, `children`, `emit`, `on`, and `loading` with catalog-inferred types.
98+
Create a type-safe component registry from a catalog. Components receive `props`, `children`, `slots`, `emit`, `on`, and `loading` with catalog-inferred types.
9999

100100
When the catalog declares actions, the `actions` field is required. When the catalog has no actions (e.g. `actions: {}`), the field is optional. When passing stubs, any `async () => {}` is sufficient.
101101

@@ -105,8 +105,12 @@ import { defineRegistry } from "@json-render/vue";
105105

106106
const { registry } = defineRegistry(catalog, {
107107
components: {
108-
Card: ({ props, children }) =>
109-
h("div", { class: "card" }, [h("h3", null, props.title), children]),
108+
Layout: ({ slots }) =>
109+
h("div", { class: "layout" }, [
110+
h("header", null, slots.header?.()),
111+
h("main", null, slots.default?.()),
112+
h("footer", null, slots.footer?.()),
113+
]),
110114
Button: ({ props, emit }) =>
111115
h("button", { onClick: () => emit("press") }, props.label),
112116
},
@@ -136,11 +140,12 @@ const { registry } = defineRegistry(catalog, {
136140
### Component Props (via defineRegistry)
137141

138142
```typescript
139-
import type { VNode } from "vue";
143+
import type { Slots, VNode } from "vue";
140144

141145
interface ComponentContext<P> {
142146
props: P; // Typed props from catalog
143147
children?: VNode | VNode[]; // Rendered children (for container components)
148+
slots: Slots; // Vue-native slot functions
144149
emit: (event: string) => void; // Emit a named event (always defined)
145150
on: (event: string) => EventHandle; // Get event handle with metadata
146151
loading?: boolean;
@@ -154,6 +159,22 @@ interface EventHandle {
154159
}
155160
```
156161

162+
Use `children` for the default slot. For other slots declared by the catalog, add a top-level `slots` map to the spec element:
163+
164+
```json
165+
{
166+
"type": "Layout",
167+
"props": {},
168+
"children": ["main-content"],
169+
"slots": {
170+
"header": ["page-heading"],
171+
"footer": ["page-actions"]
172+
}
173+
}
174+
```
175+
176+
The component renders these regions with Vue's native slot functions: `slots.header?.()`, `slots.footer?.()`, and so on. `slots.default?.()` renders the spec's `children`; `children` is a convenience alias for that rendered result. In the JSON spec, keep default content in `children` rather than adding a `default` entry to `slots`.
177+
157178
Use `emit("press")` for simple event firing. Use `on("click")` when you need metadata like `shouldPreventDefault`:
158179

159180
```typescript

apps/web/app/(main)/docs/specs/page.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ Each element in the map has a consistent shape:
206206
- `type` — Component type from your catalog
207207
- `props` — Component properties
208208
- `children` — Array of child element keys
209-
- `slots`: Optional map of named slots to child element keys. Use `children` for the default slot. Named slot rendering is currently supported by `@json-render/react`.
209+
- `slots`: Optional map of named slots to child element keys. Use `children` for the default slot. Named slot rendering is supported by `@json-render/react` and `@json-render/vue`.
210210

211211
### Dynamic Data
212212

packages/vue/README.md

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export const catalog = defineCatalog(schema, {
2626
title: z.string(),
2727
description: z.string().nullable(),
2828
}),
29+
slots: ["default", "header", "footer"],
2930
description: "A card container",
3031
},
3132
Button: {
@@ -53,7 +54,7 @@ export const catalog = defineCatalog(schema, {
5354

5455
### 2. Define Component Implementations
5556

56-
Components are written using Vue's `h()` render function. `children` is a `VNode | VNode[]` — pass it directly to your container element.
57+
Components are written using Vue's `h()` render function. The `slots` context uses Vue's native slot functions, so render a region with `slots.header?.()`. For convenience and React parity, `children` contains the already-rendered result of `slots.default?.()`.
5758

5859
`defineRegistry` conditionally requires the `actions` field only when the catalog declares actions. Catalogs with `actions: {}` can omit it entirely.
5960

@@ -64,11 +65,12 @@ import { catalog } from "./catalog";
6465

6566
export const { registry } = defineRegistry(catalog, {
6667
components: {
67-
Card: ({ props, children }) =>
68+
Card: ({ props, slots }) =>
6869
h("div", { class: "card" }, [
69-
h("h3", null, props.title),
70+
h("header", null, slots.header?.() ?? h("h3", null, props.title)),
7071
props.description ? h("p", null, props.description) : null,
71-
children,
72+
slots.default?.(),
73+
h("footer", null, slots.footer?.()),
7274
]),
7375
Button: ({ props, emit }) =>
7476
h("button", { onClick: () => emit("press") }, props.label),
@@ -131,6 +133,7 @@ interface UIElement {
131133
type: string; // Component name from catalog
132134
props: Record<string, unknown>; // Component props
133135
children?: string[]; // Keys of child elements
136+
slots?: Record<string, string[]>; // Named slots mapped to child keys
134137
visible?: VisibilityCondition; // Visibility condition
135138
}
136139
```
@@ -163,6 +166,35 @@ Example spec:
163166
}
164167
```
165168

169+
### Named Slots
170+
171+
Use `children` for the default slot and the element's top-level `slots` object for other slot names declared by the catalog:
172+
173+
```typescript
174+
Layout: ({ children, slots }) =>
175+
h("div", null, [
176+
h("header", null, slots.header?.()),
177+
h("main", null, children),
178+
h("footer", null, slots.footer?.()),
179+
]),
180+
```
181+
182+
The corresponding spec maps element keys to each region:
183+
184+
```json
185+
{
186+
"type": "Layout",
187+
"props": {},
188+
"children": ["main-content"],
189+
"slots": {
190+
"header": ["page-heading"],
191+
"footer": ["page-actions"]
192+
}
193+
}
194+
```
195+
196+
Registry components receive Vue-native slot functions and render them with `slots.header?.()`, `slots.footer?.()`, and so on. `slots.default?.()` renders the spec's `children`; `children` is an alias for that rendered result. In the JSON spec, keep default content in `children` rather than adding a `default` entry to `slots`.
197+
166198
## Providers
167199

168200
### StateProvider
@@ -366,11 +398,12 @@ The `setState`, `pushState`, `removeState`, and `validateForm` actions are built
366398
When using `defineRegistry`, components receive these props via their render function:
367399

368400
```typescript
369-
import type { VNode } from "vue";
401+
import type { Slots, VNode } from "vue";
370402

371403
interface ComponentContext<P> {
372404
props: P; // Typed props from the catalog (expressions resolved)
373405
children?: VNode | VNode[]; // Rendered children (for container components)
406+
slots: Slots; // Vue-native slot functions
374407
emit: (event: string) => void; // Emit a named event (always defined)
375408
on: (event: string) => EventHandle; // Get event handle with metadata
376409
loading?: boolean; // Whether the parent is loading

packages/vue/src/catalog-types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { VNode } from "vue";
1+
import type { Slots, VNode } from "vue";
22
import type {
33
Catalog,
44
InferCatalogComponents,
@@ -59,6 +59,8 @@ export interface BaseComponentProps<P = Record<string, unknown>> {
5959
props: P;
6060
/** Rendered children (from the default slot) */
6161
children?: VNode | VNode[];
62+
/** Vue-native slot functions, including the default slot */
63+
slots: Slots;
6264
/** Simple event emitter (shorthand). Fires the event and returns void. */
6365
emit: (event: string) => void;
6466
/** Get an event handle with metadata. Use when you need shouldPreventDefault or bound checks. */

packages/vue/src/hooks.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,31 @@ describe("buildSpecFromParts", () => {
128128
];
129129
expect(buildSpecFromParts(parts)).toBeNull();
130130
});
131+
132+
it("preserves named slots in nested spec parts", () => {
133+
const spec = buildSpecFromParts([
134+
{
135+
type: SPEC_DATA_PART_TYPE,
136+
data: {
137+
type: "nested",
138+
spec: {
139+
type: "Layout",
140+
props: {},
141+
slots: {
142+
header: [
143+
{ type: "Heading", props: { text: "Header" }, children: [] },
144+
],
145+
},
146+
},
147+
},
148+
},
149+
]);
150+
151+
expect(spec).not.toBeNull();
152+
const root = spec!.elements[spec!.root]!;
153+
expect(root.slots?.header).toHaveLength(1);
154+
expect(spec!.elements[root.slots!.header![0]!]!.type).toBe("Heading");
155+
});
131156
});
132157

133158
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)