diff --git a/apps/web/app/(main)/docs/catalog/page.mdx b/apps/web/app/(main)/docs/catalog/page.mdx index c3a17828..81f56ad3 100644 --- a/apps/web/app/(main)/docs/catalog/page.mdx +++ b/apps/web/app/(main)/docs/catalog/page.mdx @@ -70,12 +70,42 @@ Each component in the catalog has: ```typescript { props: z.object({...}), // Zod schema for props (use .nullable() for optional) - slots?: string[], // Named slots for children (e.g., ["default"]) + slots?: string[], // Named slots for children (e.g., ["default", "header", "footer"]) description?: string, // Help AI understand when to use it } ``` -Use `slots: ["default"]` for components that can contain children. The slot name corresponds to where child elements are rendered. +### Named Slots + +Components can define multiple named slots: + +```typescript +const catalog = defineCatalog(schema, { + components: { + // Simple component with just default slot + Card: { + props: z.object({ title: z.string() }), + slots: ["default"], + description: "Container card", + }, + + // Component with multiple named slots + Layout: { + props: z.object({}), + slots: ["default", "header", "footer"], + description: "Full-page layout with header, main content, and footer", + }, + }, +}); +``` + +The `slots` array lists all available slots: +- `"default"` - The main content slot, mapped from the `children` field in specs +- Other names (e.g., `"header"`, `"footer"`) - Named slots, mapped from the `slots` field in specs + +When implementing the component in your registry, you'll receive: +- `children` prop - Content for the default slot +- `slots` prop - Object with content for named slots (e.g., `{ header: ReactNode, footer: ReactNode }`) ## Generating AI Prompts diff --git a/apps/web/app/(main)/docs/registry/page.mdx b/apps/web/app/(main)/docs/registry/page.mdx index 8e817060..e0583715 100644 --- a/apps/web/app/(main)/docs/registry/page.mdx +++ b/apps/web/app/(main)/docs/registry/page.mdx @@ -70,7 +70,7 @@ Each component receives a `ComponentContext` object: interface ComponentContext { props: T; // Type-safe props from your catalog children?: React.ReactNode; // Rendered children (for slot components) - emit: (event: string) => void; // Emit a named event (always defined) + slots?: Record; // Named slots (header, footer, etc.) on: (event: string) => EventHandle; // Get event handle with metadata loading?: boolean; // Whether the renderer is in a loading state bindings?: Record; // State paths from $bindState/$bindItem expressions @@ -125,6 +125,26 @@ TextInput: ({ props, bindings }) => { `useBoundProp` returns `[resolvedValue, setter]`. The setter writes to the bound state path. If no binding exists (the prop is a literal), the setter is a no-op. +### Named Slots + +Components can define multiple named slots: + +```tsx +export const { registry } = defineRegistry(myCatalog, { + components: { + Layout: ({ children, slots }) => ( +
+
{slots?.header}
+
{children}
+
{slots?.footer}
+
+ ), + }, +}); +``` + +The `children` prop receives content from the `children` field in the spec, `slots` receives named slots content. See the [catalog documentation](/docs/catalog#named-slots) for more details. + ### Action Handlers Instead of AI generating arbitrary code, it declares *intent* by name. Your application provides the implementation. This is a core guardrail. diff --git a/apps/web/app/(main)/docs/specs/page.mdx b/apps/web/app/(main)/docs/specs/page.mdx index 4eaff495..50051698 100644 --- a/apps/web/app/(main)/docs/specs/page.mdx +++ b/apps/web/app/(main)/docs/specs/page.mdx @@ -174,13 +174,18 @@ Each element in the map has a consistent shape: { "type": "ComponentName", "props": { "label": "Hello" }, - "children": ["child-1", "child-2"] + "children": ["child-1", "child-2"], + "slots": { + "slot-1": ["child-3"], + "slot-2": ["child-4", "child-5"] + } } ``` - `type` — Component type from your catalog - `props` — Component properties -- `children` — Array of child element keys +- `children` — Array of child element keys (maps to default slot) +- `slots` — Object mapping slot names to arrays of child element keys ### Dynamic Data diff --git a/apps/web/components/playground.tsx b/apps/web/components/playground.tsx index 51c96c75..d92da1a3 100644 --- a/apps/web/components/playground.tsx +++ b/apps/web/components/playground.tsx @@ -283,22 +283,64 @@ export function Playground() { const propsStr = serializeProps(propsObj); const hasChildren = element.children && element.children.length > 0; + const hasSlots = element.slots && Object.keys(element.slots).length > 0; - if (!hasChildren) { + if (!hasChildren && !hasSlots) { return propsStr ? `${spaces}<${componentName} ${propsStr} />` : `${spaces}<${componentName} />`; } const lines: string[] = []; - lines.push( - propsStr - ? `${spaces}<${componentName} ${propsStr}>` - : `${spaces}<${componentName}>`, - ); - for (const childKey of element.children!) { - lines.push(generateJSX(childKey, indent + 1)); + // If we have slots, we need to format them as props on the opening tag + if (hasSlots) { + lines.push(`${spaces}<${componentName}`); + + // Add regular props if any + if (propsStr) { + lines.push(`${spaces} ${propsStr}`); + } + + // Add slot props + for (const [slotName, slotKeys] of Object.entries(element.slots!)) { + if (slotKeys.length === 0) continue; + + const slotChildren: string[] = []; + for (const childKey of slotKeys) { + slotChildren.push(generateJSX(childKey, 0).trim()); + } + + if (slotChildren.length === 1) { + // Single child - inline it + lines.push(`${spaces} ${slotName}={${slotChildren[0]}}`); + } else { + // Multiple children - use fragment + lines.push(`${spaces} ${slotName}={`); + lines.push(`${spaces} <>`); + for (const child of slotChildren) { + lines.push(`${spaces} ${child}`); + } + lines.push(`${spaces} `); + lines.push(`${spaces} }`); + } + } + + lines.push(`${spaces}>`); + } else { + // No slots - regular opening tag + lines.push( + propsStr + ? `${spaces}<${componentName} ${propsStr}>` + : `${spaces}<${componentName}>`, + ); + } + + // Render default children + if (hasChildren) { + for (const childKey of element.children!) { + lines.push(generateJSX(childKey, indent + 1)); + } } lines.push(`${spaces}`); diff --git a/packages/codegen/src/traverse.ts b/packages/codegen/src/traverse.ts index a65c256f..557192e4 100644 --- a/packages/codegen/src/traverse.ts +++ b/packages/codegen/src/traverse.ts @@ -37,6 +37,14 @@ export function traverseSpec( visit(childKey, depth + 1, element); } } + + if (element.slots) { + for (const slotChildren of Object.values(element.slots)) { + for (const childKey of slotChildren) { + visit(childKey, depth + 1, element); + } + } + } } visit(rootKey, 0, null); diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 214fa616..751ec0e6 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -754,14 +754,14 @@ Note: state patches appear right after the elements that use them, so the UI fil for (const [name, def] of Object.entries(components)) { const propsStr = def.props ? formatZodType(def.props) : "{}"; - const hasChildren = def.slots && def.slots.length > 0; - const childrenStr = hasChildren ? " [accepts children]" : ""; + const hasSlots = def.slots && def.slots.length > 0; + const slotsStr = hasSlots ? ` [slots: ${def.slots!.join(", ")}]` : ""; const eventsStr = def.events && def.events.length > 0 ? ` [events: ${def.events.join(", ")}]` : ""; const descStr = def.description ? ` - ${def.description}` : ""; - lines.push(`- ${name}: ${propsStr}${descStr}${childrenStr}${eventsStr}`); + lines.push(`- ${name}: ${propsStr}${descStr}${slotsStr}${eventsStr}`); } lines.push(""); } @@ -959,6 +959,7 @@ Note: state patches appear right after the elements that use them, so the UI fil "Output /state patches right after the elements that use them, one per array item for progressive loading. REQUIRED whenever using $state, $bindState, $bindItem, $item, $index, or repeat.", "ONLY use components listed above", "Each element value needs: type, props, children (array of child keys)", + "Use children for the default slot. Use slots object for named slots (e.g., slots: { header: [...], footer: [...] }). Never use slots.default - that's what children is for", "Use unique keys for the element map entries (e.g., 'header', 'metric-1', 'chart-revenue')", ] : [ @@ -968,6 +969,7 @@ Note: state patches appear right after the elements that use them, so the UI fil "Output /state patches right after the elements that use them, one per array item for progressive loading. REQUIRED whenever using $state, $bindState, $bindItem, $item, $index, or repeat.", "ONLY use components listed above", "Each element value needs: type, props, children (array of child keys)", + "Use children for the default slot. Use slots object for named slots (e.g., slots: { header: [...], footer: [...] }). Never use slots.default - that's what children is for", "Use unique keys for the element map entries (e.g., 'header', 'metric-1', 'chart-revenue')", ]; const schemaRules = catalog.schema.defaultRules ?? []; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 3815533f..7fd5c43b 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -61,8 +61,10 @@ export interface UIElement< type: T; /** Component props */ props: P; - /** Child element keys (flat structure) */ + /** Child element keys (flat structure) - maps to the 'default' slot */ children?: string[]; + /** Named slots - maps slot names to arrays of child element keys */ + slots?: Record; /** Visibility condition */ visible?: VisibilityCondition; /** Event bindings — maps event names to action bindings */ diff --git a/packages/react/package.json b/packages/react/package.json index 631f9357..43b1040a 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -57,7 +57,8 @@ "@internal/typescript-config": "workspace:*", "@types/react": "19.2.3", "tsup": "^8.0.2", - "typescript": "^5.4.5" + "typescript": "^5.4.5", + "zod": "^4.0.0" }, "peerDependencies": { "react": "^19.2.3" diff --git a/packages/react/src/catalog-types.ts b/packages/react/src/catalog-types.ts index d7187ef4..f8e38683 100644 --- a/packages/react/src/catalog-types.ts +++ b/packages/react/src/catalog-types.ts @@ -60,6 +60,7 @@ export interface EventHandle { export interface BaseComponentProps

> { props: P; children?: ReactNode; + slots?: Record; /** Simple event emitter (shorthand). Fires the event and returns void. */ emit: (event: string) => void; /** Get an event handle with metadata. Use when you need shouldPreventDefault or bound checks. */ diff --git a/packages/react/src/renderer.test.tsx b/packages/react/src/renderer.test.tsx index 7afaf7bb..213ee522 100644 --- a/packages/react/src/renderer.test.tsx +++ b/packages/react/src/renderer.test.tsx @@ -1,6 +1,14 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import React from "react"; -import { Renderer } from "./renderer"; +import { render } from "@testing-library/react"; +import { + defineRegistry, + Renderer, + JSONUIProvider, +} from "./renderer"; +import { defineCatalog, type Spec } from "@json-render/core"; +import { z } from "zod"; +import { schema } from "./schema"; describe("Renderer", () => { it("renders null for null spec", () => { @@ -41,3 +49,291 @@ describe("Renderer", () => { expect(element.props.fallback).toBe(Fallback); }); }); + +describe("Renderer - Named Slots", () => { + it("passes named slots to components", () => { + const spec: Spec = { + root: "layout", + elements: { + layout: { + type: "Layout", + props: {}, + slots: { + header: ["header-el"], + footer: ["footer-el"], + }, + children: ["main-el"], + }, + "header-el": { + type: "Text", + props: { content: "Header Content" }, + }, + "footer-el": { + type: "Text", + props: { content: "Footer Content" }, + }, + "main-el": { + type: "Text", + props: { content: "Main Content" }, + }, + }, + }; + + const catalog = defineCatalog(schema, { + components: { + Layout: { + props: z.object({}), + slots: ["header", "footer"], + description: "Layout component with header and footer slots", + }, + Text: { + props: z.object({ content: z.string() }), + slots: [], + description: "Text component", + }, + }, + actions: {}, + }); + + const { registry } = defineRegistry(catalog, { + components: { + Layout: ({ children, slots }) => ( +

+
{slots?.header}
+
{children}
+
{slots?.footer}
+
+ ), + Text: ({ props }: { props: { content: string } }) => ( + {props.content} + ), + }, + }); + + const { getByTestId, getAllByTestId } = render( + + + , + ); + + const layout = getByTestId("layout"); + expect(layout).toBeDefined(); + + const headerSlot = getByTestId("header-slot"); + expect(headerSlot.textContent).toBe("Header Content"); + + const mainSlot = getByTestId("main-slot"); + expect(mainSlot.textContent).toBe("Main Content"); + + const footerSlot = getByTestId("footer-slot"); + expect(footerSlot.textContent).toBe("Footer Content"); + + const texts = getAllByTestId("text"); + expect(texts.length).toBe(3); + }); + + it("warns when spec references undeclared slots", () => { + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const spec: Spec = { + root: "layout", + elements: { + layout: { + type: "Layout", + props: {}, + slots: { + sidebar: ["text-1"], // 'sidebar' not declared in catalog + }, + }, + "text-1": { + type: "Text", + props: { content: "Test" }, + }, + }, + }; + + const catalog = defineCatalog(schema, { + components: { + Layout: { + props: z.object({}), + slots: ["header", "footer"], // sidebar NOT included + description: "Layout component", + }, + Text: { + props: z.object({ content: z.string() }), + slots: [], + description: "Text component", + }, + }, + actions: {}, + }); + + const { registry } = defineRegistry(catalog, { + components: { + Layout: ({ slots }) =>
{slots?.sidebar}
, + Text: ({ props }: { props: { content: string } }) => ( + {props.content} + ), + }, + }); + + render( + + + , + ); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Unknown slot "sidebar" on component "Layout"'), + ); + + consoleSpy.mockRestore(); + }); + + it("warns when using slots.default", () => { + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const spec: Spec = { + root: "layout", + elements: { + layout: { + type: "Layout", + props: {}, + slots: { + default: ["text-1"], // Should warn - slots.default is not allowed + }, + children: ["text-2"], + }, + "text-1": { + type: "Text", + props: { content: "From Default Slot" }, + }, + "text-2": { + type: "Text", + props: { content: "From Children" }, + }, + }, + }; + + const catalog = defineCatalog(schema, { + components: { + Layout: { + props: z.object({}), + slots: ["header", "footer"], // default NOT included + description: "Layout component", + }, + Text: { + props: z.object({ content: z.string() }), + slots: [], + description: "Text component", + }, + }, + actions: {}, + }); + + const { registry } = defineRegistry(catalog, { + components: { + Layout: ({ children, slots }) => ( +
+
{slots?.default}
+
{children}
+
+ ), + Text: ({ props }: { props: { content: string } }) => ( + {props.content} + ), + }, + }); + + const { getByTestId } = render( + + + , + ); + + // Should warn about slots.default + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("uses slots.default"), + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Use the "children" field instead'), + ); + + const layout = getByTestId("layout"); + expect(layout.textContent).toContain("From Children"); + expect(layout.textContent).toContain("From Default Slot"); + + consoleSpy.mockRestore(); + }); + + it("warns when slot references missing element", () => { + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const spec: Spec = { + root: "layout", + elements: { + layout: { + type: "Layout", + props: {}, + slots: { + header: ["missing-header"], // This element doesn't exist + }, + children: ["text-1"], + }, + "text-1": { + type: "Text", + props: { content: "Main Content" }, + }, + }, + }; + + const catalog = defineCatalog(schema, { + components: { + Layout: { + props: z.object({}), + slots: ["header", "footer"], + description: "Layout component", + }, + Text: { + props: z.object({ content: z.string() }), + slots: [], + description: "Text component", + }, + }, + actions: {}, + }); + + const { registry } = defineRegistry(catalog, { + components: { + Layout: ({ children, slots }) => ( +
+
{slots?.header}
+
{children}
+
+ ), + Text: ({ props }: { props: { content: string } }) => ( + {props.content} + ), + }, + }); + + const { getByTestId } = render( + + + , + ); + + // Should warn about missing element in slot + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Missing element "missing-header" referenced in slot "header" of "Layout"', + ), + ); + + // Layout should still render with empty header + const layout = getByTestId("layout"); + expect(layout).toBeDefined(); + + consoleSpy.mockRestore(); + }); +}); diff --git a/packages/react/src/renderer.tsx b/packages/react/src/renderer.tsx index a397c2c8..fdb026d3 100644 --- a/packages/react/src/renderer.tsx +++ b/packages/react/src/renderer.tsx @@ -49,8 +49,10 @@ import { RepeatScopeProvider, useRepeatScope } from "./contexts/repeat-scope"; export interface ComponentRenderProps

> { /** The element being rendered */ element: UIElement; - /** Rendered children */ + /** Rendered children (default slot) */ children?: ReactNode; + /** Named slots - maps slot names to rendered children */ + slots?: Record; /** Emit a named event. The renderer resolves the event to action binding(s) from the element's `on` field. Always provided by the renderer. */ emit: (event: string) => void; /** Get an event handle with metadata (shouldPreventDefault, bound). Use when you need to inspect event bindings. */ @@ -72,6 +74,24 @@ export type ComponentRenderer

> = ComponentType< ComponentRenderProps

>; +/** + * Helper function to attach metadata (used for slot validation) to a registry. + * Metadata is stored as a non-enumerable __metadata__ property at runtime, + * but not included in the type definition to avoid index signature conflicts. + * @internal + */ +export function attachRegistryMetadata( + registry: ComponentRegistry, + metadata: { components: Record }, +): void { + Object.defineProperty(registry, "__metadata__", { + value: metadata, + writable: false, + enumerable: false, + configurable: true, + }); +} + /** * Registry of component renderers */ @@ -252,22 +272,39 @@ const ElementRenderer = React.memo(function ElementRenderer({ return null; } - // ---- Render children (with repeat support) ---- - const children = resolvedElement.repeat ? ( - - ) : ( - resolvedElement.children?.map((childKey) => { + // Validate slots + const registryMetadata = (registry as any).__metadata__; + if (resolvedElement.slots && registryMetadata) { + const componentMeta = registryMetadata.components[resolvedElement.type]; + if (componentMeta?.slots) { + const declaredSlots = new Set(componentMeta.slots); + for (const slotName of Object.keys(resolvedElement.slots)) { + if (slotName === "default") { + console.warn( + `[json-render] Component "${resolvedElement.type}" uses slots.default. ` + + `Use the "children" field instead for default slot content.`, + ); + } else if (!declaredSlots.has(slotName)) { + console.warn( + `[json-render] Unknown slot "${slotName}" on component "${resolvedElement.type}". ` + + `Available slots: ${componentMeta.slots.join(", ")}`, + ); + } + } + } + } + + // ---- Render children (with repeat support) and named slots ---- + const renderChildKeys = (childKeys: string[], slotName?: string) => { + return childKeys.map((childKey) => { const childElement = spec.elements[childKey]; if (!childElement) { if (!loading) { + const location = slotName + ? `in slot "${slotName}" of "${resolvedElement.type}"` + : `as child of "${resolvedElement.type}"`; console.warn( - `[json-render] Missing element "${childKey}" referenced as child of "${resolvedElement.type}". This element will not render.`, + `[json-render] Missing element "${childKey}" referenced ${location}. This element will not render.`, ); } return null; @@ -282,8 +319,29 @@ const ElementRenderer = React.memo(function ElementRenderer({ fallback={fallback} /> ); - }) - ); + }); + }; + + const children = resolvedElement.repeat ? ( + + ) : resolvedElement.children ? ( + renderChildKeys(resolvedElement.children) + ) : undefined; + + const slots = resolvedElement.slots + ? Object.fromEntries( + Object.entries(resolvedElement.slots).map(([slotName, childKeys]) => [ + slotName, + renderChildKeys(childKeys, slotName), + ]), + ) + : undefined; return ( @@ -293,6 +351,7 @@ const ElementRenderer = React.memo(function ElementRenderer({ on={on} bindings={elementBindings} loading={loading} + slots={slots} > {children} @@ -541,7 +600,7 @@ type DefineRegistryOptions = { * ``` */ export function defineRegistry( - _catalog: C, + catalog: C, options: DefineRegistryOptions, ): DefineRegistryResult { // Build component registry @@ -551,6 +610,7 @@ export function defineRegistry( registry[name] = ({ element, children, + slots, emit, on, bindings, @@ -559,6 +619,7 @@ export function defineRegistry( return (componentFn as DefineRegistryComponentFn)({ props: element.props, children, + slots, emit, on, bindings, @@ -568,6 +629,21 @@ export function defineRegistry( } } + // Attach metadata from catalog for slot validation + const catalogData = catalog.data as { + components?: Record; + }; + if (catalogData.components) { + attachRegistryMetadata(registry, { + components: Object.fromEntries( + Object.entries(catalogData.components).map(([name, def]) => [ + name, + { slots: def.slots }, + ]), + ), + }); + } + // Build action helpers const actionMap = options.actions ? (Object.entries(options.actions) as Array< @@ -616,6 +692,7 @@ export function defineRegistry( type DefineRegistryComponentFn = (ctx: { props: unknown; children?: React.ReactNode; + slots?: Record; emit: (event: string) => void; on: (event: string) => EventHandle; bindings?: Record; diff --git a/packages/react/src/schema.ts b/packages/react/src/schema.ts index 85c2fc3f..8f3eb46e 100644 --- a/packages/react/src/schema.ts +++ b/packages/react/src/schema.ts @@ -20,8 +20,10 @@ export const schema = defineSchema( type: s.ref("catalog.components"), /** Component props */ props: s.propsOf("catalog.components"), - /** Child element keys (flat reference) */ + /** Child element keys (flat reference) - maps to the 'default' slot */ children: s.array(s.string()), + /** Named slots - maps slot names to arrays of child element keys */ + slots: s.record(s.array(s.string())), /** Visibility condition */ visible: s.any(), }), @@ -73,6 +75,9 @@ export const schema = defineSchema( "CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist. A missing child element causes that entire branch of the UI to be invisible.", "SELF-CHECK: After generating all elements, mentally walk the tree from root. Every key in every children array must resolve to a defined element. If you find a gap, output the missing element immediately.", + // Named slots + 'Components may declare named slots in their catalog definition (e.g., slots: ["header", "footer"]). Example: {"type":"Layout","props":{},"slots":{"header":["h1"],"footer":["f1"]},"children":["main1"]}. Verify slot names exist in the component\'s catalog definition and all referenced child element keys exist.', + // Field placement 'CRITICAL: The "visible" field goes on the ELEMENT object, NOT inside "props". Correct: {"type":"","props":{},"visible":{"$state":"/tab","eq":"home"},"children":[...]}.', 'CRITICAL: The "on" field goes on the ELEMENT object, NOT inside "props". Use on.press, on.change, on.submit etc. NEVER put action/actionParams inside props.', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8552ab30..a2b2579c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -907,6 +907,9 @@ importers: typescript: specifier: ^5.4.5 version: 5.9.2 + zod: + specifier: ^4.0.0 + version: 4.3.5 packages/react-native: dependencies: diff --git a/skills/json-render-react/SKILL.md b/skills/json-render-react/SKILL.md index 6e824bb0..d8130a9b 100644 --- a/skills/json-render-react/SKILL.md +++ b/skills/json-render-react/SKILL.md @@ -44,8 +44,14 @@ export const catalog = defineCatalog(schema, { }, Card: { props: z.object({ title: z.string() }), + slots: ["default"], description: "Card container with title", }, + Layout: { + props: z.object({}), + slots: ["default", "header", "footer"], + description: "Full-page layout with named slots", + }, }, }); @@ -61,6 +67,13 @@ const { registry } = defineRegistry(catalog, { {children} ), + Layout: ({ children, slots }) => ( +

+
{slots?.header}
+
{children}
+
{slots?.footer}
+
+ ), }, }); ``` @@ -81,6 +94,80 @@ The React schema uses an element tree format: } ``` +## Named Slots + +Components can define multiple named slots for flexible content placement. The `children` field maps to the default slot, while the `slots` field provides explicit slot assignments. + +### Defining Components with Named Slots + +```typescript +// In catalog - declare available slots +export const catalog = defineCatalog(schema, { + components: { + Layout: { + props: z.object({}), + slots: ["default", "header", "footer"], + description: "Layout with header, main content, and footer", + }, + }, +}); + +// In registry - receive slots via props +const { registry } = defineRegistry(catalog, { + components: { + Layout: ({ children, slots }) => ( +
+
{slots?.header}
+
{children}
+ +
+ ), + }, +}); +``` + +### Using Named Slots in Specs + +```json +{ + "root": "layout", + "elements": { + "layout": { + "type": "Layout", + "props": {}, + "slots": { + "header": ["nav-bar"], + "footer": ["footer-content", "footer-copyright"] + }, + "children": ["main-content"] + }, + "nav-bar": { + "type": "Text", + "props": { "text": "Navigation" } + }, + "main-content": { + "type": "Text", + "props": { "text": "Main content" } + }, + "footer-content": { + "type": "Text", + "props": { "text": "Footer" } + }, + "footer-copyright": { + "type": "Text", + "props": { "text": "© 2026" } + } + } +} +``` + +### Slot Assignment Rules + +- **`children`** → Maps to the default slot (always use this for default content) +- **`slots.header`, `slots.footer`, etc.** → Named slots for specific placement +- **Never use `slots.default`** → This is incorrect; use `children` instead +- Components receive `children` prop (default slot) and `slots` prop (named slots) + ## Visibility Conditions Use `visible` on elements to show/hide based on state. New syntax: `{ "$state": "/path" }`, `{ "$state": "/path", "eq": value }`, `{ "$state": "/path", "not": true }`, `{ "$and": [cond1, cond2] }` for AND, `{ "$or": [cond1, cond2] }` for OR. Helpers: `visibility.when("/path")`, `visibility.unless("/path")`, `visibility.eq("/path", val)`, `visibility.and(cond1, cond2)`, `visibility.or(cond1, cond2)`.