diff --git a/README.md b/README.md index 67055d8..29ea76d 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ component documentation, and published React packages. - `packages/json-schema-to-blocks` — the `json-schema-to-blocks` lowering of JSON Schema into those documents. - `packages/meta-to-blocks` — the `meta-to-blocks` lowering of database metadata into generated form, list, and detail documents. - `packages/flow-to-blocks` — the `flow-to-blocks` evaluation of a flow graph into a computed document. +- `packages/blocks-ui` — the `@constructive-io/blocks-ui` default registry: Constructive document node types wired to `@constructive-io/ui` components. The documentation site is published at . Registry JSON is served from diff --git a/apps/blocks/package.json b/apps/blocks/package.json index 252382f..9b755b3 100644 --- a/apps/blocks/package.json +++ b/apps/blocks/package.json @@ -33,6 +33,7 @@ "@ai-sdk/openai-compatible": "^3.0.9", "@ai-sdk/react": "^4.0.27", "@base-ui/react": "^1.0.0", + "@constructive-io/blocks-ui": "workspace:*", "@constructive-io/command-palette": "workspace:*", "@constructive-io/data": "workspace:*", "@constructive-io/graphql-types": "^3.4.3", @@ -42,10 +43,13 @@ "@tanstack/react-form": "^1.27.7", "@tanstack/react-query": "^5.90.16", "ai": "^7.0.26", + "blocks-renderer": "workspace:*", + "blocks-schema": "workspace:*", "clsx": "^2.1.1", "dompurify": "^3.3.1", "gql-ast": "^3.3.3", "graphql": "16.13.0", + "json-schema-to-blocks": "workspace:*", "lucide-react": "^0.525.0", "marked": "^16.4.2", "motion": "^12.40.0", diff --git a/apps/blocks/src/app/blocks/documents/page.tsx b/apps/blocks/src/app/blocks/documents/page.tsx new file mode 100644 index 0000000..8c31011 --- /dev/null +++ b/apps/blocks/src/app/blocks/documents/page.tsx @@ -0,0 +1,109 @@ +import type { Metadata } from 'next'; + +import { CodeBlock } from '@/components/docs/code-block'; +import { DocSection } from '@/components/docs/doc-section'; +import { DocumentFormDemo } from '@/components/documents-showcase/document-form-demo'; +import { OG_IMAGE, withBase } from '@/lib/site'; + +const TITLE = 'JSON documents'; +const DESCRIPTION = + 'Render a declarative JSON UI document with the default widget registry: JSON Schema, database metadata, or an agent tool produces the document, and no page hand-writes the form.'; + +const INSTALL = `pnpm add blocks-schema blocks-renderer json-schema-to-blocks @constructive-io/blocks-ui`; + +const USAGE = `'use client'; + +import { DocumentRenderer } from 'blocks-renderer'; +import { defaultBlockRegistry } from '@constructive-io/blocks-ui'; +import { schemaToDocument } from 'json-schema-to-blocks'; + +const document = schemaToDocument({ + type: 'object', + required: ['title'], + properties: { + title: { type: 'string', maxLength: 120 }, + status: { type: 'string', enum: ['draft', 'review', 'published'] }, + featured: { type: 'boolean' } + } +}); + +export function PostForm() { + return ( + save(values)} + /> + ); +}`; + +const OVERRIDE = `import { composeRegistry } from 'blocks-renderer'; +import { defaultBlockRegistry } from '@constructive-io/blocks-ui'; + +// Layer over the default registry one node type at a time. +const registry = composeRegistry(defaultBlockRegistry, { + Select: MyCombobox, + DataTable: MyDataTable +});`; + +export const metadata: Metadata = { + title: TITLE, + description: DESCRIPTION, + alternates: { canonical: withBase('/blocks/documents') }, + openGraph: { + title: TITLE, + description: DESCRIPTION, + url: withBase('/blocks/documents'), + images: [OG_IMAGE], + }, +}; + +export default function DocumentsPage() { + return ( +
+
+

Documents

+

+ {TITLE} +

+

+ {DESCRIPTION} +

+
+ + + + {INSTALL} + + + + + + {USAGE} + + + + + + + + + + {OVERRIDE} + + +
+ ); +} diff --git a/apps/blocks/src/app/blocks/page.tsx b/apps/blocks/src/app/blocks/page.tsx index fe79855..7230aa1 100644 --- a/apps/blocks/src/app/blocks/page.tsx +++ b/apps/blocks/src/app/blocks/page.tsx @@ -34,6 +34,12 @@ const APPLICATION_CATALOG = [ description: 'A full-page console driven by injected endpoints, session state, adapters, and versioned _meta.', }, + { + href: '/blocks/documents', + title: 'JSON documents', + description: + 'Declarative JSON UI documents rendered by the default widget registry, generated from JSON Schema or database metadata.', + }, { href: '/blocks/ai', title: 'AI', diff --git a/apps/blocks/src/app/llms.txt/route.test.ts b/apps/blocks/src/app/llms.txt/route.test.ts index 7682941..479dda3 100644 --- a/apps/blocks/src/app/llms.txt/route.test.ts +++ b/apps/blocks/src/app/llms.txt/route.test.ts @@ -29,6 +29,7 @@ describe('llms.txt', () => { '/blocks/billing/', '/blocks/features/', '/blocks/console-kit/', + '/blocks/documents/', ]) { expect(source).toContain( `https://constructive-io.github.io/blocks${route}`, diff --git a/apps/blocks/src/app/llms.txt/route.ts b/apps/blocks/src/app/llms.txt/route.ts index d668603..dd76628 100644 --- a/apps/blocks/src/app/llms.txt/route.ts +++ b/apps/blocks/src/app/llms.txt/route.ts @@ -52,6 +52,12 @@ const surfaceLinks: readonly SurfaceLink[] = [ description: 'Composable tenant-console roots, modules, presets, endpoint contracts, and integration diagnostics.', }, + { + title: 'JSON documents', + path: '/blocks/documents/', + description: + 'Declarative JSON UI documents plus the default widget registry that renders them, generated from JSON Schema or database metadata.', + }, { title: 'Primitives and bundles', path: '/blocks/', diff --git a/apps/blocks/src/app/sitemap.ts b/apps/blocks/src/app/sitemap.ts index 6d8aadc..785e62e 100644 --- a/apps/blocks/src/app/sitemap.ts +++ b/apps/blocks/src/app/sitemap.ts @@ -23,6 +23,7 @@ export default function sitemap(): MetadataRoute.Sitemap { ...SOURCE_BLOCKS.map(({ name }) => `/blocks/${name}`), ...APPLICATION_BLOCKS.map(({ name }) => `/blocks/${name}`), '/blocks/console-kit', + '/blocks/documents', ...BASE_PRIMITIVES.map(({ name }) => `/blocks/ui/${name}`), '/blocks/billing', ...BILLING_BLOCKS.map(({ name }) => `/blocks/billing/${name}`), @@ -42,6 +43,7 @@ export default function sitemap(): MetadataRoute.Sitemap { path === '/blocks/ai' || SOURCE_BLOCKS.some(({ name }) => path === `/blocks/${name}`) || path === '/blocks/console-kit' || + path === '/blocks/documents' || path === '/blocks/billing' ? 0.9 : 0.7, diff --git a/apps/blocks/src/components/documents-showcase/document-form-demo.tsx b/apps/blocks/src/components/documents-showcase/document-form-demo.tsx new file mode 100644 index 0000000..2f1bba2 --- /dev/null +++ b/apps/blocks/src/components/documents-showcase/document-form-demo.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { defaultBlockRegistry } from '@constructive-io/blocks-ui'; +import { Card, CardContent } from '@constructive-io/ui'; +import { DocumentRenderer } from 'blocks-renderer'; +import type { JSONSchema } from 'json-schema-to-blocks'; +import { schemaToDocument } from 'json-schema-to-blocks'; +import { useMemo, useState } from 'react'; + +/** + * The JSON Schema is the only input: `json-schema-to-blocks` lowers it to a + * document and the default registry renders it, so this page hand-writes no UI + * for any of the fields below. + */ +const POST_SCHEMA: JSONSchema = { + $id: 'post', + type: 'object', + title: 'Publish a post', + required: ['title', 'status'], + properties: { + title: { type: 'string', title: 'Title', maxLength: 120 }, + slug: { type: 'string', title: 'Slug', pattern: '^[a-z0-9-]+$' }, + summary: { type: 'string', title: 'Summary', maxLength: 400 }, + status: { + type: 'string', + title: 'Status', + enum: ['draft', 'in_review', 'published', 'archived'], + }, + reading_time: { + type: 'integer', + title: 'Reading time (minutes)', + minimum: 1, + maximum: 120, + }, + publish_at: { type: 'string', title: 'Publish at', format: 'date-time' }, + featured: { type: 'boolean', title: 'Featured' }, + }, +}; + +export function DocumentFormDemo() { + const document = useMemo( + () => schemaToDocument(POST_SCHEMA), + [], + ); + const [submitted, setSubmitted] = useState | null>( + null, + ); + + return ( +
+ + + + + +
+
+

Generated document

+
+            {JSON.stringify(document, null, 2)}
+          
+
+
+

Submitted values

+
+            {submitted
+              ? JSON.stringify(submitted, null, 2)
+              : 'Submit the form. Validation comes from the document constraints, not from this page.'}
+          
+
+
+
+ ); +} diff --git a/apps/blocks/src/components/site/site-sidebar.tsx b/apps/blocks/src/components/site/site-sidebar.tsx index 6c58c06..36f77cb 100644 --- a/apps/blocks/src/components/site/site-sidebar.tsx +++ b/apps/blocks/src/components/site/site-sidebar.tsx @@ -294,6 +294,11 @@ export const SiteSidebar = forwardRef(function Si Console Kit +
  • + + JSON documents + +
  • diff --git a/apps/blocks/src/components/site/site-topbar.tsx b/apps/blocks/src/components/site/site-topbar.tsx index 2a5cee7..9877720 100644 --- a/apps/blocks/src/components/site/site-topbar.tsx +++ b/apps/blocks/src/components/site/site-topbar.tsx @@ -33,6 +33,7 @@ function crumbFor(path: string): string { return pack ? `${pack.title} feature pack` : 'Feature packs'; } if (p === '/blocks/console-kit') return 'Console Kit'; + if (p === '/blocks/documents') return 'JSON documents'; if (p === '/blocks/command-palette') return 'Command Palette'; if (p === '/blocks/ai') return 'AI'; if (p.startsWith('/blocks/ai/')) { diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 2adb78b..9a74f80 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -32,6 +32,11 @@ so their entry points are root-level files and consumers get deep imports layout in an isolated consumer, including packed dependents resolving the packed schema. +`@constructive-io/blocks-ui` is a React package built with `tsup`, so it keeps +the scoped-package layout (`dist` plus an exports map) rather than publishing +from `dist`. It peer-depends on `@constructive-io/ui`, `blocks-renderer`, and +`blocks-schema`, so publish those first. + ## Verify the exact publish inputs From the validated tag checkout: diff --git a/package.json b/package.json index 1215999..d047309 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ }, "scripts": { "build": "lerna run build", - "build:packages": "pnpm --filter @constructive-io/ui build && pnpm --filter @constructive-io/data build && pnpm --filter @constructive-io/command-palette build && pnpm --filter @constructive-io/sheets build && pnpm --filter @constructive-io/schema-builder build && pnpm --filter json-renderer build && pnpm --filter blocks-schema build && pnpm --filter blocks-renderer build && pnpm --filter json-schema-to-blocks build && pnpm --filter meta-to-blocks build && pnpm --filter flow-to-blocks build", + "build:packages": "pnpm --filter @constructive-io/ui build && pnpm --filter @constructive-io/data build && pnpm --filter @constructive-io/command-palette build && pnpm --filter @constructive-io/sheets build && pnpm --filter @constructive-io/schema-builder build && pnpm --filter json-renderer build && pnpm --filter blocks-schema build && pnpm --filter blocks-renderer build && pnpm --filter json-schema-to-blocks build && pnpm --filter meta-to-blocks build && pnpm --filter flow-to-blocks build && pnpm --filter @constructive-io/blocks-ui build", "build:registry": "pnpm --filter @constructive-io/registry build && pnpm check:console-kit-inspector", "build:pages": "pnpm build:packages && pnpm build:registry && pnpm --filter blocks build:pages && pnpm pages:artifact", "build:storybook": "pnpm --filter @constructive-io/ui build-sb", diff --git a/packages/blocks-ui/LICENSE b/packages/blocks-ui/LICENSE new file mode 100644 index 0000000..c702001 --- /dev/null +++ b/packages/blocks-ui/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Constructive + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/blocks-ui/README.md b/packages/blocks-ui/README.md new file mode 100644 index 0000000..a945f3a --- /dev/null +++ b/packages/blocks-ui/README.md @@ -0,0 +1,117 @@ +# @constructive-io/blocks-ui + +

    + +

    + +

    + + + + + +

    + +The default widget registry for [`blocks-renderer`](../blocks-renderer): every +Constructive document node type wired to a `@constructive-io/ui` component. + +`blocks-renderer` walks a `UIDocument` and asks a registry "what renders a +`Select`?". This package answers that question so a generated document renders +without any host wiring — and, because it is a plain `type → component` map, it +doubles as the worked example for writing your own. + +```bash +pnpm add @constructive-io/blocks-ui +``` + +```tsx +'use client'; + +import { DocumentRenderer } from 'blocks-renderer'; +import { defaultBlockRegistry } from '@constructive-io/blocks-ui'; +import { schemaToDocument } from 'json-schema-to-blocks'; + +const document = schemaToDocument({ + type: 'object', + required: ['title'], + properties: { + title: { type: 'string', maxLength: 120 }, + status: { type: 'string', enum: ['draft', 'review', 'live'] }, + featured: { type: 'boolean' } + } +}); + +export function PostForm() { + return ( + console.log(values)} + /> + ); +} +``` + +## Replacing components + +The registry is data, so layer over it instead of forking it. `composeRegistry` +applies the later registry on top, type by type: + +```tsx +import { composeRegistry } from 'blocks-renderer'; +import { defaultBlockRegistry } from '@constructive-io/blocks-ui'; + +const registry = composeRegistry(defaultBlockRegistry, { + Select: MyCombobox, + DataTable: MyDataTable +}); +``` + +Take a subset if the page chrome is yours: `widgetRegistry` (the form +controls), `containerRegistry` (`Page`, `Form`, `Section`, `Grid`, `Tabs`), and +`blockRegistry` (`Button`, `ActionBar`, `Markdown`, `StatCard`) are exported +separately. + +Writing an adapter from scratch needs nothing from this package — a registry is +`Record>`. `useNodeField` and `FieldShell` are +exported for reuse, and reading them is the fastest way to see how a node's +`name`, `required`, and `constraints` become renderer form state. + +## What is intentionally not registered + +`DataTable`, `DetailPanel`, `RelationList`, `Chart`, and `AgentChat` need a +query runtime and a data source, so they are left out and fall back to the +renderer's visible unknown-node placeholder. Register them with your own +data-bound components (for example against `@constructive-io/data`). + +`CodeEditor`, `MarkdownEditor`, and `JsonEditor` render as a monospace textarea: +a real editor is a heavy dependency, so it belongs in the host that wants it. +`FileUpload` records the selected file name only — the byte upload needs your +storage adapter. + +## Form state + +Widgets own no state. Each one reads and writes the `DocumentRenderer` context +through `useBlockField`, so validation, defaults, and submission stay with the +renderer and the document's declared constraints: + +```tsx +export function MyInput({ props }: BlockProps) { + const field = useNodeField(props); + + return ( + + field.setValue(event.target.value)} + disabled={field.disabled} + /> + + ); +} +``` + +## License + +MIT diff --git a/packages/blocks-ui/package.json b/packages/blocks-ui/package.json new file mode 100644 index 0000000..d484e70 --- /dev/null +++ b/packages/blocks-ui/package.json @@ -0,0 +1,80 @@ +{ + "name": "@constructive-io/blocks-ui", + "version": "0.1.0", + "description": "Default widget registry for blocks-renderer: Constructive UI document node types wired to @constructive-io/ui components", + "keywords": [ + "react", + "registry", + "ui-schema", + "blocks", + "renderer", + "form" + ], + "author": "Constructive", + "license": "MIT", + "homepage": "https://constructive-io.github.io/blocks/", + "repository": { + "type": "git", + "url": "git+https://github.com/constructive-io/blocks.git", + "directory": "packages/blocks-ui" + }, + "bugs": { + "url": "https://github.com/constructive-io/blocks/issues" + }, + "publishConfig": { + "access": "public" + }, + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "lint:types": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "clean": "rm -rf dist" + }, + "peerDependencies": { + "@constructive-io/ui": ">=0.12.0", + "blocks-renderer": ">=0.4.0", + "blocks-schema": ">=0.5.0", + "react": "^18.0.0 || ^19.0.0" + }, + "devDependencies": { + "@base-ui/react": "^1.0.0", + "@constructive-io/ui": "workspace:*", + "@testing-library/react": "^16.3.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "blocks-renderer": "workspace:^", + "blocks-schema": "workspace:^", + "jsdom": "^26.1.0", + "json-schema-to-blocks": "workspace:^", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "tsup": "^8.5.1", + "typescript": "^5.9.3", + "vitest": "^3.2.4" + }, + "engines": { + "node": ">=24.0.0" + } +} diff --git a/packages/blocks-ui/src/__tests__/registry.test.tsx b/packages/blocks-ui/src/__tests__/registry.test.tsx new file mode 100644 index 0000000..f6f9d20 --- /dev/null +++ b/packages/blocks-ui/src/__tests__/registry.test.tsx @@ -0,0 +1,139 @@ +import { schemaToDocument } from 'json-schema-to-blocks'; +import { composeRegistry, DocumentRenderer, missingTypes } from 'blocks-renderer'; +import { walkNodes } from 'blocks-schema'; +import type { UIDocument, UINode } from 'blocks-schema'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { defaultBlockRegistry, widgetRegistry } from '../registry'; + +function doc(page: UINode): UIDocument { + return { formatVersion: '1.0', type: 'UISchema', id: 'doc-1', page }; +} + +function form(...children: UINode[]): UIDocument { + return doc({ + type: 'Page', + key: 'page', + props: { title: 'New post' }, + children: [{ type: 'Form', key: 'form', props: {}, children }], + }); +} + +function field(type: string, props: Record): UINode { + return { type, key: String(props.name ?? 'field'), props, children: [] }; +} + +describe('defaultBlockRegistry', () => { + it('covers every widget and container type a generated document can use', () => { + const document = schemaToDocument({ + $id: 'post', + type: 'object', + required: ['title'], + properties: { + title: { type: 'string', maxLength: 120 }, + body: { type: 'string' }, + status: { type: 'string', enum: ['draft', 'review', 'live', 'archived'] }, + visibility: { type: 'string', enum: ['public', 'private'] }, + published_at: { type: 'string', format: 'date-time' }, + reading_time: { type: 'integer' }, + featured: { type: 'boolean' }, + attachment: { type: 'string', format: 'data-url' }, + metadata: { type: 'object' }, + }, + }); + + const used = [...walkNodes(document.page)].map((node) => node.type); + expect(missingTypes(defaultBlockRegistry, used)).toEqual([]); + }); + + it('renders a generated document as a working form', () => { + const document = schemaToDocument({ + $id: 'post', + type: 'object', + required: ['title'], + properties: { + title: { type: 'string', maxLength: 120, title: 'Title' }, + featured: { type: 'boolean', title: 'Featured' }, + }, + }); + + render(); + + expect(screen.getByLabelText(/Title/)).toBeDefined(); + expect(screen.getByText('Featured')).toBeDefined(); + expect(screen.getByRole('button', { name: 'Submit' })).toBeDefined(); + }); + + it('reads and writes the renderer form state rather than owning it', () => { + const onChange = vi.fn(); + render( + , + ); + + const input = screen.getByLabelText(/Title/) as HTMLInputElement; + expect(input.value).toBe('Draft'); + + fireEvent.change(input, { target: { value: 'Published' } }); + + expect(onChange).toHaveBeenCalledWith({ title: 'Published' }); + }); + + it('submits through the renderer, which validates the document constraints first', () => { + const onSubmit = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Submit' })); + expect(onSubmit).not.toHaveBeenCalled(); + expect(screen.getByText(/required/i)).toBeDefined(); + }); + + it('renders a hidden field as nothing while keeping it in the document', () => { + render( + , + ); + + expect(screen.queryByLabelText(/Tenant/)).toBeNull(); + }); + + it('marks every widget disabled in edit mode, where the document is the subject', () => { + render( + , + ); + + expect((screen.getByLabelText(/Title/) as HTMLInputElement).disabled).toBe(true); + }); + + it('lets a host override one type without forking the set', () => { + const registry = composeRegistry(defaultBlockRegistry, { + Input: ({ props }) => , + }); + + render(); + + expect(document.querySelector('[data-custom="yes"]')).not.toBeNull(); + }); + + it('leaves data blocks unregistered, so an unsatisfied node stays visible', () => { + expect(widgetRegistry.DataTable).toBeUndefined(); + expect(missingTypes(defaultBlockRegistry, ['DataTable'])).toEqual(['DataTable']); + }); +}); diff --git a/packages/blocks-ui/src/blocks.tsx b/packages/blocks-ui/src/blocks.tsx new file mode 100644 index 0000000..2fa3730 --- /dev/null +++ b/packages/blocks-ui/src/blocks.tsx @@ -0,0 +1,84 @@ +'use client'; + +/** + * Document-level blocks that need no data source. The data blocks + * (`DataTable`, `DetailPanel`, `RelationList`, `Chart`, `AgentChat`) are + * deliberately absent: they need a query runtime, so they fall back to the + * renderer's visible `UnknownBlock` until a host registers them. + */ + +import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@constructive-io/ui'; +import { useRenderer } from 'blocks-renderer'; +import type { BlockProps } from 'blocks-renderer'; +import type { UINodeProps } from 'blocks-schema'; + +function text(props: UINodeProps, ...keys: string[]): string | undefined { + for (const key of keys) { + const value = props[key]; + if (typeof value === 'string') return value; + } + return undefined; +} + +const VARIANTS = new Set(['default', 'destructive', 'outline', 'secondary', 'ghost', 'link']); + +/** + * A button either submits its form or fires its `click` action; the renderer + * decides what an action means, so this block only reports the event. + */ +export function ButtonBlock({ node, props }: BlockProps) { + const { onAction } = useRenderer(); + const label = text(props, 'text', 'label') ?? 'Button'; + const variant = text(props, 'variant'); + const submits = props.type === 'submit' || props.submit === true; + const action = node.actions?.click; + + return ( + + ); +} + +export function ActionBarBlock({ props, children }: BlockProps) { + return ( +
    + {children} +
    + ); +} + +/** + * Markdown source is rendered as pre-wrapped text rather than parsed: a parser + * is a dependency and an XSS surface, and a host that wants one registers it. + */ +export function MarkdownBlock({ props }: BlockProps) { + const content = text(props, 'content', 'text', 'markdown') ?? ''; + + return
    {content}
    ; +} + +export function StatCardBlock({ props }: BlockProps) { + const label = text(props, 'label', 'title') ?? ''; + const value = props.value; + const description = text(props, 'description'); + + return ( + + + {label} + {description && {description}} + + + + {value === null || value === undefined ? '—' : String(value)} + + + + ); +} diff --git a/packages/blocks-ui/src/containers.tsx b/packages/blocks-ui/src/containers.tsx new file mode 100644 index 0000000..f78a90a --- /dev/null +++ b/packages/blocks-ui/src/containers.tsx @@ -0,0 +1,125 @@ +'use client'; + +/** + * Container nodes: layout plus, for `Form`, the submit path. Children arrive + * already rendered, so a container only positions them — except `Tabs`, which + * needs its children's labels to build triggers and therefore renders them + * itself through {@link BlockRenderer}. + */ + +import { Button, Card, CardContent, CardHeader, CardTitle, Tabs, TabsContent, TabsList, TabsTrigger } from '@constructive-io/ui'; +import { BlockRenderer, useRenderer } from 'blocks-renderer'; +import type { BlockProps } from 'blocks-renderer'; +import type { UINodeProps } from 'blocks-schema'; + +function text(props: UINodeProps, key: string): string | undefined { + const value = props[key]; + return typeof value === 'string' ? value : undefined; +} + +export function PageBlock({ props, children }: BlockProps) { + const title = text(props, 'title'); + const description = text(props, 'description'); + + return ( +
    + {(title || description) && ( +
    + {title &&

    {title}

    } + {description &&

    {description}

    } +
    + )} + {children} +
    + ); +} + +/** + * Submitting delegates to the renderer: it validates every field against the + * document's constraints before calling the host's `onSubmit`, so this component + * never inspects values itself. + */ +export function FormBlock({ node, props, children }: BlockProps) { + const { onAction } = useRenderer(); + const submitLabel = text(props, 'submitLabel') ?? (props.mode === 'update' ? 'Save' : 'Submit'); + + return ( +
    { + event.preventDefault(); + onAction?.(node.actions?.submit ?? { type: 'handler', handler: 'submit' }, 'submit'); + }} + > + {children} +
    + +
    +
    + ); +} + +export function SectionBlock({ props, children }: BlockProps) { + const title = text(props, 'title'); + + if (!title) { + return
    {children}
    ; + } + + return ( + + + {title} + + {children} + + ); +} + +/** `columns` is a count, so the grid stays declarative rather than class-driven. */ +export function GridBlock({ props, children }: BlockProps) { + const columns = typeof props.columns === 'number' ? Math.min(Math.max(props.columns, 1), 4) : 2; + const template = ['grid-cols-1', 'md:grid-cols-2', 'md:grid-cols-3', 'md:grid-cols-4'][columns - 1]; + + return
    {children}
    ; +} + +// Spelled out rather than interpolated, because Tailwind only ships classes it +// can see in the source. +const SPANS = ['', 'md:col-span-2', 'md:col-span-3', 'md:col-span-4']; + +export function GridColumnBlock({ props, children }: BlockProps) { + const span = typeof props.span === 'number' ? Math.min(Math.max(props.span, 1), 4) : 1; + + return
    {children}
    ; +} + +export function TabsBlock({ node }: BlockProps) { + const tabs = node.children ?? []; + if (tabs.length === 0) return null; + + const first = tabs[0]!.key; + + return ( + + + {tabs.map((tab) => ( + + {text(tab.props, 'label') ?? text(tab.props, 'title') ?? tab.key} + + ))} + + {tabs.map((tab) => ( + + + + ))} + + ); +} + +/** Rendered inside a `TabsContent` by {@link TabsBlock}; it owns no chrome. */ +export function TabBlock({ children }: BlockProps) { + return
    {children}
    ; +} diff --git a/packages/blocks-ui/src/field.tsx b/packages/blocks-ui/src/field.tsx new file mode 100644 index 0000000..63a0be4 --- /dev/null +++ b/packages/blocks-ui/src/field.tsx @@ -0,0 +1,90 @@ +'use client'; + +/** + * The field plumbing every widget shares: a node's props are presentation + * (`label`, `description`, `required`), while its value lives in the renderer's + * form state keyed by `props.name`. Widgets therefore never own state; they read + * and write the document's. + */ + +import { Field } from '@constructive-io/ui'; +import { useBlockField } from 'blocks-renderer'; +import type { UINodeProps } from 'blocks-schema'; +import { useId } from 'react'; +import type { ReactNode } from 'react'; + +/** The value/error wiring for one widget node, plus a stable control id. */ +export function useNodeField(props: UINodeProps) { + const name = typeof props.name === 'string' ? props.name : undefined; + const { value, error, setValue, mode } = useBlockField(name); + const id = useId(); + + return { + name, + id, + value, + error, + setValue, + /** `edit` is the authoring surface, so widgets are inert there. */ + disabled: Boolean(props.disabled) || mode === 'edit', + required: Boolean(props.required), + placeholder: typeof props.placeholder === 'string' ? props.placeholder : undefined, + }; +} + +export interface FieldShellProps { + props: UINodeProps; + id: string; + error?: string; + children: ReactNode; +} + +/** + * Label, description, and error chrome around a control. `hidden` fields stay + * in the document (they still carry a value) but render nothing. + */ +export function FieldShell({ props, id, error, children }: FieldShellProps) { + if (props.hidden) return null; + + const label = typeof props.label === 'string' ? props.label : (props.name as string | undefined) ?? ''; + + return ( + + {children} + + ); +} + +/** `{ label, value }[]`, however the document spelled the options. */ +export interface WidgetOption { + label: string; + value: string; +} + +export function readOptions(props: UINodeProps): WidgetOption[] { + const options = props.options; + if (!Array.isArray(options)) return []; + + return options.map((option) => { + if (option !== null && typeof option === 'object') { + const entry = option as { label?: unknown; value?: unknown }; + const value = String(entry.value ?? ''); + return { label: String(entry.label ?? value), value }; + } + const value = String(option ?? ''); + return { label: value, value }; + }); +} + +/** Controlled inputs cannot take `undefined`, and `null` means "cleared". */ +export function textValue(value: unknown): string { + if (value === undefined || value === null) return ''; + return typeof value === 'string' ? value : String(value); +} diff --git a/packages/blocks-ui/src/index.ts b/packages/blocks-ui/src/index.ts new file mode 100644 index 0000000..4cb110b --- /dev/null +++ b/packages/blocks-ui/src/index.ts @@ -0,0 +1,28 @@ +export { blockRegistry, containerRegistry, defaultBlockRegistry, widgetRegistry } from './registry'; +export { FieldShell, readOptions, textValue, useNodeField } from './field'; +export type { FieldShellProps, WidgetOption } from './field'; +export { ActionBarBlock, ButtonBlock, MarkdownBlock, StatCardBlock } from './blocks'; +export { + FormBlock, + GridBlock, + GridColumnBlock, + PageBlock, + SectionBlock, + TabBlock, + TabsBlock, +} from './containers'; +export { + CheckboxBlock, + CodeBlock, + DatePickerBlock, + DateTimePickerBlock, + FileUploadBlock, + InputBlock, + NumberInputBlock, + PhoneInputBlock, + RadioGroupBlock, + SelectBlock, + SwitchBlock, + TextareaBlock, + TimePickerBlock, +} from './widgets'; diff --git a/packages/blocks-ui/src/registry.ts b/packages/blocks-ui/src/registry.ts new file mode 100644 index 0000000..d350f4c --- /dev/null +++ b/packages/blocks-ui/src/registry.ts @@ -0,0 +1,82 @@ +/** + * The default registry: Constructive node types → components. + * + * It is split into three layers so a host can take only what it wants — the + * widgets without the page chrome, say — and compose the rest itself. + */ + +import type { BlockRegistry } from 'blocks-renderer'; + +import { ActionBarBlock, ButtonBlock, MarkdownBlock, StatCardBlock } from './blocks'; +import { + FormBlock, + GridBlock, + GridColumnBlock, + PageBlock, + SectionBlock, + TabBlock, + TabsBlock, +} from './containers'; +import { + CheckboxBlock, + CodeBlock, + DatePickerBlock, + DateTimePickerBlock, + FileUploadBlock, + InputBlock, + NumberInputBlock, + PhoneInputBlock, + RadioGroupBlock, + SelectBlock, + SwitchBlock, + TextareaBlock, + TimePickerBlock, +} from './widgets'; + +/** Every field widget in `WIDGET_NODE_TYPES`. */ +export const widgetRegistry: BlockRegistry = { + Input: InputBlock, + Textarea: TextareaBlock, + Select: SelectBlock, + RadioGroup: RadioGroupBlock, + Checkbox: CheckboxBlock, + Switch: SwitchBlock, + NumberInput: NumberInputBlock, + DatePicker: DatePickerBlock, + DateTimePicker: DateTimePickerBlock, + TimePicker: TimePickerBlock, + PhoneInput: PhoneInputBlock, + CodeEditor: CodeBlock, + MarkdownEditor: CodeBlock, + JsonEditor: CodeBlock, + FileUpload: FileUploadBlock, +}; + +/** Every layout type in `CONTAINER_NODE_TYPES`. */ +export const containerRegistry: BlockRegistry = { + Page: PageBlock, + Form: FormBlock, + Section: SectionBlock, + Grid: GridBlock, + GridColumn: GridColumnBlock, + Tabs: TabsBlock, + Tab: TabBlock, +}; + +/** The blocks that need no data source. */ +export const blockRegistry: BlockRegistry = { + Button: ButtonBlock, + ActionBar: ActionBarBlock, + Markdown: MarkdownBlock, + StatCard: StatCardBlock, +}; + +/** + * Everything above, ready to render a generated document. Layer over it rather + * than editing it: `composeRegistry(defaultBlockRegistry, myRegistry)`. + */ +export const defaultBlockRegistry: BlockRegistry = { + ...widgetRegistry, + ...containerRegistry, + ...blockRegistry, +}; diff --git a/packages/blocks-ui/src/widgets.tsx b/packages/blocks-ui/src/widgets.tsx new file mode 100644 index 0000000..225c325 --- /dev/null +++ b/packages/blocks-ui/src/widgets.tsx @@ -0,0 +1,271 @@ +'use client'; + +/** + * Widget nodes → `@constructive-io/ui` controls. + * + * Each component is deliberately small and independently replaceable: a host + * that dislikes one of these layers its own over the default registry rather + * than forking the set. + */ + +import { Checkbox, Input, Label, Radio, RadioGroup, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch, Textarea } from '@constructive-io/ui'; +import type { BlockProps } from 'blocks-renderer'; +import type { UINodeConstraints, UINodeProps } from 'blocks-schema'; +import type { ReactNode } from 'react'; + +import { FieldShell, readOptions, textValue, useNodeField } from './field'; + +/** Native validation attributes the document already declares. */ +function inputConstraints(props: UINodeProps) { + const constraints = (props.constraints ?? {}) as UINodeConstraints; + return { + ...(constraints.minLength != null ? { minLength: constraints.minLength } : {}), + ...(constraints.maxLength != null ? { maxLength: constraints.maxLength } : {}), + ...(constraints.pattern ? { pattern: constraints.pattern } : {}), + }; +} + +function numericConstraints(props: UINodeProps) { + const constraints = (props.constraints ?? {}) as UINodeConstraints; + return { + ...(constraints.minValue != null ? { min: constraints.minValue } : {}), + ...(constraints.maxValue != null ? { max: constraints.maxValue } : {}), + ...(typeof props.step === 'number' ? { step: props.step } : {}), + }; +} + +/** A text-ish input; `inputType` carries the HTML type a format implies. */ +function TextInput({ props, type }: { props: UINodeProps; type?: string }) { + const field = useNodeField(props); + const inputType = type ?? (typeof props.inputType === 'string' ? props.inputType : 'text'); + + return ( + + field.setValue(event.target.value)} + disabled={field.disabled} + required={field.required} + {...(field.placeholder ? { placeholder: field.placeholder } : {})} + {...inputConstraints(props)} + /> + + ); +} + +export function InputBlock({ props }: BlockProps) { + return ; +} + +export function TextareaBlock({ props }: BlockProps) { + const field = useNodeField(props); + + return ( + +