From 9527df2614fb47d95692896525e1e1cccf380c30 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 08:23:44 +0000 Subject: [PATCH 1/9] docs(sdk): move the Object UI SDK guide and renderer-type pages into content/docs/sdk Claude-Session: https://claude.ai/code/session_01L9qibzngYyRjmvNxjkfVyd Co-authored-by: Claude --- content/docs/meta.json | 1 + content/docs/sdk/api/meta.json | 6 + content/docs/sdk/api/schema-reference.mdx | 1443 +++++++++++++ .../sdk/components/basic/button-group.mdx | 103 + content/docs/sdk/components/basic/div.mdx | 250 +++ content/docs/sdk/components/basic/meta.json | 11 + .../sdk/components/basic/navigation-menu.mdx | 97 + .../docs/sdk/components/basic/pagination.mdx | 51 + .../docs/sdk/components/basic/separator.mdx | 63 + content/docs/sdk/components/basic/sidebar.mdx | 259 +++ .../docs/sdk/components/complex/carousel.mdx | 379 ++++ .../sdk/components/complex/data-table.mdx | 646 ++++++ .../sdk/components/complex/filter-builder.mdx | 401 ++++ .../docs/sdk/components/complex/filter-ui.mdx | 103 + content/docs/sdk/components/complex/meta.json | 13 + .../docs/sdk/components/complex/resizable.mdx | 465 +++++ .../sdk/components/complex/scroll-area.mdx | 1817 +++++++++++++++++ .../docs/sdk/components/complex/sort-ui.mdx | 72 + .../sdk/components/complex/view-switcher.mdx | 96 + .../sdk/components/data-display/avatar.mdx | 38 + .../components/data-display/breadcrumb.mdx | 160 ++ .../docs/sdk/components/data-display/kbd.mdx | 102 + .../docs/sdk/components/data-display/list.mdx | 67 + .../sdk/components/data-display/meta.json | 11 + .../sdk/components/data-display/statistic.mdx | 222 ++ .../sdk/components/data-display/tree-view.mdx | 297 +++ .../sdk/components/disclosure/collapsible.mdx | 43 + .../docs/sdk/components/disclosure/meta.json | 7 + .../components/disclosure/toggle-group.mdx | 115 ++ .../docs/sdk/components/feedback/empty.mdx | 103 + .../docs/sdk/components/feedback/loading.mdx | 51 + .../docs/sdk/components/feedback/meta.json | 12 + .../docs/sdk/components/feedback/progress.mdx | 48 + .../docs/sdk/components/feedback/skeleton.mdx | 43 + .../docs/sdk/components/feedback/sonner.mdx | 118 ++ .../docs/sdk/components/feedback/spinner.mdx | 100 + .../docs/sdk/components/feedback/toast.mdx | 115 ++ content/docs/sdk/components/form/calendar.mdx | 146 ++ content/docs/sdk/components/form/checkbox.mdx | 56 + content/docs/sdk/components/form/combobox.mdx | 208 ++ content/docs/sdk/components/form/command.mdx | 129 ++ .../docs/sdk/components/form/date-picker.mdx | 141 ++ .../docs/sdk/components/form/file-upload.mdx | 157 ++ content/docs/sdk/components/form/form.mdx | 165 ++ .../docs/sdk/components/form/input-otp.mdx | 110 + content/docs/sdk/components/form/input.mdx | 71 + content/docs/sdk/components/form/meta.json | 20 + .../docs/sdk/components/form/radio-group.mdx | 240 +++ content/docs/sdk/components/form/select.mdx | 77 + content/docs/sdk/components/form/slider.mdx | 32 + content/docs/sdk/components/form/switch.mdx | 61 + content/docs/sdk/components/form/textarea.mdx | 51 + content/docs/sdk/components/form/toggle.mdx | 242 +++ content/docs/sdk/components/index.mdx | 97 + .../sdk/components/layout/aspect-ratio.mdx | 100 + content/docs/sdk/components/layout/meta.json | 7 + content/docs/sdk/components/layout/page.mdx | 295 +++ content/docs/sdk/components/meta.json | 14 + .../sdk/components/navigation/header-bar.mdx | 281 +++ .../docs/sdk/components/navigation/meta.json | 6 + .../sdk/components/overlay/alert-dialog.mdx | 115 ++ .../sdk/components/overlay/context-menu.mdx | 96 + .../docs/sdk/components/overlay/dialog.mdx | 41 + .../docs/sdk/components/overlay/drawer.mdx | 39 + .../sdk/components/overlay/dropdown-menu.mdx | 115 ++ .../sdk/components/overlay/hover-card.mdx | 42 + .../docs/sdk/components/overlay/menubar.mdx | 126 ++ content/docs/sdk/components/overlay/meta.json | 15 + .../docs/sdk/components/overlay/popover.mdx | 37 + content/docs/sdk/components/overlay/sheet.mdx | 80 + .../docs/sdk/components/overlay/tooltip.mdx | 48 + content/docs/sdk/core/meta.json | 6 + content/docs/sdk/core/schema-renderer.mdx | 251 +++ .../docs/sdk/guide/architecture-overview.mdx | 338 +++ content/docs/sdk/guide/building-crud-app.mdx | 429 ++++ content/docs/sdk/guide/component-registry.mdx | 457 +++++ content/docs/sdk/guide/data-source.mdx | 622 ++++++ content/docs/sdk/guide/fields.mdx | 237 +++ content/docs/sdk/guide/layout.mdx | 917 +++++++++ content/docs/sdk/guide/meta.json | 18 + content/docs/sdk/guide/plugin-development.mdx | 552 +++++ content/docs/sdk/guide/plugins.mdx | 543 +++++ content/docs/sdk/guide/quick-start.mdx | 332 +++ content/docs/sdk/guide/schema-overview.mdx | 313 +++ content/docs/sdk/guide/schema-rendering.mdx | 643 ++++++ content/docs/sdk/guide/theming.mdx | 355 ++++ content/docs/sdk/guide/troubleshooting.mdx | 425 ++++ content/docs/sdk/index.mdx | 103 + content/docs/sdk/layout/app-shell.mdx | 306 +++ content/docs/sdk/layout/meta.json | 7 + content/docs/sdk/layout/sidebar-nav.mdx | 339 +++ content/docs/sdk/meta.json | 12 + content/docs/sdk/plugins/index.mdx | 418 ++++ content/docs/sdk/plugins/meta.json | 21 + content/docs/sdk/plugins/plugin-calendar.mdx | 806 ++++++++ content/docs/sdk/plugins/plugin-charts.mdx | 558 +++++ content/docs/sdk/plugins/plugin-chatbot.mdx | 853 ++++++++ content/docs/sdk/plugins/plugin-dashboard.mdx | 756 +++++++ content/docs/sdk/plugins/plugin-detail.mdx | 604 ++++++ content/docs/sdk/plugins/plugin-editor.mdx | 192 ++ content/docs/sdk/plugins/plugin-form.mdx | 755 +++++++ content/docs/sdk/plugins/plugin-gantt.mdx | 738 +++++++ content/docs/sdk/plugins/plugin-grid.mdx | 1039 ++++++++++ content/docs/sdk/plugins/plugin-kanban.mdx | 709 +++++++ content/docs/sdk/plugins/plugin-list.mdx | 68 + content/docs/sdk/plugins/plugin-map.mdx | 760 +++++++ content/docs/sdk/plugins/plugin-markdown.mdx | 408 ++++ content/docs/sdk/plugins/plugin-report.mdx | 352 ++++ content/docs/sdk/plugins/plugin-timeline.mdx | 591 ++++++ content/docs/sdk/plugins/plugin-view.mdx | 849 ++++++++ content/docs/sdk/utilities/cli.mdx | 424 ++++ content/docs/sdk/utilities/create-plugin.mdx | 189 ++ .../docs/sdk/utilities/data-objectstack.mdx | 556 +++++ content/docs/sdk/utilities/meta.json | 8 + 114 files changed, 30247 insertions(+) create mode 100644 content/docs/sdk/api/meta.json create mode 100644 content/docs/sdk/api/schema-reference.mdx create mode 100644 content/docs/sdk/components/basic/button-group.mdx create mode 100644 content/docs/sdk/components/basic/div.mdx create mode 100644 content/docs/sdk/components/basic/meta.json create mode 100644 content/docs/sdk/components/basic/navigation-menu.mdx create mode 100644 content/docs/sdk/components/basic/pagination.mdx create mode 100644 content/docs/sdk/components/basic/separator.mdx create mode 100644 content/docs/sdk/components/basic/sidebar.mdx create mode 100644 content/docs/sdk/components/complex/carousel.mdx create mode 100644 content/docs/sdk/components/complex/data-table.mdx create mode 100644 content/docs/sdk/components/complex/filter-builder.mdx create mode 100644 content/docs/sdk/components/complex/filter-ui.mdx create mode 100644 content/docs/sdk/components/complex/meta.json create mode 100644 content/docs/sdk/components/complex/resizable.mdx create mode 100644 content/docs/sdk/components/complex/scroll-area.mdx create mode 100644 content/docs/sdk/components/complex/sort-ui.mdx create mode 100644 content/docs/sdk/components/complex/view-switcher.mdx create mode 100644 content/docs/sdk/components/data-display/avatar.mdx create mode 100644 content/docs/sdk/components/data-display/breadcrumb.mdx create mode 100644 content/docs/sdk/components/data-display/kbd.mdx create mode 100644 content/docs/sdk/components/data-display/list.mdx create mode 100644 content/docs/sdk/components/data-display/meta.json create mode 100644 content/docs/sdk/components/data-display/statistic.mdx create mode 100644 content/docs/sdk/components/data-display/tree-view.mdx create mode 100644 content/docs/sdk/components/disclosure/collapsible.mdx create mode 100644 content/docs/sdk/components/disclosure/meta.json create mode 100644 content/docs/sdk/components/disclosure/toggle-group.mdx create mode 100644 content/docs/sdk/components/feedback/empty.mdx create mode 100644 content/docs/sdk/components/feedback/loading.mdx create mode 100644 content/docs/sdk/components/feedback/meta.json create mode 100644 content/docs/sdk/components/feedback/progress.mdx create mode 100644 content/docs/sdk/components/feedback/skeleton.mdx create mode 100644 content/docs/sdk/components/feedback/sonner.mdx create mode 100644 content/docs/sdk/components/feedback/spinner.mdx create mode 100644 content/docs/sdk/components/feedback/toast.mdx create mode 100644 content/docs/sdk/components/form/calendar.mdx create mode 100644 content/docs/sdk/components/form/checkbox.mdx create mode 100644 content/docs/sdk/components/form/combobox.mdx create mode 100644 content/docs/sdk/components/form/command.mdx create mode 100644 content/docs/sdk/components/form/date-picker.mdx create mode 100644 content/docs/sdk/components/form/file-upload.mdx create mode 100644 content/docs/sdk/components/form/form.mdx create mode 100644 content/docs/sdk/components/form/input-otp.mdx create mode 100644 content/docs/sdk/components/form/input.mdx create mode 100644 content/docs/sdk/components/form/meta.json create mode 100644 content/docs/sdk/components/form/radio-group.mdx create mode 100644 content/docs/sdk/components/form/select.mdx create mode 100644 content/docs/sdk/components/form/slider.mdx create mode 100644 content/docs/sdk/components/form/switch.mdx create mode 100644 content/docs/sdk/components/form/textarea.mdx create mode 100644 content/docs/sdk/components/form/toggle.mdx create mode 100644 content/docs/sdk/components/index.mdx create mode 100644 content/docs/sdk/components/layout/aspect-ratio.mdx create mode 100644 content/docs/sdk/components/layout/meta.json create mode 100644 content/docs/sdk/components/layout/page.mdx create mode 100644 content/docs/sdk/components/meta.json create mode 100644 content/docs/sdk/components/navigation/header-bar.mdx create mode 100644 content/docs/sdk/components/navigation/meta.json create mode 100644 content/docs/sdk/components/overlay/alert-dialog.mdx create mode 100644 content/docs/sdk/components/overlay/context-menu.mdx create mode 100644 content/docs/sdk/components/overlay/dialog.mdx create mode 100644 content/docs/sdk/components/overlay/drawer.mdx create mode 100644 content/docs/sdk/components/overlay/dropdown-menu.mdx create mode 100644 content/docs/sdk/components/overlay/hover-card.mdx create mode 100644 content/docs/sdk/components/overlay/menubar.mdx create mode 100644 content/docs/sdk/components/overlay/meta.json create mode 100644 content/docs/sdk/components/overlay/popover.mdx create mode 100644 content/docs/sdk/components/overlay/sheet.mdx create mode 100644 content/docs/sdk/components/overlay/tooltip.mdx create mode 100644 content/docs/sdk/core/meta.json create mode 100644 content/docs/sdk/core/schema-renderer.mdx create mode 100644 content/docs/sdk/guide/architecture-overview.mdx create mode 100644 content/docs/sdk/guide/building-crud-app.mdx create mode 100644 content/docs/sdk/guide/component-registry.mdx create mode 100644 content/docs/sdk/guide/data-source.mdx create mode 100644 content/docs/sdk/guide/fields.mdx create mode 100644 content/docs/sdk/guide/layout.mdx create mode 100644 content/docs/sdk/guide/meta.json create mode 100644 content/docs/sdk/guide/plugin-development.mdx create mode 100644 content/docs/sdk/guide/plugins.mdx create mode 100644 content/docs/sdk/guide/quick-start.mdx create mode 100644 content/docs/sdk/guide/schema-overview.mdx create mode 100644 content/docs/sdk/guide/schema-rendering.mdx create mode 100644 content/docs/sdk/guide/theming.mdx create mode 100644 content/docs/sdk/guide/troubleshooting.mdx create mode 100644 content/docs/sdk/index.mdx create mode 100644 content/docs/sdk/layout/app-shell.mdx create mode 100644 content/docs/sdk/layout/meta.json create mode 100644 content/docs/sdk/layout/sidebar-nav.mdx create mode 100644 content/docs/sdk/meta.json create mode 100644 content/docs/sdk/plugins/index.mdx create mode 100644 content/docs/sdk/plugins/meta.json create mode 100644 content/docs/sdk/plugins/plugin-calendar.mdx create mode 100644 content/docs/sdk/plugins/plugin-charts.mdx create mode 100644 content/docs/sdk/plugins/plugin-chatbot.mdx create mode 100644 content/docs/sdk/plugins/plugin-dashboard.mdx create mode 100644 content/docs/sdk/plugins/plugin-detail.mdx create mode 100644 content/docs/sdk/plugins/plugin-editor.mdx create mode 100644 content/docs/sdk/plugins/plugin-form.mdx create mode 100644 content/docs/sdk/plugins/plugin-gantt.mdx create mode 100644 content/docs/sdk/plugins/plugin-grid.mdx create mode 100644 content/docs/sdk/plugins/plugin-kanban.mdx create mode 100644 content/docs/sdk/plugins/plugin-list.mdx create mode 100644 content/docs/sdk/plugins/plugin-map.mdx create mode 100644 content/docs/sdk/plugins/plugin-markdown.mdx create mode 100644 content/docs/sdk/plugins/plugin-report.mdx create mode 100644 content/docs/sdk/plugins/plugin-timeline.mdx create mode 100644 content/docs/sdk/plugins/plugin-view.mdx create mode 100644 content/docs/sdk/utilities/cli.mdx create mode 100644 content/docs/sdk/utilities/create-plugin.mdx create mode 100644 content/docs/sdk/utilities/data-objectstack.mdx create mode 100644 content/docs/sdk/utilities/meta.json diff --git a/content/docs/meta.json b/content/docs/meta.json index b3f29b79441..85662a20aa3 100644 --- a/content/docs/meta.json +++ b/content/docs/meta.json @@ -13,6 +13,7 @@ "permissions", "ai", "api", + "sdk", "upgrading", "protocol-upgrade", "---Platform---", diff --git a/content/docs/sdk/api/meta.json b/content/docs/sdk/api/meta.json new file mode 100644 index 00000000000..928b6664b52 --- /dev/null +++ b/content/docs/sdk/api/meta.json @@ -0,0 +1,6 @@ +{ + "title": "API Reference", + "pages": [ + "schema-reference" + ] +} diff --git a/content/docs/sdk/api/schema-reference.mdx b/content/docs/sdk/api/schema-reference.mdx new file mode 100644 index 00000000000..f960653a2b2 --- /dev/null +++ b/content/docs/sdk/api/schema-reference.mdx @@ -0,0 +1,1443 @@ +--- +title: "Schema Type Reference" +description: "Complete API reference for all ObjectUI schema types with annotated examples" +--- + +# Schema Type Reference + +This reference documents every ObjectUI schema type with annotated JSON examples. Each schema extends `BaseSchema` and can be rendered by the ObjectUI engine from pure JSON. + +> **Import:** All types are available from `@object-ui/types`. +> +> ```typescript +> import type { PageNodeSchema, FormSchema, TableSchema, /* ... */ } from '@object-ui/types'; +> ``` + +--- + +## Base Schema + +**"A component node" is named `DeclaredNode`.** That is the object half a renderer +receives: a node of a declared type, keyed by the required `type` — the registry key that +selects the renderer. Every schema type in this reference extends `BaseSchema`, which +carries `type` and the shared keys tabled below. Use `DeclaredNode` for any position that +holds a node object: a slot's declared type, a prop, a type annotation in an example. A +value typed `BaseSchema` names no declared type (its `type` is any string), so it does not +fit a node slot or `SchemaRenderer`'s `schema` prop. + +Reach for `SchemaNode` only where the wider union is genuinely correct. `SchemaNode` is +`DeclaredNode` (a node of a declared type) **plus** the primitive members that render as +text, so it is the right word +for a slot that also accepts a bare string (`children`) and the wrong word for a +position that must be an object: a renderer that narrows a slot with +`typeof node === 'object'` before reading its keys drops those primitive members on the +floor. Naming the union where only the object half is accepted is the mismatch +objectui#7082 had to correct nine times. + +### SchemaNode + +The foundational building block of ObjectUI. Every component in the system is described by a `SchemaNode`. It can be a node of a declared type, or a primitive value rendered as text. + +```typescript +import type { DeclaredNode } from '@object-ui/types'; + +// The definition `@object-ui/types` declares +type SchemaNode = DeclaredNode | string | number | boolean | null | undefined; +``` + +`DeclaredNode` is the discriminated union, keyed by `type`, of every node type +`@object-ui/types` declares (the component schemas and the spec-declared +authoring nodes) and every type your application declares in +`CustomNodeRegistry`. Every node slot (`children`, `trigger`, `content`, a +view's `schema`) and `SchemaRenderer`'s `schema` prop take it, so an inline +child is checked against its own type's keys, and a `type` nothing declares is +refused. A type you register with `ComponentRegistry` joins it through +`CustomNodeRegistry`, an interface your application augments with +`declare module '@object-ui/types'`, one entry per registered type, keyed by +the type's name. The SchemaRenderer page's "Component Registry" section walks +through a registration and its entry. + +### BaseSchema + +All schema types extend `BaseSchema`. These shared properties are available on every component. + +```json +{ + "type": "div", + "id": "my-component", + "name": "wrapper", + "label": "Wrapper", + "description": "A container element", + "className": "p-4 bg-white rounded-lg", + "visible": true, + "visibleWhen": "${data.showWrapper}", + "disabled": "${data.isLocked}", + "testId": "wrapper-element", + "ariaLabel": "Content wrapper", + "children": [] +} +``` + +One row per declared member, in declaration order, so the list can be checked against `BaseSchema` by reading the two side by side. + +| Property | Type | Description | +|----------|------|-------------| +| `type` | `string` | **Required.** Component type identifier (e.g. `"page"`, `"form"`, `"table"`). | +| `id` | `string` | Unique instance identifier. | +| `name` | `string` | Component name, used for form fields and data binding. | +| `label` | `string \| I18nLabel` | Human-readable display label. `I18nLabel` is the spec's **inline locale map** (`string \| Record`, keyed by BCP-47 locale tag such as `en` or `zh-CN`), resolved against the display locale by `resolveI18nLabel`. | +| `description` | `string \| I18nLabel` | Help text or tooltip content. Same inline-locale-map vocabulary and resolver as `label`. | +| `placeholder` | `string` | Hint text for input components. | +| `className` | `string` | Tailwind CSS utility classes. | +| `style` | `Record` | Inline CSS styles. Use sparingly — prefer `className`. | +| `data` | `any` | Arbitrary data attached to the node. `any` because the shape is defined by the consuming component rather than by `BaseSchema`. | +| `bind` | `string` | Data-scope path this node draws its rows or value from, resolved by `useDataScope()`. Honoured only by components that call it. | +| `body` | *retired* | ⛔ Refused by name (objectui#6771). `body` was a second child-list spelling `BaseSchema` declared beside `children`; it is now `never` on the TypeScript face and an alias refusal on the Zod mirror, and the refusal names `children`. | +| `children` | `SchemaNode \| SchemaNode[]` | Child components rendered inside this component — the child-list key, and since objectui#6771 the only one. Whether a given node type renders a child list at all is still per component; see the note below. The keys OTHER than this one through which a renderer hands nodes to `SchemaRenderer` (a dialog's `trigger`, a tab item's `content`, a page's `regions[].components`) are per type and declared once as `NODE_SLOT_DECLARATIONS` / `nodeSlotsFor` (objectui#11170); `objectui check`, the core schema validator and the SDUI parser walk those positions as they walk `children`. | +| `visible` | `boolean \| string \| { dialect?: string; source: string }` | Visibility control. Accepts a boolean, a predicate expression string, **or** the CEL envelope object (`{ dialect: 'cel', source }` — what `objectstack build` emits for every authored predicate) — the renderer evaluates this key rather than reading it as a boolean. The string-or-envelope half is `ExpressionWire`, the one wire type `visibleWhen` on form fields already carries. | +| `visibleWhen` | `string \| { dialect, source }` | Canonical conditional-visibility predicate (ADR-0089); the element is shown when it evaluates truthy. Typed as `@objectstack/spec`'s `EvaluatedExpressionInput`: a predicate string, or the envelope the spec's parse writes (`dialect` is `cel`, `cron` or `template`, and `source` is not blank). Evaluated **before** `visible`, and outranks it. | +| `visibleOn` | *retired* | ⛔ Refused by name. `visibleOn` was objectui's own spelling of `visibleWhen`; it is `never` on the TypeScript face and an alias refusal on the Zod mirror, and the refusal names `visibleWhen` (same predicate, same polarity). | +| `hidden` | `boolean \| string \| { dialect?: string; source: string }` | Inverse of `visible` — the node is not rendered. Accepts a boolean, a predicate expression string **or** the CEL envelope object (`ExpressionWire`), which the renderer evaluates rather than reading as a boolean. | +| `hiddenOn` | *retired* | ⛔ Refused by name; the refusal names `hidden` (same predicate, same polarity). | +| `disabled` | `boolean \| string \| { dialect?: string; source: string }` | Disabled state. Accepts a boolean, a predicate expression string **or** the CEL envelope object (`ExpressionWire`), on the same evaluated path as `visible`. | +| `disabledOn` | *retired* | ⛔ Refused by name; the refusal names `disabled` (same predicate, same polarity). | +| `testId` | `string` | Test identifier, rendered as `data-testid`. | +| `ariaLabel` | `string \| KeyedI18nLabel` | Accessibility label, rendered as `aria-label`. `KeyedI18nLabel` is the **keyed** form (`{ key, defaultValue?, params? }`), resolved by `resolveKeyedI18nLabel` — **not** the `I18nLabel` that `label` and `description` carry. The two are structurally confusable and each returns nothing useful for the other's input. | + +Two things the table cannot show in a cell: + +- **A concrete schema may narrow an inherited member, and its own declaration wins.** Many component schemas restate `label`, `description` or `disabled` more narrowly than `BaseSchema` declares them, so the unions above are what a node gets when its own schema does not restate the key. Check the component's own property table before writing a predicate string or a locale map into an inherited slot. +- **⚠️ `body` and `children` WERE two channels, not one key with two spellings — and the second one is retired (objectui#6771).** Each renderer read one, the other, both, or neither, and `SchemaRenderer` strips both out of the props bag it spreads — so writing the channel a renderer did not read rendered an EMPTY element, with no error at authoring time, none at validation time and none at render time. That is the defect objectui#8284 named after seven cards had repaired one page of it each, and it was closed per component by measurement: each schema narrows to the channel its renderer actually reads and **tombstones the other as `never`**, refused by name on both published faces (objectui#9254 for the components that read exactly one channel, objectui#9256 for the ones that read neither). objectui#6771 then closed the class at its source: `children` is the one child-list spelling and `body` is refused on `BaseSchema` itself. ⇒ **whether a node type accepts a child list at all is still per component** — check the component's own section, where a node that renders no children tombstones `children` too. +- **This list is exhaustive for *declared* members; the two faces differ on *accepted* keys.** `BaseSchema` carries no index signature (objectui#8347), so on the TypeScript face an undeclared key, a misspelling included, is a compile error in a typed literal. Its tolerant Zod mirror is still `.passthrough()`, so the rendering face (and `safeValidateSchema`) keeps an undeclared key; the derived strict face, `StrictAnyComponentSchema`, refuses it. Absence from this table does not mean the tolerant face rejects a key. + +--- + +## Layout Schemas + +### PageNodeSchema + +Top-level page container. Defines a full page with optional regions (header, sidebar, footer). + +```json +{ + "type": "page", + "title": "User Dashboard", + "icon": "LayoutDashboard", + "description": "Overview of user activity", + "pageType": "app", + "object": "User", + "variables": [ + { "name": "userId", "type": "string", "defaultValue": "current" } + ], + "regions": [ + { + "name": "header", + "components": [{ "type": "text", "content": "Welcome back" }] + } + ], + "children": [ + { "type": "card", "title": "Activity", "children": [] } + ] +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `title` | `string` | Page heading, drawn as the page's `h1` on every page type except `"record"`. A record page's heading is a `page:header` block. | +| `icon` | `string` | Lucide icon name for the page. | +| `description` | `string` | Line under the heading, drawn on every page type except `"record"`. | +| `pageType` | `PageType` | The page kind: `"record"` (the default), `"home"`, `"app"`, `"utility"` or `"list"`. See [Page types](#page-types) below. | +| `object` | `string` | ObjectQL object name this page operates on. | +| `template` | `string` | Template name for page layout. | +| `variables` | `PageVariable[]` | Page-level variables with types and defaults. | +| `regions` | `PageRegion[]` | Named layout regions (header, sidebar, footer). | +| `children` | `SchemaNode \| SchemaNode[]` | Main page content when the page declares no regions — one node, or a list of them. Spelled `body` until objectui#6771 retired that spelling. | +| `isDefault` | `boolean` | Whether this is the default page for the object. | +| `assignedProfiles` | *retired* | ⛔ Refused by name (objectui#9409). `@objectstack/spec` retired the key: ADR-0090 D2 deleted the Profile concept it was named after, and nothing ever enforced it, so a page that listed profiles stayed open to everyone who could reach it. Both published faces take the spec's tombstone: any value is a TypeScript error and a parse failure at `assignedProfiles`. Delete the key. Page audience comes from permission sets: gate the data the page shows with the object's permission sets, and grant those sets to people through positions. | +| `aria` | `AriaProps` | ARIA attributes for the page's root element: `ariaLabel` (a plain string, or an inline locale map such as `{ "en": "Orders", "fr": "Commandes" }`, resolved for the display locale) renders `aria-label`, `ariaDescribedBy` renders `aria-describedby`, and `role` renders `role`. This is the spec's inline vocabulary, not the keyed flat `ariaLabel` described under BaseSchema. The page adds no default role. | + +#### Page types + +`pageType` takes the five members of `@objectstack/spec`'s `PageTypeSchema`. It decides who +draws the page's heading and how wide the page's content may grow: + +| `pageType` | Kind | Heading | +|------------|------|---------| +| `"record"` | A component-based record page. The default when `pageType` is omitted. | Draws neither `title` nor `description`. Put the heading in a `page:header` block in `children` or a region. | +| `"home"` | A landing page, laid out dashboard-style. It has the widest cap. | Draws `title` and `description`. | +| `"app"` | An app-level page with navigation context. | Draws `title` and `description`. | +| `"utility"` | A compact, focused page, such as settings or a form. It has the narrowest cap. | Draws `title` and `description`. | +| `"list"` | A record list or grid surface. | Draws `title` and `description`. | + +On every type, a titled `page:header` block in the page takes the `h1` instead of `title`. +The page's own `description` still draws on every type except `"record"`. Any other value, +such as `"detail"`, `"form"`, `"dashboard"`, `"report"` or `"custom"`, is refused at +`pageType` when the schema is validated. The layout guide's +[Page Component](/docs/sdk/guide/layout#page-component) and +[Content Width](/docs/sdk/guide/layout#content-width) sections show each type in a page. + +**Related:** [AppSchema](/docs/ui/apps), [DivSchema](#divschema), [GridSchema](#gridschema) + +--- + +### DivSchema + +A generic container element. The simplest layout primitive. + +```json +{ + "type": "div", + "className": "flex items-center gap-4 p-6", + "children": [ + { "type": "text", "content": "Hello World" }, + { "type": "button", "label": "Click me" } + ] +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `children` | `SchemaNode \| SchemaNode[]` | Child elements to render inside the div. | + +**Related:** [GridSchema](#gridschema), [CardSchema](#cardschema) + +--- + +### CardSchema + +A styled container with optional header, body, and footer regions. + +```json +{ + "type": "card", + "title": "Revenue Summary", + "description": "Monthly revenue breakdown", + "variant": "outline", + "hoverable": true, + "header": [ + { "type": "badge", "label": "Live", "variant": "secondary" } + ], + "children": [ + { "type": "statistic", "label": "Total Revenue", "value": "$12,400" } + ], + "footer": [ + { "type": "button", "label": "View Details", "variant": "ghost" } + ] +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `title` | `string` | Card title text. | +| `description` | `string` | Subtitle / description text. | +| `variant` | `"default" \| "outline" \| "ghost"` | Visual style variant. | +| `hoverable` | `boolean` | Add hover elevation effect. | +| `clickable` | `boolean` | Make the entire card a click target. | +| `header` | `SchemaNode \| SchemaNode[]` | Content rendered in the card header. | +| `children` | `SchemaNode \| SchemaNode[]` | Main card content. Spelled `body` until objectui#6771 retired that spelling. | +| `footer` | `SchemaNode \| SchemaNode[]` | Content rendered in the card footer. | + +**Related:** [DivSchema](#divschema), [GridSchema](#gridschema) + +--- + +### GridSchema + +A responsive grid layout. Columns can be a fixed number or responsive breakpoints. + +```json +{ + "type": "grid", + "columns": { "sm": 1, "md": 2, "lg": 3 }, + "gap": 6, + "children": [ + { "type": "card", "title": "Card 1", "children": [] }, + { "type": "card", "title": "Card 2", "children": [] }, + { "type": "card", "title": "Card 3", "children": [] } + ] +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `columns` | `ColumnCount \| Partial>` | A column count from 1 to 12 (`ColumnCount`), or a responsive map of such counts keyed by breakpoint (`xs`, `sm`, `md`, `lg`, `xl`, `2xl`), e.g. `{ sm: 1, md: 2, lg: 3 }`. Any other count is refused with the set named (objectui#11491). The flat `smColumns` / `mdColumns` / `lgColumns` / `xlColumns` keys are retired and refused by name; the breakpoint map is the one spelling (objectui#11505). | +| `gap` | `0 \| 1 \| 2 \| 3 \| 4 \| 5 \| 6 \| 8 \| 10 \| 12` | Gap step between grid items; `0` is none (default `4`). Any other number is refused with the set named (objectui#11474). | +| `children` | `SchemaNode \| SchemaNode[]` | Grid items. | + +**Related:** [DivSchema](#divschema), [CardSchema](#cardschema), [DashboardComponentSchema](#dashboardcomponentschema) + +--- + +### TabsSchema + +A tabbed interface for organizing content into switchable panels. + +```json +{ + "type": "tabs", + "defaultValue": "overview", + "orientation": "horizontal", + "items": [ + { + "value": "overview", + "label": "Overview", + "icon": "Info", + "content": { "type": "div", "children": [{ "type": "text", "content": "Overview content" }] } + }, + { + "value": "settings", + "label": "Settings", + "icon": "Settings", + "content": { "type": "form", "fields": [] } + } + ] +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `defaultValue` | `string` | Initially active tab value. | +| `value` | `string` | Controlled active tab value. | +| `orientation` | `"horizontal" \| "vertical"` | Tab bar orientation. | +| `items` | `TabItem[]` | Tab definitions, each with `value`, `label`, `icon`, `content`, and optional `disabled`. | + +**Related:** [CardSchema](#cardschema), [PageNodeSchema](#pagenodeschema) + +--- + +## Form Schemas + +### FormSchema + +A complete form with fields, validation, layout, and actions. + +```json +{ + "type": "form", + "layout": "horizontal", + "columns": 2, + "validationMode": "onBlur", + "submitLabel": "Save Changes", + "showCancel": true, + "cancelLabel": "Discard", + "defaultValues": { + "name": "", + "email": "", + "role": "viewer" + }, + "fields": [ + { "name": "name", "label": "Full Name", "type": "text", "required": true }, + { "name": "email", "label": "Email", "type": "email", "required": true }, + { "name": "role", "label": "Role", "type": "select", "options": [ + { "label": "Admin", "value": "admin" }, + { "label": "Editor", "value": "editor" }, + { "label": "Viewer", "value": "viewer" } + ]} + ] +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `fields` | `FormField[]` | Field definitions for the form. | +| `defaultValues` | `Record` | Initial form values. | +| `layout` | `"vertical" \| "horizontal"` | Field label placement. | +| `columns` | `number` | Number of columns for field layout. | +| `validationMode` | `"onSubmit" \| "onBlur" \| "onChange" \| "onTouched" \| "all"` | When validation triggers. | +| `submitLabel` | `string` | Text for the submit button. | +| `cancelLabel` | `string` | Text for the cancel button. | +| `showCancel` | `boolean` | Whether to show a cancel button. | +| `showActions` | `boolean` | Whether to show the action buttons row. | +| `resetOnSubmit` | `boolean` | Reset form after successful submit. | +| `disabled` | `boolean` | Disable every input and the submit button. (`mode` is retired on this node and fails validation; for a create / edit / view form use [ObjectFormSchema](#objectformschema).) | +| `actions` | `SchemaNode[]` | Custom action buttons to replace defaults. | + +**Related:** [InputSchema](#inputschema), [SelectSchema](#selectschema), [ObjectFormSchema](#objectformschema) + +--- + +### InputSchema + +A text input field supporting multiple input types with validation. + +```json +{ + "type": "input", + "name": "email", + "label": "Email Address", + "inputType": "email", + "placeholder": "you@example.com", + "required": true, + "description": "We'll never share your email", + "maxLength": 255 +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `name` | `string` | Field name for form data binding. | +| `inputType` | `string` | HTML input type: `"text"`, `"email"`, `"password"`, `"number"`, `"tel"`, `"url"`, `"search"`, `"date"`, `"time"`, `"datetime-local"`. | +| `placeholder` | `string` | Placeholder text. | +| `required` | `boolean` | Whether the field is required. | +| `readOnly` | `boolean` | Render as read-only. | +| `error` | `string` | Error message to display. | +| `min` / `max` | `number` | Numeric range constraints. | +| `step` | `number` | Step increment for number inputs. | +| `maxLength` | `number` | Maximum character length. | +| `pattern` | `string` | Regex pattern for validation. | + +**Related:** [FormSchema](#formschema), [SelectSchema](#selectschema) + +--- + +### SelectSchema + +A dropdown select field with predefined options. + +```json +{ + "type": "select", + "name": "priority", + "label": "Priority", + "placeholder": "Choose priority...", + "required": true, + "options": [ + { "label": "🔴 Critical", "value": "critical" }, + { "label": "🟠 High", "value": "high" }, + { "label": "🟡 Medium", "value": "medium" }, + { "label": "🟢 Low", "value": "low" } + ], + "defaultValue": "medium" +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `name` | `string` | Field name for form data binding. | +| `options` | `SelectOption[]` | Array of `{ label, value }` objects. | +| `placeholder` | `string` | Placeholder text when no value selected. | +| `required` | `boolean` | Whether selection is required. | +| `defaultValue` | `string` | Initial selected value. | +| `error` | `string` | Error message to display. | + +**Related:** [FormSchema](#formschema), [InputSchema](#inputschema) + +--- + +### ButtonSchema + +An interactive button with variants, icons, and loading states. + +```json +{ + "type": "button", + "label": "Deploy to Production", + "variant": "default", + "size": "lg", + "icon": "Rocket", + "iconPosition": "left", + "loading": false, + "buttonType": "submit" +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `label` | `string` | Button text. | +| `variant` | `"default" \| "secondary" \| "destructive" \| "outline" \| "ghost" \| "link"` | Visual style. | +| `size` | `"default" \| "sm" \| "lg" \| "icon"` | Button size. | +| `icon` | `string` | Lucide icon name. | +| `iconPosition` | `"left" \| "right"` | Icon placement relative to label. | +| `loading` | `boolean` | Show loading spinner and disable interaction. | +| `buttonType` | `"button" \| "submit" \| "reset"` | HTML button type. | + +**Related:** [ActionSchema](#actionschema), [FormSchema](#formschema) + +--- + +## Data Display Schemas + +### TableSchema + +A simple static table: it renders inline `data` against `columns`, nothing +more. For row hover highlighting, striping, sorting, filtering, selection or +inline editing use the interactive `data-table` — the static `table` deliberately +does not implement those (objectui#5474 retired the keys that once suggested it +did; authoring them is now refused by validation instead of silently ignored). + +```json +{ + "type": "table", + "caption": "Recent Orders", + "columns": [ + { "accessorKey": "id", "header": "#", "width": 60 }, + { "accessorKey": "customer", "header": "Customer" }, + { "accessorKey": "amount", "header": "Amount", "cellClassName": "text-right" }, + { "accessorKey": "status", "header": "Status" } + ], + "data": [ + { "id": 1, "customer": "Acme Corp", "amount": "$1,200", "status": "Paid" }, + { "id": 2, "customer": "Globex Inc", "amount": "$3,400", "status": "Pending" } + ], + "footer": { "type": "text", "content": "Showing 2 of 156 orders" } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `caption` | `string` | Table caption / title text. | +| `columns` | `StaticTableColumn[]` | Column definitions — the static subset: `accessorKey` (the row key to read) and `header` (heading text) are required; `width`, `className`, and `cellClassName` are the only other keys this renderer honours. Interactive column keys (`align`, `sortable`, `filterable`, `resizable`, `editable`, `cell`, `fixed`, `minWidth`, `type`) belong to `data-table`'s rich `TableColumn` and are refused here. | +| `data` | `any[]` | Array of row data objects. | +| `footer` | `SchemaNode \| string` | Footer content below the table. | + +**Related:** [ObjectGridSchema](#objectgridschema) + +--- + +### ChartSchema + +A chart visualization supporting multiple chart types. + +```json +{ + "type": "chart", + "chartType": "bar", + "title": "Monthly Revenue", + "description": "Revenue by month for 2024", + "height": 350, + "showLegend": true, + "showGrid": true, + "animate": true, + "xAxisKey": "month", + "data": [ + { "month": "Jan", "Revenue": 4200, "Expenses": 3100 }, + { "month": "Feb", "Revenue": 5100, "Expenses": 3400 }, + { "month": "Mar", "Revenue": 4800, "Expenses": 3200 }, + { "month": "Apr", "Revenue": 6200, "Expenses": 3800 }, + { "month": "May", "Revenue": 5800, "Expenses": 3600 }, + { "month": "Jun", "Revenue": 7100, "Expenses": 4000 } + ], + "series": [ + { + "name": "Revenue", + "color": "#3b82f6" + }, + { + "name": "Expenses", + "color": "#ef4444" + } + ] +} +``` + +The rows live on the chart node's own `data`, one object per row keyed by column +name. Each series' `name` (or `dataKey`) selects the column it plots within those +rows, and `xAxisKey` names the column on the category axis. A series carries no +numbers of its own: `ChartDataSeries.data` is a retirement tombstone +(objectui#6896) and an authored array is refused by name at parse. + +| Property | Type | Description | +|----------|------|-------------| +| `chartType` | `ChartType` | **Required.** One of the `@objectstack/spec` `ChartType` families: `"bar"`, `"horizontal-bar"`, `"column"`, `"line"`, `"area"`, `"pie"`, `"donut"`, `"funnel"`, `"scatter"`, `"treemap"`, `"sankey"`, `"combo"`, `"gauge"`, `"solid-gauge"`, `"metric"`, `"kpi"`, `"bullet"`, `"radar"`, `"table"`, `"pivot"`. Any other value is refused with the set named (objectui#11521). The single-value families draw the first series' value in the first row as one number, and the tabular families draw a notice that points at the data-table and pivot components; the others plot the series. | +| `title` | `string` | Chart title. | +| `description` | `string` | Chart description / subtitle. | +| `categories` | `string[]` | An **alternative series list** — column names to plot, read only when `series` is absent, and ignored outright when it is present. Not axis labels: the category axis comes from `xAxisKey`. | +| `series` | `ChartDataSeries[]` | Data series. Each entry's `name` (or `dataKey`) names the column it plots within a `data` row; optional `label`, `color`, a per-series `type` (`"bar"`, `"line"`, `"area"`) for combo charts, `stack`, `yAxis` (`"left"` / `"right"`), `variant` (`"primary"` / `"comparison"`), `dashArray` and `opacity`. `chartType` on a series is refused by name — it is the renderer's internal spelling of `type`; write `type`. | +| `data` | `Array>` | Rows to plot — one object per row, keyed by column name. | +| `xAxisKey` | `string` | Row key holding the category (x) axis. The bare-string `xAxis: "month"` spelling folds onto this key at parse. | +| `xAxis` | `ChartAxis` | The category axis as `@objectstack/spec`'s axis object: `field` (required — the category column; `xAxisKey` wins when both are written), `title`, `format`, `min`, `max`, `stepSize`, `showGridLines`, `position`, `logarithmic`. Strict: an undeclared key is refused at parse. | +| `yAxis` | `ChartAxis[]` | The value axes — a **list** of the same axis object, one entry per axis; a second entry declares the right-hand axis. A single object or a bare string is refused. | +| `height` / `width` | `string \| number` | Chart dimensions. | +| `showLegend` | `boolean` | Display the legend. | +| `showGrid` | `boolean` | Display grid lines. | +| `animate` | `boolean` | Enable entry animations. | +| `config` | `Record` | Additional chart library configuration. | + +**Related:** [DashboardComponentSchema](#dashboardcomponentschema), [CardSchema](#cardschema) + +--- + +### TreeViewSchema + +A hierarchical tree component for nested data with expand/collapse and selection. + +```json +{ + "type": "tree-view", + "multiSelect": false, + "showLines": true, + "defaultExpandedIds": ["root", "src"], + "nodes": [ + { + "id": "root", + "label": "project", + "icon": "Folder", + "children": [ + { + "id": "src", + "label": "src", + "icon": "Folder", + "children": [ + { "id": "app", "label": "App.tsx", "icon": "FileCode" }, + { "id": "index", "label": "index.ts", "icon": "FileCode" } + ] + }, + { "id": "readme", "label": "README.md", "icon": "FileText" } + ] + } + ] +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `nodes` | `TreeNode[]` | Optional. Nested tree data — the spelling the renderer reads FIRST, and the one the component's own `inputs` and `defaultProps` use. Each node has `id`, `label`, optional `icon` and `children`. | +| `data` | `TreeNode[]` | Optional. Nested tree data, read only when `nodes` is absent (the renderer reads `nodes` first — objectui#6939). | +| `defaultExpandedIds` | `string[]` | Node IDs expanded on initial render. | +| `defaultSelectedIds` | `string[]` | Node IDs selected on initial render. | +| `expandedIds` | `string[]` | Controlled expanded state. | +| `selectedIds` | `string[]` | Controlled selection state. | +| `multiSelect` | `boolean` | Allow selecting multiple nodes. | +| `showLines` | `boolean` | Show tree connector lines. | + +**Related:** [TableSchema](#tableschema) + +--- + +## CRUD Schemas + +### CRUDSchema — retired + +`CRUDSchema` and the `crud` node type were **removed** in objectui#5373 under +ADR-0049 (enforce-or-remove). The type had four declaration faces — a TypeScript +interface, a zod mirror, a branch in the schema validator and a `CRUDBuilder` — +and no registered renderer, for the whole life of the key. A node that spelled it +painted the OBJUI-001 "Unknown component type" panel, so this page was teaching a +shape that could not render. + +There is no drop-in replacement, because a CRUD screen is a composition rather +than one node. Build it from the shapes that do render: + +| What `CRUDSchema` promised | What to author instead | +|---|---| +| The record table, with toolbar, filters, pagination and row/batch actions | [ObjectGridSchema](#objectgridschema) | +| The create/edit form | [ObjectFormSchema](#objectformschema) | +| The single-record read view | [DetailSchema](#detailschema) / [DetailViewSchema](#detailviewschema) | +| Whole-object screens that bundle the above | [ObjectViewSchema](#objectviewschema) | + +The `defaultSort` and `defaultSortOrder` keys documented here were `CRUDSchema`'s +own — a flat field name plus a separate direction. They are gone with it. +[ObjectGridSchema](#objectgridschema) used to declare its own, differently shaped +`defaultSort` (an object with `field` and `order`); that key has since been +retired too — see the note under [ObjectGridSchema](#objectgridschema). + +Authoring `crud` is now refused by name: `validateSchema` from `@object-ui/core` +returns a `RETIRED_TYPE` error on `schema.type` naming the migration above, and +`objectui check` reports the type as unknown. + +--- + +### ActionSchema + +A powerful action definition supporting API calls, confirmations, dialogs, chaining, and conditional execution. + +```json +{ + "type": "action", + "label": "Submit Order", + "level": "primary", + "icon": "Send", + "actionType": "ajax", + "api": "/api/orders", + "method": "POST", + "data": { "status": "submitted" }, + "confirmText": "This will send the order to the warehouse.", + "successMessage": "Order submitted successfully", + "errorMessage": "Failed to submit order", + "chain": [ + { + "type": "action", + "label": "Refresh", + "actionType": "button", + "reload": true + } + ], + "chainMode": "sequential", + "condition": "${data.items.length > 0}", + "retry": { + "maxAttempts": 3, + "delayMs": 1000 + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `label` | `string` | **Required.** Action display text. | +| `level` | `string` | Semantic level: `"primary"`, `"secondary"`, `"success"`, `"warning"`, `"danger"`, `"info"`. | +| `icon` | `string` | Lucide icon name. | +| `actionType` | `string` | Action kind: `"button"`, `"link"`, `"dropdown"`, `"ajax"`, `"confirm"`, `"dialog"`. | +| `api` | `string` | API endpoint for `ajax` actions. | +| `method` | `string` | HTTP method: `"GET"`, `"POST"`, `"PUT"`, `"DELETE"`, `"PATCH"`. | +| `data` | `any` | Request body data. | +| `confirmText` | `string` | Confirmation message shown before executing — the one confirm spelling, addressed by the translation bundle. (A structured `confirm` object was retired in objectui#4314.) | +| `dialog` | `object` | Modal dialog with `title`, `content`, `size`, `actions`. | +| `chain` | `ActionSchema[]` | Actions to execute after this action completes. | +| `chainMode` | `"sequential" \| "parallel"` | How chained actions execute. | +| `condition` | `boolean \| string \| { dialect?, source }` | Execution gate — the action runs only while this predicate holds (boolean, bare CEL, `${...}` template, or the normalized envelope). Declared and false skips the action; absent executes it. It is a **gate, not a branch**: express a branch as separate actions with mutually exclusive `condition`s. (The `{ expression, then, else }` branch shape was retired in objectui#3917 — nothing read it, so it ran unconditionally.) | +| `successMessage` / `errorMessage` | `string` | Toast messages on success/failure. | +| `reload` | `boolean` | Reload data after action completes. | +| `redirect` | `string` | URL to navigate to after action. | +| `retry` | `object` | Retry config with `maxAttempts` and `delayMs` (milliseconds; renamed from `delay`, objectstack#25762). | + +**Related:** [DetailSchema](#detailschema), [ButtonSchema](#buttonschema) + +--- + +### DetailSchema + +A single-record detail view with grouped fields, actions, and tabs. + +```json +{ + "type": "detail", + "title": "Order #1042", + "api": "/api/orders/1042", + "showBack": true, + "groups": [ + { + "title": "Customer Info", + "fields": [ + { "name": "customer", "label": "Customer", "type": "text" }, + { "name": "email", "label": "Email", "type": "text" }, + { "name": "created", "label": "Created", "type": "date", "format": "MMM d, yyyy" } + ] + }, + { + "title": "Order Details", + "fields": [ + { "name": "total", "label": "Total", "type": "text" }, + { "name": "status", "label": "Status", "type": "badge" } + ] + } + ], + "actions": [ + { "type": "action", "label": "Edit", "icon": "Pencil", "level": "primary" }, + { "type": "action", "label": "Delete", "icon": "trash", "level": "danger", "actionType": "confirm" } + ], + "tabs": [ + { + "key": "items", + "label": "Line Items", + "content": { "type": "table", "columns": [], "data": [] } + }, + { + "key": "history", + "label": "History", + "content": { "type": "timeline", "items": [] } + } + ] +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `title` | `string` | Detail page title. | +| `api` | `string` | API endpoint to fetch record data. | +| `resourceId` | `string \| number` | ID of the record to display. | +| `groups` | `array` | Field groups, each with `title`, `description`, and `fields`. | +| `actions` | `ActionSchema[]` | Available actions (edit, delete, etc.). | +| `tabs` | `array` | Additional tabbed content with `key`, `label`, and `content`. | +| `showBack` | `boolean` | Show a back navigation button. | +| `loading` | `boolean` | Show loading state. | + +> **Handler keys are not authorable in JSON, and this face refuses three of them by name.** `onBack` has been a named refusal since objectui#7344; since objectui#7804 this face also declares `onNavigate` and `onAddComment` as objectui#6124 **runtime slots**: a React host supplies the function through the TypeScript interface or as a React prop, and the validator **refuses the key by name** — the message leads with the slot's label (`SPA navigation callback`, `New comment callback`), states that the key "is a RUNTIME SLOT for a host-supplied function, not authorable metadata (objectui#6124): JSON has no function value, and no handler key consumes a declarative action object", and closes by pointing at the node-type spelling (`{ "type": "toast", … }`, an `action:button` node). Until then an authored `onNavigate: { "action": "toast" }` parsed **green** — `BaseSchema` is `.passthrough()`, so a key no arm declares is not refused, it stops being judged and the value is kept — and because every read site only tests the key for truthiness, the kept object then reached a call site expecting a function: `handleBack`, `handleEdit` and the post-delete redirect call `schema.onNavigate(url, { replace })` in `DetailView`'s own body, while `onAddComment` is forwarded as a prop into the comment composer, which renders *because* the key is truthy and then awaits it on send. ⚠️ `onTabChange` is a fourth handler key this view reads and it is **still undeclared** — and where the `object-kanban` board's third key, `onCardMove`, has been a **tombstone** refused by name since objectui#9342, an authored `onTabChange` is not refused at all: it is **kept**, read through a cast and handed to the tab strip's `onValueChange`, so it still reaches a call site expecting a function at the first tab switch. That gap is open on objectui#7804. + +**Related:** [DetailViewSchema](#detailviewschema), [ObjectGridSchema](#objectgridschema) + +--- + +## ObjectQL Schemas + +These schemas integrate with [ObjectStack](https://objectstack.ai) for automatic data fetching, but work with any backend through the `data` prop. + +### ObjectGridSchema + +A data grid that auto-fetches from an ObjectQL object definition. Includes search, filters, pagination, grouping, and inline editing. + +An authored `object-grid` node takes its props in its `properties` bag, whose members are `@objectstack/spec`'s `ComponentPropsMap['object-grid']` row. `objectui validate` judges the bag against that row and refuses a prop written flat on the node by name, naming its bag member, as the spec's own page component does (objectui#11276). `SchemaRenderer` hoists the bag onto the node before `ObjectGrid` runs, so `ObjectGridSchema` is the node as the renderer reads it, and the table below lists its members. Base props such as `className` stay on the node, beside the bag. `description` is a bag member: the spec's row declares it (objectstack#20694), so written on the node it is refused by name and pointed at `properties.description` (objectui#11227). + +```json +{ + "type": "object-grid", + "properties": { + "objectName": "Contact", + "description": "Manage your contacts", + "title": "All Contacts", + "searchableFields": ["name", "email", "company"], + "resizable": true, + "columns": [ + { "field": "name" }, + { "field": "email" }, + { "field": "company" }, + { "field": "phone" }, + { "field": "status", "label": "Status", "sortable": true } + ], + "sort": [{ "field": "name", "order": "asc" }], + "operations": { + "create": true, + "update": true, + "delete": true, + "export": true + }, + "rowActions": ["edit", "delete"], + "pagination": { + "pageSize": 25, + "pageSizeOptions": [10, 25, 50, 100] + } + } +} +``` + +Every key in this example is one the grid reads (objectui#11068). It used to +also author `showFilters` (now retired on this node, below), `striped` and +`operations.read`, which nothing reads, and `selection: { enabled, mode }` and +`pagination.enabled`, which the validator refuses: selection is spelled `selection: { type: 'multiple' }` (`'none'`, +`'single'` or `'multiple'`), and `pagination` declares no on switch — its +presence enables paging. It also authored `emptyState`, which the grid reads but +the spec's row did not declare until objectstack#20694; it is authorable in the +`properties` bag now (see its row below). + +| Property | Type | Description | +|----------|------|-------------| +| `objectName` | `string` | **Required**, unless a `dataSource` binding names the object (`"dataSource": { "object": "Contact" }`), which the registration lands here (objectui#11117). ObjectQL object API name. | +| `label` | `string \| I18nLabel` | Grid label: the table caption, the export file title and the record-detail overlay heading. The canonical spelling; the deprecated `title` is read only when `label` is absent. A per-locale map (`{ "en": "Accounts", "zh-CN": "客户" }`) resolves against the display locale (the workspace's regional default when one is configured, otherwise the active UI language) (objectui#10993). | +| `columns` | `string[] \| ListColumn[]` | Columns to display. Either a plain array of field names (`["name", "email"]`), which auto-resolve from object metadata, or an array of `ListColumn` objects whose identity key is `field` (`{ "field": "status", "label": "Status" }`) — never `name`. **Do not mix the two forms in one array:** the array is dispatched on its first entry, so column objects sitting behind a bare string are dropped. | +| `filter` | `any[]` | Pre-applied filter conditions. | +| `sort` | `SortConfig[]` | Default sort configuration. The string clause (`"name desc"`) was retired in objectui#8221 and now fails validation. | +| `description` | `string \| I18nLabel` | One line of help text drawn above the grid; a per-locale map resolves like `label` (objectui#11068). Authored in the `properties` bag: the spec's row declares it (objectstack#20694, objectui#11227). | +| `searchableFields` | `string[]` | Fields included in search. | +| `selection` | `SelectionConfig` | Row selection configuration. | +| `pagination` | `PaginationConfig` | Pagination settings. | +| `operations` | `object` | Enabled CRUD operations. | +| `rowActions` / `bulkActions` | `string[]` | Action identifiers for rows and batch selection. `bulkActions` is the spec-aligned key; `batchActions` is a legacy alias that takes precedence when both are set. | +| `editable` | `boolean` | Enable inline cell editing. | +| `grouping` | `GroupingConfig` | Row grouping configuration. **Server-side**: the set of groups, every group count and every per-group aggregation come from the group header query (`dataSource.queryGroupHeaders`), and each group's rows are paged by the server. Rows handed in whole are grouped in the browser (exact); over a data source with no header query, a grid that fetches its own rows refuses grouping with an error naming `queryGroupHeaders`. | +| `frozenColumns` | `number` | Number of columns frozen on scroll. | +| `conditionalFormatting` | `ConditionalFormattingRule[]` | Row styling rules, each `{ condition, style }` — a CEL `condition` over the row's `record.*` and a CSS `style` map, the rule a list view declares; the first matching rule styles the row. The native `{ field, operator, value }` rule, its `expression`, and a colour written beside `condition` (rather than inside `style`) are retired (objectui#11533): `@object-ui/types` refuses them by name on `ObjectGridSchema`, the `object-view` `table` slot and `list-view`. `{ field: 'priority', operator: 'equals', value: 'high', backgroundColor: '#fee2e2' }` is `{ condition: "record.priority == 'high'", style: { backgroundColor: '#fee2e2' } }`. In an authored node's `properties` bag the member is `@objectstack/spec`'s row member, judged by the installed spec. A grid stored with a retired rule still paints it. | +| `navigation` | `ViewNavigationConfig` | SPA navigation configuration. | +| `emptyState` | `EmptyState` — `{ title?, message?, icon?, link? }` | Drawn in place of an empty table: a Lucide `icon`, a `title` (default: the table's "No results found"), a `message` (default: none) and one `link: { label, url }` the user follows (default: none), opened as a `type: 'url'` action opens it, through the action runner (objectui#12270). `title`, `message` and the link's `label` are `string \| I18nLabel`, each resolved against the display locale; a map with no usable entry keeps that member's default. Not drawn when a term in the grid's own server-side search box emptied it — the table and its search box stay (objectui#11068). Authored in the `properties` bag: the spec's row declares it as the list view's own `EmptyStateSchema` (objectstack#20694, objectui#11227). | + +> **`name`, `placeholder`, `rowSpecActions` and `bulkSpecActions` are retired on +> this node (objectui#11068).** Nothing ever read them: `rowSpecActions` / +> `bulkSpecActions` were second spellings of `rowActions` / `bulkActions`, and a +> grid is neither a form field (`name`) nor an input (`placeholder`). Both faces of +> `@object-ui/types` refuse them by name. Write `rowActions`, `bulkActions`, `id` +> or `label`, and `emptyState: { "message": … }` instead. + +> **`showFilters` is retired on this node too (objectui#11068).** An `object-grid` +> has no filter UI, and nothing read the key. The filter builder is the +> `list-view` toolbar's: author a `list-view` node and switch it with +> `"userActions": { "filter": true }`. To narrow the rows a grid fetches, write +> `filter`. Both faces of `@object-ui/types` refuse an authored `showFilters` on an +> `object-grid` by name. An [`object-view`](#objectviewschema)'s own `showFilters`, +> below, is a different key and is unchanged. + +> **`defaultSort` is retired (objectui#5861).** `ObjectGridSchema` used to accept a +> legacy single-entry `defaultSort: { field, order }` beside `sort`. The installed +> `@objectstack/spec` protocol refuses it by name (a retired-key tombstone), and no +> renderer reads it any more: a grid that still carries it renders **unsorted**, and +> `@object-ui/types` refuses it on both faces (a `?: never` member and a named zod +> refusal). Rename the key to `sort` and wrap the value in an array — +> `"defaultSort": { "field": "name", "order": "asc" }` becomes +> `"sort": [{ "field": "name", "order": "asc" }]`, as in the example above. + +**Related:** [ObjectViewSchema](#objectviewschema), [TableSchema](#tableschema) + +--- + +### ObjectFormSchema + +A smart form that auto-generates fields from an ObjectQL object. Supports simple, tabbed, wizard, split, drawer, and modal layouts. + +An authored `object-form` node takes its props in its `properties` bag, whose members are `@objectstack/spec`'s `ComponentPropsMap['object-form']` row. `objectui validate` judges the bag against that row and refuses a prop written flat on the node by name, naming its bag member, as the spec's own page component does (objectui#10859). `SchemaRenderer` hoists the bag onto the node before `ObjectForm` runs, so `ObjectFormSchema` is the node as the renderer reads it, and the table below lists its members. + +```json +{ + "type": "object-form", + "properties": { + "objectName": "Contact", + "mode": "create", + "formType": "tabbed", + "title": "New Contact", + "layout": "vertical", + "columns": 2, + "fields": ["firstName", "lastName", "email", "phone", "company"], + "sections": [ + { + "label": "Basic Info", + "fields": ["firstName", "lastName", "email"] + }, + { + "label": "Details", + "fields": ["phone", "company", "address"] + } + ], + "showSubmit": true, + "submitText": "Create Contact", + "showCancel": true, + "cancelText": "Cancel" + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `objectName` | `string` | **Required.** ObjectQL object API name. | +| `mode` | `"create" \| "edit" \| "view"` | **Required.** Form interaction mode. | +| `formType` | `string` | Layout type: `"simple"`, `"tabbed"`, `"wizard"`, `"split"`, `"drawer"`, `"modal"`. Aligned with `@objectstack/spec` `FormViewSchema.type`. | +| `recordId` | `string \| number` | Record ID for edit/view modes. | +| `fields` | `string[]` | Field API names to include (auto-resolved from object metadata). | +| `customFields` | `FormField[]` | Manually defined fields that override auto-generated ones. | +| `sections` | `ObjectFormSection[]` | Field sections (simple groups, tabs, or wizard steps depending on `formType`). Spec-aligned key. | +| `groups` | `array` | **Deprecated.** Legacy alias of `sections` (spec defines `groups` as an alias); normalized into `sections` when `sections` is absent. Legacy shape: `title`→`label`, `defaultCollapsed`→`collapsed`. Not a member of the spec's `object-form` row, so an authored bag refuses it; write `sections`. | +| `layout` | `string` | Label layout: `"vertical"`, `"horizontal"`. The spec row retired `"inline"` and `"grid"` (both rendered as `"vertical"`), so an authored bag refuses them; for several columns set `columns`. | +| `columns` | `number` | Number of form columns. | +| `submitText` / `cancelText` | `string \| I18nLabel` | Button labels. | +| `title` / `description` | `string \| I18nLabel` | Heading and subtitle of the drawer and modal presentations. | +| `nextText` / `prevText` | `string \| I18nLabel` | Wizard step-button labels. | +| `successMessage` | `string \| I18nLabel` | Toast shown after a successful submit. | +| `showSubmit` / `showCancel` / `showReset` | `boolean` | Toggle action buttons. | +| `drawerSide` | `string` | Drawer position: `"top"`, `"bottom"`, `"left"`, `"right"`. | +| `modalSize` | `string` | Modal size: `"sm"`, `"default"`, `"lg"`, `"xl"`, `"full"`. | +| `modalCloseButton` | `boolean` | Show the modal's close (X) button. Default `true`; `false` hides it. The modal still closes on Escape, and on the Cancel action when that is shown. | + +#### Spec alignment & extension keys + +`ObjectFormSchema` keys fall into four classes (#2545): + +- **Spec-aligned** — same name and semantics as `@objectstack/spec` `FormViewSchema`: `title`, `description`, `layout`, `columns`, `sections`, `defaultTab`, `tabPosition`, `allowSkip`, `showStepIndicator`, `splitDirection`/`splitSize`/`splitResizable`, `drawerSide`/`drawerWidth`, `modalSize`, `subforms`, `submitBehavior` (plus `formType` ↔ spec `type`). +- **Component-contract keys** — not on `FormViewSchema`, but members of the spec's `ComponentPropsMap['object-form']` row, the `object-form` block's own props contract: `showSubmit`/`submitText`, `showCancel`/`cancelText`, `showReset`, `nextText`/`prevText`, `successMessage`, `navigateOnSuccess`, `resetOnSuccess`, `modalCloseButton`, `initialValues`, `fields`, `customFields`. `submitText`, `cancelText`, `nextText`, `prevText` and `successMessage` are `I18nLabel` there, as `title` and `description` are. This list was read off the installed `@objectstack/spec`'s `ComponentPropsMap['object-form']` row; nothing re-derives it, so check that row itself before relying on it. +- **Envelope key** — `className` is on neither `FormViewSchema` nor the `object-form` row, but on the spec's `PageComponentSchema`, the envelope every page node carries beside `type` and `properties`. +- **Runtime-only** — non-serializable renderer concerns that never appear in view metadata: `mode`, `recordId`, `open`/`onOpenChange`, `readOnly`, and all callbacks (`onSuccess`, `onError`, `onCancel`, `onStepChange`, `submitHandler`). + +`I18nLabel` is `@objectstack/spec`'s label union: a plain string, or an inline per-locale map such as `{ "en": "Save order", "zh-CN": "保存订单" }`. `ObjectForm` resolves a map against the active UI language before any presentation reads it (objectui#10993). + +**Related:** [FormSchema](#formschema), [ObjectViewSchema](#objectviewschema) + +--- + +### ObjectViewSchema + +A complete object management interface combining grid, form, search, filters, and view switching. + +```json +{ + "type": "object-view", + "objectName": "Deal", + "title": "Sales Pipeline", + "description": "Manage your deals", + "defaultViewType": "kanban", + "showSearch": true, + "showFilters": true, + "showCreate": true, + "showViewSwitcher": true, + "operations": { + "create": true, + "read": true, + "update": true, + "delete": true + }, + "searchableFields": ["name", "company", "owner"], + "listViews": { + "all": { + "label": "All Deals", + "columns": ["name", "stage", "value", "owner", "closeDate"], + "filter": [], + "sort": [{ "field": "value", "order": "desc" }] + }, + "my-deals": { + "label": "My Deals", + "columns": ["name", "stage", "value", "owner", "closeDate"], + "filter": [{ "field": "owner", "operator": "equals", "value": "{current_user_id}" }] + } + }, + "defaultListView": "my-deals", + "table": { + "columns": ["name", "stage", "value", "owner", "closeDate"], + "pageSize": 25 + }, + "form": { + "formType": "drawer", + "drawerSide": "right", + "fields": ["name", "stage", "value", "owner", "closeDate", "notes"] + } +} +``` + +`{current_user_id}` in the `my-deals` filter is the spec's context token (`CONTEXT_TOKENS` in `@objectstack/spec/data`): the `object-view` node resolves it to the signed-in user's id before the query runs (objectui#10506), through the same `resolveFilterPlaceholders` from `@object-ui/core` that the app-shell host, the charts and the dashboard widgets call, with the user taken from the nearest `FilterScopeProvider` (`@object-ui/react`; the console shell mounts one). With no provider mounted the token is left as written and the filter is never widened: the ObjectStack server resolves the literal token for a signed-in request and refuses the request otherwise (the REST face answers 401 at its auth gate before the filter is read, and where a guest context reaches the engine the resolver answers 400), and a backend with no resolver of its own matches no record. + +| Property | Type | Description | +|----------|------|-------------| +| `objectName` | `string` | **Required.** ObjectQL object API name. | +| `title` | `string` | View title. | +| `defaultViewType` | `string` | Initial view: `"grid"`, `"kanban"`, `"gallery"`, `"calendar"`, `"timeline"`, `"gantt"`, `"map"`. | +| `listViews` | `Record` | Named list views with filters and sort. Each entry is `@objectstack/spec`'s `ObjectListViewSchema`: it needs `columns`, takes `filter` as `{ field, operator, value }` rules, and puts view-kind config in the top-level block of that kind (`kanban`, `calendar`, …). A legacy `options` bag is refused. | +| `defaultListView` | `string` | Key of the default list view. | +| `table` | grid keys of [ObjectGridSchema](#objectgridschema) | The grid keys the view hands the grid it draws; any other grid key is refused (objectui#10976). The key list is in the `@object-ui/plugin-view` guide. | +| `form` | `Partial` | Form configuration overrides. | +| `showSearch` / `showFilters` / `showCreate` | `boolean` | Toggle toolbar features. | +| `showViewSwitcher` | `boolean` | Show view type toggle (grid, kanban, etc.). | +| `operations` | `object` | Enabled CRUD operations. | +| `navigation` | `ViewNavigationConfig` | SPA-aware navigation. | + +**Related:** [ObjectGridSchema](#objectgridschema), [ObjectFormSchema](#objectformschema), [ViewSwitcherSchema](#viewswitcherschema) + +--- + +## Complex Schemas + +### ObjectKanbanSchema + +A drag-and-drop Kanban board. The `object-kanban` type key validates the shape the registered renderer (`@object-ui/plugin-kanban`) reads: bind the board to an object with `objectName` + `groupBy` — the lanes come from the group field's options — or hand it rows on `data`. + +> **The bare `kanban` node type key is retired** (objectui#8802, ruled 2026-09-09). It published two faces that disagreed with each other, and `object-kanban` is now the one spelling. A `{ "type": "kanban" }` document is refused by name and told to write `object-kanban`. +> +> ⚠️ The **stored view type** `"kanban"` — what `listViews[].type` and `defaultViewType` hold — is a **different layer and is unchanged**. Do not rewrite it: a saved kanban view already renders through the `object-kanban` node type, because `ObjectView` maps the stored type onto it. + +```json +{ + "type": "object-kanban", + "objectName": "tasks", + "groupBy": "status", + "titleField": "title", + "cardFields": ["assignee", "due_date"] +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `objectName` | `string` | Object to fetch records from. | +| `groupBy` | `string` | Field whose values become the lanes (maps to column ids). **Optional** since objectui#8990, matching `@objectstack/spec`. A board that omits it draws whatever lanes `columns` declares — and holds **no cards**, because records are only distributed once a lane key exists. | +| `columns` | `string[] \| KanbanLane[]` | Swimlane definitions — an array of `{ id, title }` lanes (one per `groupBy` value), **or** an array of bare value strings; never a mix. **Not** a field projection (that is `cardFields`). A lane's `cards` is optional: an object-bound board buckets records into the lane by `groupBy`, and only a static board writes a lane's cards itself. A record lands in a lane when its stored `groupBy` value equals the lane **`id`** (the option value, compared case-insensitively); the lane `title` is display only and never decides membership (objectui#10069). A record matching no lane id is shown in a trailing *Uncategorized* lane and named in a console warning. | +| `titleField` | `string` | Field used as the card title. | +| `cardFields` | `string[]` | Fields rendered on each card. | +| `swimlaneField` | `string` | Record field that splits the board into horizontal swimlanes, across the `groupBy` columns. Declared on both faces since objectui#11355, with the type `@objectstack/spec`'s `object-kanban` row gives it. With the key absent the board falls back to `grouping.fields[0].field`. | +| `grouping` | `GroupingConfig` | The fallback for `swimlaneField`: with that key absent, the board splits into swimlanes by `grouping.fields[0].field`, and reads nothing else in the block. The type is `@objectstack/spec`'s `GroupingConfig` (`{ fields: [{ field, order?, collapsed? }] }`), the one the `object-kanban` row and `object-grid` take, by reference on both faces since objectui#11216: at least one entry, no undeclared key, and a `field` written without leading or trailing spaces. | +| `filter` | `any[]` | Query filter, forwarded verbatim as `$filter`. | +| `limit` | `number` | Fetch window for the board (default 100). | +| `coverImageField` | `string` | Field whose URL renders as the card cover image. | +| `conditionalFormatting` | `KanbanConditionalFormattingRule[]` | Card colouring rules, each `{ condition, style }` — a CEL `condition` over the card's `record.*` and a CSS `style` map, the rule a list view declares; the first matching rule styles the card. The native `{ field, operator, value }` rule and a colour written beside `condition` (rather than inside `style`) are retired and refused by name (objectui#11522): `{ field: 'priority', operator: 'equals', value: 'high', backgroundColor: '#fee2e2' }` is `{ condition: "record.priority == 'high'", style: { backgroundColor: '#fee2e2' } }`. | +| `navigation` | `ViewNavigationConfig` | What a card click opens — the spec's `NavigationConfig` by reference, the type `ObjectGridSchema.navigation` uses: `mode` (`drawer`, `page`, `new_window` or `none`) with `size`, `openNewTab` and `preventNavigation`. With the key absent a click opens the record in a drawer. A block written without `mode` takes the spec's `drawer` default. `page` opens the record page through the record navigator the host publishes (objectui#11293); the console publishes one on its custom pages, record pages and list views, and under a host that publishes none the click opens nothing. | + +> `groupField` is refused by name (objectui#7322): the renderer reads `groupBy`. + +> **`quickAdd` and `allowCollapse` were rows of the table above and are not authorable on this board.** `allowCollapse` is **refused by name** by the strict authoring face, which does not declare it at all — a document carrying it fails validation rather than merely going unread, and `@object-ui/plugin-kanban` has no read site for it. `quickAdd` is **retired** on this board (objectui#8285, director seat, decision batch 91): the Quick Add control is gated on an `onQuickAdd` handler as well, and no `object-kanban` path supplies one, so the key never drew anything, and the ruling retired it rather than add inline record creation. The strict face tombstones it (refused by name), `ObjectKanban` no longer forwards it, and `@objectstack/sdui-parser` answers it with the ordinary `unknown-prop` warning. `@object-ui/types` mirrors this face and agrees on both: each is `never` on its TypeScript face and a retirement tombstone on its Zod face (objectui#8801, objectui#8285). The Quick Add pair itself lives on the `KanbanRenderer` component, which a React host mounts directly. + +> `columns` is declared on this face since objectui#8913, as the pair of array shapes `@objectstack/spec` declares — an array of `{ id, title }` lanes, **or** an array of bare value strings. A **mixed** array is refused: the renderer decides which shape it has from the first element alone, so a mix yields a blank lane and mis-bucketed cards. A lane accepts `id`, `title`, `cards`, `limit`, `className` and `collapsed`, which are the members the board implementations read; `id` is a **string** — the authored face keeps that narrowing, and since objectui#8993 a non-string lane id no longer renders every card twice: the bucketer's leftover sweep keys membership the way the injection already did (a lane `1` takes the group `'1'`). When a lane carries `cards`, each card is judged — a card with no `title` is refused. An undeclared lane key is accepted and dropped, not refused, which is this tolerant face's posture; the strict authoring face refuses it by name. +> +> ⚠️ The **bare-string array applies only to a board with no `groupBy`.** It is declared so this package does not refuse an authoring the protocol allows. The renderer reads a bare-string lane list only when a board has no `groupBy` — so on a board that *does* declare one the strings are ignored and the lanes come from the group field's picklist options or from the data. Since objectui#8990 made `groupBy` optional, a lane-less board is a valid authoring and this arm is live on it: the lanes are drawn, titled by the **raw strings** (a grouped board titles its lanes with the picklist *labels* instead). ⚠️ Such a board holds **no cards** — with no lane key the records are never distributed — and dragging a card writes nothing back. It is lane headings, not a populated board; to control the lanes of a working board, declare `groupBy` and write the `{ id, title }` array. +> +> Of the other keys the retired `kanban` arm alone declared, `cardTitle` (objectui#9606), `navigation` (objectui#8652), `swimlaneField` (objectui#11355) and `grouping` (objectui#11216) are declared on this face. `grouping` was the last of them: until objectui#11216 it was kept unjudged here and refused by name on the strict authoring face. + +> **Handler keys are not authorable in JSON, and all three now say so by name.** Since objectui#7804 this face declares `onCardClick` as an objectui#6124 **runtime slot**: a React host supplies the function through the TypeScript interface or as a React prop, and this validator **refuses the key by name** with a message pointing at the node-type spelling (`{ "type": "toast", … }`, an `action:button` node). Until then an authored `onCardClick: { "action": "toast" }` parsed **green** — `BaseSchema` is `.passthrough()`, so a key no arm declares is not refused, it stops being judged and the value is kept, then reaches a call site expecting a function. ⭐ The other two keys are **tombstones**, not runtime slots, so their TypeScript twins are `?: never` rather than callable. `onCardMove` has been one since objectui#9342: an authored one reached **nothing** even as a function, because an object-bound board substitutes its own mover, and the mover lives on `KanbanRenderer`'s React prop of the same name, a sibling of its `schema`. `onQuickAdd` has been one since objectui#11234: a supplied one reached the board and was never called, because its partner `quickAdd` is retired here. `ObjectKanban` renders an internal board that takes the Quick Add pair only as explicit props and supplies neither half; the pair lives on `KanbanRenderer`'s `schema`, for a React host. + +> `data` and `bind` are [`BaseSchema`](#baseschema) members, not narrowed here, but this face requires **one of** `bind`, `data`, `objectName` — the renderer's own record-source ladder (an external `data` prop → `bind` via `useDataScope` → this schema's own `data` → a fetch keyed by `objectName`) — or a `dataSource` binding whose `object` names the object, which the registration's `ElementDataSourceGate` lands on `objectName` before the board reads it (objectui#11117). A purely static board (lanes carrying their own cards, no record source) authors `"groupBy"` and `"data": []`. ⚠️ The record-source rule is **separate** from the lane key and is unaffected by objectui#8990: omitting `groupBy` is fine, omitting all of `bind` / `data` / `objectName` with no binding is still refused, at the refinement rather than at `groupBy`. + +**Related:** [ObjectViewSchema](#objectviewschema), [ObjectGridSchema](#objectgridschema) + +--- + +### DashboardComponentSchema + +A widget-based dashboard with configurable grid layout and auto-refresh. + +```json +{ + "type": "dashboard", + "columns": 4, + "gap": 6, + "refreshIntervalSeconds": 30, + "widgets": [ + { + "id": "revenue", + "type": "metric", + "title": "Total Revenue", + "layout": { "x": 0, "y": 0, "w": 1, "h": 2 }, + "dataset": "sales", + "values": ["revenue"] + }, + { + "id": "sales_trend", + "type": "area", + "title": "Sales Trend", + "layout": { "x": 1, "y": 0, "w": 2, "h": 4 }, + "dataset": "sales", + "dimensions": ["day"], + "values": ["revenue"] + }, + { + "id": "open_tasks", + "type": "table", + "title": "Open Tasks by Owner", + "layout": { "x": 3, "y": 0, "w": 1, "h": 4 }, + "dataset": "tasks", + "dimensions": ["owner"], + "values": ["open_count"] + } + ] +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `columns` | `number` | Number of grid columns. | +| `gap` | `number` | Gap between widgets (Tailwind spacing scale). | +| `widgets` | `(DashboardWidgetSlotComponentSchema \| DashboardWidgetSchema)[]` | **Required.** Each entry is a widget or a component node. A widget (`DashboardWidgetSchema`) names itself with `id`, `title` and `description`, sizes itself with `layout: { x, y, w, h }`, and holds its content either as a family named in `type` bound to a `dataset` (with that family's settings under `options`), or as a registered component node in `component`; its full key set is the spec's `DashboardWidget` plus objectui's own. A component node (`DashboardWidgetSlotComponentSchema`) sits in the slot directly: its `type` is a member of the closed `DASHBOARD_COMPONENT_WIDGET_TYPES` set, such as `metric-card`, its other keys are that component's own props, and it is placed by the same `layout` a widget is, which the editable grid's Save Layout writes onto every entry. | +| `refreshIntervalSeconds` | `number` | Auto-refresh interval in **seconds** — the renderer multiplies by 1000. Renamed from `refreshInterval`, which this table documented as milliseconds and which it never was (objectui#7783). | + +A widget's size is its `layout`: `w` and `h` are the grid columns and rows it spans, and `x` and `y` are its position on the editable grid (`DashboardGridLayout`). `layout` takes all four numbers or is left out. `colSpan`, `rowSpan` and `body` are **not** widget keys: `DashboardWidgetSchema` is strict (objectui#6002) and refuses all three by name. The size is `layout.w` / `layout.h`, and the content is `type` + `dataset` (with `options`) or `component`. + +A widget's data is a `dataset` (ADR-0021): `values` names the measures it shows and `dimensions` the dimensions it groups them by, both selected from the dataset by name. The family in `type` decides how the result is drawn: `metric` shows its one measure as a number; `table` lists a row per dimension value; a chart family (`area`, `bar`, `line`, `pie`, …) plots one series per measure over the dimension. A widget never carries rows. `options.data`, `options.xField` / `options.yField`, a metric's `options.value` / `options.description` / `options.trend`, and a `component` chart's `chartType` / `xAxisKey` / `series` are not widget keys, and `StrictAnyComponentSchema` refuses each of them by name (objectui#11228). The widget's own `description` is the subtitle under its `title` in the card header. + +**Related:** [GridSchema](#gridschema), [ChartSchema](#chartschema), [CardSchema](#cardschema) + +--- + +### CalendarViewSchema + +A multi-view calendar computed from the node's `data` records. There is no +authorable `events` key: the renderer builds one event per record in `data`, +reading the fields the field-name properties point at (objectui#5667; an +authored `events` is dropped by design, objectui#4433). + +```json +{ + "type": "calendar-view", + "view": "month", + "currentDate": "2024-03-15T12:00:00.000Z", + "data": [ + { + "id": "evt-1", + "title": "Team Standup", + "start": "2024-03-15T09:00:00", + "end": "2024-03-15T09:30:00", + "color": "#3b82f6" + }, + { + "id": "evt-2", + "title": "Sprint Review", + "start": "2024-03-15T14:00:00", + "end": "2024-03-15T15:00:00", + "color": "#8b5cf6", + "allDay": false + } + ], + "allowCreate": true, + "className": "h-[600px] border rounded-lg" +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `data` | `any` | Records rendered as events — an array, or a binding expression that resolves to one. | +| `titleField` | `string` | Record field for the event title. Default `"title"`. | +| `startDateField` | `string` | Record field for the event start date/time. Default `"start"`. | +| `endDateField` | `string` | Record field for the event end date/time. Default `"end"`. | +| `allDayField` | `string` | Record field for the all-day flag. Default `"allDay"`. | +| `colorField` | `string` | Record field for the event color. Default `"color"`. | +| `view` | `CalendarViewMode` | View mode: `"month"`, `"week"`, `"day"` — the full union. `"agenda"` was retired in `b55a34647` and now fails validation. Default `"month"`. | +| `currentDate` | `string \| Date` | Initial calendar date — an ISO date string when authored as JSON. | +| `allowCreate` | `boolean` | Show the "New event" affordance; clicking it dispatches a `create` action. Default `false`. | +| `onEventClick` | `function` | Host-only: forwarded when a React host supplies a function; authored JSON cannot produce one. | +| `onViewChange` | `function` | Host-only: same rule as `onEventClick`. | + +Nine formerly declared keys — `events` (was required, and dropped by the +renderer), `defaultView`, `defaultDate`, `date`, `views`, `editable`, +`onEventCreate`, `onEventUpdate`, `onDateChange` — were retired in +objectui#5667: nothing read them on the authored-node path. + +**Related:** [ObjectViewSchema](#objectviewschema), [DashboardComponentSchema](#dashboardcomponentschema) + +--- + +## View Schemas + +### DetailViewSchema + +An enhanced detail view for a single record with sections, tabs and navigation. + +```json +{ + "type": "detail-view", + "title": "Contact Details", + "objectName": "Contact", + "resourceId": "contact-123", + "layout": "grid", + "columns": 2, + "showBack": true, + "backUrl": "/contacts", + "showEdit": true, + "editUrl": "/contacts/contact-123/edit", + "showDelete": true, + "deleteConfirmation": "Are you sure you want to delete this contact?", + "sections": [ + { + "title": "Personal Information", + "icon": "User", + "columns": 2, + "collapsible": true, + "fields": [ + { "name": "firstName", "label": "First Name", "type": "text" }, + { "name": "lastName", "label": "Last Name", "type": "text" }, + { "name": "email", "label": "Email", "type": "email" }, + { "name": "avatar", "label": "Photo", "type": "image" } + ] + } + ], + "tabs": [ + { + "key": "activities", + "label": "Activities", + "icon": "Activity", + "badge": 5, + "content": { "type": "timeline", "items": [] } + } + ], + "actions": [ + { "type": "action", "label": "Send Email", "icon": "Mail", "level": "primary" } + ] +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `title` | `string` | Detail page title. | +| `objectName` | `string` | ObjectQL object name for data binding. | +| `resourceId` | `string \| number` | Record ID to display. | +| `api` | `string` | API endpoint to fetch record data. | +| `data` | `any` | Static data (if not fetching from API). | +| `layout` | `"vertical" \| "horizontal" \| "grid"` | Field layout mode. | +| `columns` | `number` | Grid columns (for grid layout). | +| `sections` | `DetailViewSection[]` | Field groups with `title`, `icon`, `fields`, `collapsible`. | +| `fields` | `DetailViewField[]` | Direct fields (without sections). | +| `tabs` | `DetailViewTab[]` | Tabbed content with `key`, `label`, `icon`, `badge`, `content`. | +| `related` | ⛔ **RETIRED** | Retired in objectui#7997 (ADR-0049 enforce-or-remove). Authoring it is now refused by name on both faces. Author a `record:related_list` block instead — see below. | +| `actions` | `ActionSchema[]` | Available actions. | +| `showHeader` | `boolean` | Draw the view's own heading — the title, follow-star and copy-id chip above the fields. Drawn unless `false`. `record:details` sets it from its own `showHeader` on the `detail-view` it builds, so the type is that spec row's. Declared since objectui#11355. | +| `showBack` / `backUrl` | `boolean` / `string` | Back navigation. | +| `showEdit` / `editUrl` | `boolean` / `string` | Edit navigation. | +| `showDelete` / `deleteConfirmation` | `boolean` / `string` | Delete with confirmation message. | +| `header` / `footer` | `SchemaNode` | Custom header/footer content. | + +> **Retired: `related`** (objectui#7997, ADR-0049 enforce-or-remove). +> Author a `record:related_list` block instead. +> +> Until objectui#7997 this block carried its own `related` array, and this page +> taught it with `{ "name": ..., "label": ... }` columns. That array is retired +> under ADR-0049 enforce-or-remove: it was a second entry to a capability +> `@objectstack/spec` already governs, it mirrored no protocol schema, and it +> drifted from the renderer it fed. Authoring it is now **refused by name** on +> both the TypeScript and the JSON face — it is not silently ignored. +> +> Related lists have one declared entry now, and it renders through the same component: +> +> ```json +> { +> "type": "record:related_list", +> "properties": { +> "objectName": "order", +> "relationshipField": "contact_id", +> "title": "Recent Orders", +> "columns": ["id", "total", "status"] +> } +> } +> ``` +> +> The block's props go in its `properties` bag, the spec's +> `ComponentPropsMap['record:related_list']` row: `objectui validate` and the +> spec's page component both refuse them written flat on the node. +> +> ⚠️ `columns` here is an array of **field-name strings**, not column objects — +> that is what the protocol declares (`RecordRelatedListProps.columns`), and the +> header and cell formatting are derived from the related object's schema, so a +> field label rename reaches the list for free. `relationshipField` names the +> field on the RELATED object that points back at this record, and replaces the +> retired form's `api` endpoint. +> +> ⚠️ The block reads the parent record from the record page's `RecordContext`, +> so author it on a record page. Placed anywhere it cannot resolve a parent id +> it scopes to nothing and renders an empty list. + +> **Due/deadline fields: `dueLike` and `settledWhen`.** A deadline is declared +> on the object's field metadata, never on a field entry: `dueLike` and its +> partner `settledWhen` are `@objectstack/spec`'s `FieldSchema.dueLike` and +> `FieldSchema.settledWhen`, both for `date` / `datetime` fields only. A +> `DetailViewField` entry carries neither — the detail view reads both from the +> object field it draws. See [Date Field](/docs/data-modeling/field-types) and +> [Overdue Affordance](/docs/data-modeling/field-types). +> +> `settledWhen` is a CEL predicate over the record (`record.status == 'done'`), +> accepted only beside `dueLike: true`. The detail view evaluates it against +> the record it shows: while it holds, the deadline is settled and the cell +> paints no overdue wording or colour. Nothing is inferred from a field's +> name: an undeclared date in the past reads as any past date does. + +**Related:** [DetailSchema](#detailschema), [ObjectViewSchema](#objectviewschema) + +--- + +### ViewSwitcherSchema + +A toggle control that switches between different view types (list, grid, kanban, calendar, etc.). + +```json +{ + "type": "view-switcher", + "defaultView": "list", + "variant": "tabs", + "position": "top", + "persistPreference": true, + "storageKey": "contacts-view-pref", + "views": [ + { + "type": "list", + "label": "List View", + "icon": "List", + "schema": { + "type": "object-grid", + "properties": { + "objectName": "Contact", + "columns": ["name", "email", "phone"] + } + } + }, + { + "type": "grid", + "label": "Card View", + "icon": "LayoutGrid", + "schema": { + "type": "grid", + "columns": 3, + "children": [] + } + }, + { + "type": "kanban", + "label": "Kanban", + "icon": "Kanban", + "schema": { + "type": "object-kanban", + "objectName": "tasks", + "groupBy": "status" + } + } + ] +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `views` | `array` | **Required.** Available views, each with `type`, `label`, `icon`, and `schema`. | +| `defaultView` | `ViewType` | Initially active view: `"list"`, `"detail"`, `"grid"`, `"kanban"`, `"calendar"`, `"timeline"`, `"map"`. | +| `activeView` | `ViewType` | Controlled active view. | +| `variant` | `"tabs" \| "buttons" \| "dropdown"` | Switcher UI style. | +| `position` | `"top" \| "bottom" \| "left" \| "right"` | Switcher position relative to content. | +| `persistPreference` | `boolean` | Save the user's view preference to storage. | +| `storageKey` | `string` | Storage key for persisting the preference. | +| `onViewChange` | `string` | Event name dispatched on `window` as a `CustomEvent` when the view changes (`detail: { view }`). An event NAME, not a callback or a handler expression. | + +**Related:** [ObjectViewSchema](#objectviewschema), [ObjectKanbanSchema](#objectkanbanschema), [CalendarViewSchema](#calendarviewschema) + +--- + +## Schema Composition + +Schemas are designed to compose. Nest any `SchemaNode` inside another to build complex interfaces: + +```json +{ + "type": "page", + "pageType": "app", + "title": "CRM Dashboard", + "children": [ + { + "type": "grid", + "columns": { "sm": 1, "lg": 2 }, + "gap": 6, + "children": [ + { + "type": "card", + "title": "Quick Stats", + "children": { + "type": "dashboard", + "columns": 2, + "widgets": [ + { "type": "metric-card", "title": "Leads", "value": "142" }, + { "type": "metric-card", "title": "Revenue", "value": "$24k" } + ] + } + }, + { + "type": "card", + "title": "Recent Activity", + "children": { + "type": "tabs", + "items": [ + { "value": "deals", "label": "Deals", "content": { "type": "table", "columns": [], "data": [] } }, + { "value": "tasks", "label": "Tasks", "content": { "type": "table", "columns": [], "data": [] } } + ] + } + } + ] + }, + { + "type": "object-grid", + "properties": { + "objectName": "Lead", + "title": "All Leads", + "showSearch": true, + "columns": ["name", "company", "status", "value"] + } + } + ] +} +``` + +## Type Imports + +Import only the types you need: + +```typescript +// Layout +import type { PageNodeSchema, DivSchema, CardSchema, GridSchema, TabsSchema } from '@object-ui/types'; + +// Forms +import type { FormSchema, InputSchema, SelectSchema, ButtonSchema } from '@object-ui/types'; + +// Data Display +import type { TableSchema, ChartSchema, TreeViewSchema } from '@object-ui/types'; + +// CRUD +import type { ActionSchema, DetailSchema } from '@object-ui/types'; + +// ObjectQL +import type { ObjectGridSchema, ObjectFormSchema, ObjectViewSchema } from '@object-ui/types'; + +// Complex +import type { DashboardComponentSchema, CalendarViewSchema } from '@object-ui/types'; + +// Views +import type { DetailViewSchema, ViewSwitcherSchema } from '@object-ui/types'; + +// Base +import type { BaseSchema, SchemaNode } from '@object-ui/types'; +``` + +## Next Steps + +- **[Schema Overview](/docs/sdk/guide/schema-overview)** — High-level guide to ObjectUI schemas +- **[Quick Start](/docs/sdk/guide/quick-start)** — Build your first ObjectUI application +- **[Expressions](/docs/ui)** — Dynamic expressions with `visibleWhen`, `hidden`, `disabled` +- **[Fields Guide](/docs/sdk/guide/fields)** — Deep dive into form fields +- **[Plugin Development](/docs/sdk/guide/plugin-development)** — Build custom schema renderers diff --git a/content/docs/sdk/components/basic/button-group.mdx b/content/docs/sdk/components/basic/button-group.mdx new file mode 100644 index 00000000000..1c075eb77b7 --- /dev/null +++ b/content/docs/sdk/components/basic/button-group.mdx @@ -0,0 +1,103 @@ +--- +title: "Button Group" +description: "Group multiple buttons together with shared styling" +--- + +The Button Group component groups multiple buttons together with consistent styling. + +## Basic Usage + +**Basic Button Group** + +```json +{ + "type": "button-group", + "buttons": [ + { + "label": "Left" + }, + { + "label": "Center" + }, + { + "label": "Right" + } + ] +} +``` + +## Variants + +**Outline Variant** + +```json +{ + "type": "button-group", + "variant": "outline", + "buttons": [ + { + "label": "Day" + }, + { + "label": "Week" + }, + { + "label": "Month" + } + ] +} +``` + +## Schema + +```ts +interface ButtonGroupButton { + label: string; // Button label (required) + variant?: 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link'; + size?: 'default' | 'sm' | 'lg' | 'icon'; + disabled?: boolean; // Whether this button is disabled + onClick?: never; // RETIRED (objectui#6124) — author behaviour + // as a node type, e.g. an action:button node + className?: string; // Per-button CSS class +} + +interface ButtonGroupSchema { + type: 'button-group'; + buttons?: ButtonGroupButton[]; // Button definitions + + // Defaults applied to every button that does not set its own + variant?: 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link'; + size?: 'default' | 'sm' | 'lg' | 'icon'; + + // States + disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope + + // Styling + className?: string; +} +``` + +## Examples + +### Toolbar Actions + +**Icon Toolbar** + +```json +{ + "type": "button-group", + "variant": "outline", + "size": "sm", + "buttons": [ + { + "label": "Copy" + }, + { + "label": "Cut" + }, + { + "label": "Paste" + } + ] +} +``` diff --git a/content/docs/sdk/components/basic/div.mdx b/content/docs/sdk/components/basic/div.mdx new file mode 100644 index 00000000000..50cd377c239 --- /dev/null +++ b/content/docs/sdk/components/basic/div.mdx @@ -0,0 +1,250 @@ +--- +title: "Div (Deprecated)" +description: "Generic container element - use Shadcn components instead" +--- + +
+

⚠️ Deprecated Component

+

+ The Div component is deprecated. Please use Shadcn components instead: +

+
    +
  • For a plain wrapper: use box — the one drop-in swap (same element, your className verbatim, no layout of its own)
  • +
  • When you want their layout: use card, flex, container, stack, or grid — each injects classes of its own, and card also moves children into an extra element
  • +
+

+ Move any content authored under the retired body key into children first: + objectui#6771 retired that child-list spelling, so validation refuses it by name, and neither + div nor any replacement listed above draws it. +

+
+ +The Div component is a generic container element. **It is now deprecated in favor of semantic Shadcn-based components** that provide better accessibility, consistency, and design system integration. + +**Scope of this deprecation: both authoring surfaces.** A `kind:'html'` page is written +as constrained JSX text that the engine compiles (parses, never executes) into nodes, +tag name straight through. On that tier `
` is **refused**: the page fails to +compile, and the error names `box` as the replacement. `os validate` gives the same +verdict, since the published manifest does not declare `div` for the html tier, but its +refusal is a plain `forbidden-tag` error that does not name `box`. Author `` on an +html page exactly where you would author `box` in JSON. The +dev-build deprecation notice covers JSON-authored nodes, and the compile error covers +html pages. + +Styling on that tier is the blocks' own structured props (``, +``) plus a JSON `style` object — **not** Tailwind utility classes, which +produce no CSS at all when authored in page `source`. `os validate` reports that as +`page-source-className-tailwind`. See the React Pages guide, §Styling. + +## Migration Guide + +Which replacement is right depends on whether you want layout or only a wrapper. +Measured through the real renderer, on a node carrying an authored `className`: +`box` reproduces `div` exactly, while `card`, `flex`, `container`, `stack` and +`grid` each add classes of their own — and `card` additionally moves the children +inside an extra element. So a mechanical `div` → `card`/`flex`/`grid` swap changes +the page; a `div` → `box` swap does not. + +One caveat for a node still authored in the retired `body` spelling: every +option reads `children` and never `body` (objectui#6771 retired it across the +protocol, `card` included), and so does `div` itself. Such a node must move that +content into `children` as part of the swap. Until it does, validation refuses +the key by name, and the content is missing from the page under `div` and under +every replacement alike. + +### For a Plain Wrapper → Use `box` + +The mechanical swap. `box` renders `children` inside a plain block element with +your `className` passed through verbatim and nothing injected, so retyping a +`div` node to `box` is the one conversion that leaves the rendered result alone. + +**Use Box Instead** + +```json +{ + "type": "box", + "className": "max-w-sm rounded-lg border bg-card text-card-foreground shadow-sm", + "children": [ + { + "type": "box", + "className": "p-6 space-y-2", + "children": [ + { + "type": "text", + "content": "Card Title", + "className": "text-2xl font-semibold" + }, + { + "type": "text", + "content": "The same markup as the div example, retyped to box: className verbatim, no layout injected, no extra element.", + "className": "text-sm text-muted-foreground" + } + ] + } + ] +} +``` + +### For Card-like Containers → Use `card` + +**Use Card Instead** + +```json +{ + "type": "card", + "children": [ + { + "type": "text", + "content": "Card Title", + "className": "text-2xl font-semibold" + }, + { + "type": "text", + "content": "Card content with proper semantic structure.", + "className": "text-sm text-muted-foreground mt-2" + } + ] +} +``` + +### For Flex Layouts → Use `flex` + +**Flex Layout** + +```json +{ + "type": "flex", + "properties": { + "gap": 4, + "children": [ + { + "type": "card", + "className": "w-20 h-20 bg-blue-500" + }, + { + "type": "card", + "className": "w-20 h-20 bg-green-500" + }, + { + "type": "card", + "className": "w-20 h-20 bg-purple-500" + } + ] + } +} +``` + +### For Grid Layouts → Use `grid` + +**Grid Layout** + +```json +{ + "type": "grid", + "columns": 2, + "gap": 4, + "children": [ + { + "type": "card", + "className": "h-20 bg-red-500" + }, + { + "type": "card", + "className": "h-20 bg-yellow-500" + }, + { + "type": "card", + "className": "h-20 bg-pink-500" + }, + { + "type": "card", + "className": "h-20 bg-indigo-500" + } + ] +} +``` + +## Old Examples (Deprecated) + +Below are the old `div` examples. Please migrate to the alternatives shown above. + +## Schema + +```plaintext +// ⚠️ DEPRECATED - Use Card, Flex, Grid, or other layout components instead +interface DivSchema { + type: 'div'; + + // Content + children?: SchemaNode | SchemaNode[]; // Child components (the child key this component reads) + + // Styling + className?: string; // Tailwind CSS classes + + // Base properties + id?: string; + visible?: boolean | string | { dialect?: string; source: string }; + testId?: string; +} +``` + +## Legacy Examples (For Reference Only) + +### Old Card Layout Example + +**Custom Card** + +```json +{ + "type": "div", + "className": "max-w-sm rounded-lg border bg-card text-card-foreground shadow-sm", + "children": [ + { + "type": "div", + "className": "p-6 space-y-2", + "children": [ + { + "type": "text", + "content": "Card Title", + "className": "text-2xl font-semibold" + }, + { + "type": "text", + "content": "Card description goes here. This is a basic card layout using div containers.", + "className": "text-sm text-muted-foreground" + } + ] + } + ] +} +``` + +### Nested Containers + +**Nested Divs** + +```json +{ + "type": "div", + "className": "p-6 border-2 border-dashed border-blue-300 rounded-lg", + "children": [ + { + "type": "div", + "className": "p-4 border-2 border-dashed border-green-300 rounded", + "children": [ + { + "type": "div", + "className": "p-3 border-2 border-dashed border-purple-300 rounded text-center", + "children": [ + { + "type": "text", + "content": "Nested Content", + "className": "font-mono text-sm" + } + ] + } + ] + } + ] +} +``` diff --git a/content/docs/sdk/components/basic/meta.json b/content/docs/sdk/components/basic/meta.json new file mode 100644 index 00000000000..f0f386ec778 --- /dev/null +++ b/content/docs/sdk/components/basic/meta.json @@ -0,0 +1,11 @@ +{ + "title": "Basic", + "pages": [ + "separator", + "button-group", + "pagination", + "navigation-menu", + "sidebar", + "div" + ] +} diff --git a/content/docs/sdk/components/basic/navigation-menu.mdx b/content/docs/sdk/components/basic/navigation-menu.mdx new file mode 100644 index 00000000000..8e514a6ba0e --- /dev/null +++ b/content/docs/sdk/components/basic/navigation-menu.mdx @@ -0,0 +1,97 @@ +--- +title: "Navigation Menu" +description: "Accessible navigation menu with dropdown support" +--- + +The Navigation Menu component provides an accessible menu for site navigation. + +## Basic Usage + +**Site Navigation** + +```json +{ + "type": "navigation-menu", + "items": [ + { + "label": "Home", + "href": "/" + }, + { + "label": "Products", + "children": [ + { + "label": "All Products", + "href": "/products" + }, + { + "label": "New Arrivals", + "href": "/products/new" + }, + { + "label": "Best Sellers", + "href": "/products/popular" + } + ] + }, + { + "label": "About", + "href": "/about" + }, + { + "label": "Contact", + "href": "/contact" + } + ] +} +``` + +## With Descriptions + +**Documentation Nav** + +```json +{ + "type": "navigation-menu", + "items": [ + { + "label": "Getting Started", + "children": [ + { + "label": "Introduction", + "description": "Learn the basics", + "href": "/docs/intro" + }, + { + "label": "Installation", + "description": "Install the package", + "href": "/docs/install" + }, + { + "label": "Quick Start", + "description": "Build your first app", + "href": "/docs/quick-start" + } + ] + } + ] +} +``` + +## Schema + +```ts +interface NavigationMenuItem { + label: string; + href?: string; + description?: string; + icon?: string; + items?: NavigationMenuItem[]; // Submenu items +} + +interface NavigationMenuSchema { + type: 'navigation-menu'; + items: NavigationMenuItem[]; // Menu items + className?: string; +} +``` diff --git a/content/docs/sdk/components/basic/pagination.mdx b/content/docs/sdk/components/basic/pagination.mdx new file mode 100644 index 00000000000..cfe9abe54ee --- /dev/null +++ b/content/docs/sdk/components/basic/pagination.mdx @@ -0,0 +1,51 @@ +--- +title: "Pagination" +description: "Navigate through pages of content" +--- + +The Pagination component allows users to navigate through pages of data. + +## Basic Usage + +**Basic Pagination** + +```json +{ + "type": "pagination", + "currentPage": 1, + "totalPages": 10 +} +``` + +## On a Later Page + +**With Item Count** + +```json +{ + "type": "pagination", + "currentPage": 3, + "totalPages": 20 +} +``` + +`pagination` has no page-size or item-count keys: the page state it reads is +`currentPage` and `totalPages`. + +## Schema + +```ts +interface PaginationSchema { + type: 'pagination'; + currentPage: number; // Current page (1-based) + totalPages: number; // Total number of pages + + // Events + // RUNTIME SLOT (objectui#6124): a React host supplies it; JSON refuses it + // by name. Author behaviour as a node type, e.g. an action:button node. + onPageChange?: (page: number) => void; + + // Styling + className?: string; +} +``` diff --git a/content/docs/sdk/components/basic/separator.mdx b/content/docs/sdk/components/basic/separator.mdx new file mode 100644 index 00000000000..32feee15f6c --- /dev/null +++ b/content/docs/sdk/components/basic/separator.mdx @@ -0,0 +1,63 @@ +--- +title: "Separator" +description: "Visual divider between content sections" +--- + +## Examples + +**Horizontal** + +```json +{ + "type": "stack", + "gap": 4, + "children": [ + { + "type": "text", + "content": "Content Above" + }, + { + "type": "separator" + }, + { + "type": "text", + "content": "Content Below" + } + ] +} +``` + +**Vertical** + +```json +{ + "type": "flex", + "properties": { + "gap": 4, + "children": [ + { + "type": "text", + "content": "Left" + }, + { + "type": "separator", + "orientation": "vertical" + }, + { + "type": "text", + "content": "Right" + } + ] + } +} +``` + +## Schema + +```ts +interface SeparatorSchema { + type: 'separator'; + orientation?: 'horizontal' | 'vertical'; + className?: string; +} +``` diff --git a/content/docs/sdk/components/basic/sidebar.mdx b/content/docs/sdk/components/basic/sidebar.mdx new file mode 100644 index 00000000000..33fc468d00c --- /dev/null +++ b/content/docs/sdk/components/basic/sidebar.mdx @@ -0,0 +1,259 @@ +--- +title: "Sidebar" +description: "An app-level sidebar region that works in any host" +--- + +The `sidebar` node draws one sidebar region and renders its `children` inside it, in order. + +It works in any host. A host that already mounts a sidebar provider, such as the app shell, keeps its own: the node uses that provider rather than mounting a second one. In a host without one, the node mounts its own. + +## Basic Usage + +**Basic Sidebar** + +```json +{ + "type": "sidebar", + "collapsible": false, + "className": "gap-1 border-r p-2", + "children": [ + { + "type": "text", + "content": "Menu", + "className": "px-2 py-1.5 text-xs font-medium text-muted-foreground" + }, + { + "type": "button", + "label": "Dashboard", + "variant": "ghost", + "className": "w-full justify-start" + }, + { + "type": "button", + "label": "Projects", + "variant": "ghost", + "className": "w-full justify-start" + }, + { + "type": "button", + "label": "Team", + "variant": "ghost", + "className": "w-full justify-start" + }, + { + "type": "button", + "label": "Settings", + "variant": "ghost", + "className": "w-full justify-start" + } + ] +} +``` + +## With Groups + +A group is a heading `text` node followed by its items. A `separator` node divides two groups. + +**Grouped Sidebar** + +```json +{ + "type": "sidebar", + "collapsible": false, + "className": "gap-1 border-r p-2", + "children": [ + { + "type": "text", + "content": "Main", + "className": "px-2 py-1.5 text-xs font-medium text-muted-foreground" + }, + { + "type": "button", + "label": "Dashboard", + "variant": "ghost", + "className": "w-full justify-start" + }, + { + "type": "button", + "label": "Analytics", + "variant": "ghost", + "className": "w-full justify-start" + }, + { + "type": "separator", + "className": "my-2" + }, + { + "type": "text", + "content": "Management", + "className": "px-2 py-1.5 text-xs font-medium text-muted-foreground" + }, + { + "type": "button", + "label": "Projects", + "variant": "ghost", + "className": "w-full justify-start" + }, + { + "type": "button", + "label": "Team", + "variant": "ghost", + "className": "w-full justify-start" + } + ] +} +``` + +## The `collapsible` key + +`collapsible` is a boolean. + +- `false` draws the sidebar in the page flow, at a fixed width. It does not follow the host's open state, so it is visible in every host. Every example on this page authors it. +- `true`, or leaving the key out, selects the collapsible form instead. That form is not drawn in the page flow: it is positioned against the edge of the viewport, at full viewport height, so it does not appear where the schema places it. Author `false` for a sidebar that does. + +**Non-Collapsible Sidebar** — `collapsible: false` draws the sidebar in the page flow at a fixed width, whatever the host's sidebar state. + +```json +{ + "type": "sidebar", + "collapsible": false, + "className": "gap-1 border-r p-2", + "children": [ + { + "type": "button", + "label": "Home", + "variant": "ghost", + "className": "w-full justify-start" + }, + { + "type": "button", + "label": "Files", + "variant": "ghost", + "className": "w-full justify-start" + }, + { + "type": "button", + "label": "Settings", + "variant": "ghost", + "className": "w-full justify-start" + } + ] +} +``` + +## The `side` key + +`side` picks the edge of the viewport the collapsible form is drawn against: `left`, the default, or `right`. On the right edge the sidebar's border moves to its left side, and on a narrow viewport the sheet slides in from the right. + +```json +{ + "type": "sidebar", + "side": "right", + "children": [ + { "type": "text", "content": "Details", "className": "px-2 text-xs text-muted-foreground" } + ] +} +``` + +`side` shows on the collapsible form only, as `variant` does. With `collapsible: false` the sidebar is drawn in the page flow, where the page's layout places it, so every value draws the same column. + +## Schema + +```ts +import type { SidebarSchema } from '@object-ui/types'; + +const sidebar: SidebarSchema = { + type: 'sidebar', + collapsible: false, + className: 'gap-1 border-r p-2', + children: [ + { type: 'text', content: 'Menu', className: 'px-2 text-xs text-muted-foreground' }, + { type: 'button', label: 'Dashboard', variant: 'ghost', className: 'w-full justify-start' }, + { type: 'button', label: 'Settings', variant: 'ghost', className: 'w-full justify-start' }, + ], +}; +``` + +The renderer reads `collapsible` and `children`, and passes `side`, `variant` and `className` to the sidebar element. + +## Examples + +### With Badges + +Each item is a `flex` row holding a `text` node and a `badge` node. + +**Sidebar With Badges** + +```json +{ + "type": "sidebar", + "collapsible": false, + "className": "gap-1 border-r p-2", + "children": [ + { + "type": "flex", + "className": "rounded-md px-2 py-1.5 text-sm", + "properties": { + "align": "center", + "justify": "between", + "children": [ + { + "type": "text", + "content": "Inbox" + }, + { + "type": "badge", + "label": "12", + "className": "ml-auto" + } + ] + } + }, + { + "type": "flex", + "className": "rounded-md px-2 py-1.5 text-sm", + "properties": { + "align": "center", + "justify": "between", + "children": [ + { + "type": "text", + "content": "Drafts" + }, + { + "type": "badge", + "label": "3", + "className": "ml-auto" + } + ] + } + }, + { + "type": "flex", + "className": "rounded-md px-2 py-1.5 text-sm", + "properties": { + "align": "center", + "children": [ + { + "type": "text", + "content": "Sent" + } + ] + } + }, + { + "type": "flex", + "className": "rounded-md px-2 py-1.5 text-sm", + "properties": { + "align": "center", + "children": [ + { + "type": "text", + "content": "Trash" + } + ] + } + } + ] +} +``` diff --git a/content/docs/sdk/components/complex/carousel.mdx b/content/docs/sdk/components/complex/carousel.mdx new file mode 100644 index 00000000000..913d2b505f7 --- /dev/null +++ b/content/docs/sdk/components/complex/carousel.mdx @@ -0,0 +1,379 @@ +--- +title: "Carousel" +description: "Slideshow component for cycling through content" +--- + +The Carousel component displays content in a slideshow format with navigation controls. + +## Basic Usage + +**Simple Carousel** + +```json +{ + "type": "carousel", + "orientation": "horizontal", + "showArrows": true, + "className": "w-full max-w-xs", + "items": [ + [ + { + "type": "box", + "className": "p-8 border rounded bg-blue-50", + "children": [ + { + "type": "text", + "content": "Slide 1" + } + ] + } + ], + [ + { + "type": "box", + "className": "p-8 border rounded bg-green-50", + "children": [ + { + "type": "text", + "content": "Slide 2" + } + ] + } + ], + [ + { + "type": "box", + "className": "p-8 border rounded bg-purple-50", + "children": [ + { + "type": "text", + "content": "Slide 3" + } + ] + } + ] + ] +} +``` + +## Without Arrows + +**No Arrows** + +```json +{ + "type": "carousel", + "orientation": "horizontal", + "showArrows": false, + "className": "w-full max-w-md", + "items": [ + [ + { + "type": "box", + "className": "p-12 border rounded bg-gradient-to-r from-pink-500 to-purple-500 text-white text-center", + "children": [ + { + "type": "text", + "content": "1" + } + ] + } + ], + [ + { + "type": "box", + "className": "p-12 border rounded bg-gradient-to-r from-blue-500 to-cyan-500 text-white text-center", + "children": [ + { + "type": "text", + "content": "2" + } + ] + } + ], + [ + { + "type": "box", + "className": "p-12 border rounded bg-gradient-to-r from-orange-500 to-red-500 text-white text-center", + "children": [ + { + "type": "text", + "content": "3" + } + ] + } + ] + ] +} +``` + +## Schema + +```ts +import type { SchemaNode } from '@object-ui/types'; + +interface CarouselSchema { + type: 'carousel'; + + // Configuration + orientation?: 'horizontal' | 'vertical'; // Carousel direction (default: 'horizontal') + showArrows?: boolean; // Show navigation arrows (default: true) + + // Items + items: SchemaNode[][]; // Array of slides (each slide is an array of components) + + // Options + opts?: { + loop?: boolean; // Enable infinite loop + align?: 'start' | 'center' | 'end'; // Slide alignment + }; + + // Styling + className?: string; // Container CSS classes + itemClassName?: string; // Individual slide CSS classes + + // Base properties + id?: string; + visible?: boolean | string | { dialect?: string; source: string }; + testId?: string; +} +``` + +## Examples + +### Image Gallery + +**Photo Gallery** + +```json +{ + "type": "carousel", + "orientation": "horizontal", + "showArrows": true, + "className": "w-full max-w-lg", + "itemClassName": "basis-full", + "items": [ + [ + { + "type": "flex", + "properties": { + "justify": "center", + "align": "center", + "gap": 0, + "children": [ + { + "type": "text", + "content": "Photo 1" + } + ] + }, + "className": "aspect-video bg-gradient-to-br from-blue-400 to-blue-600 rounded-lg text-white text-2xl font-bold" + } + ], + [ + { + "type": "flex", + "properties": { + "justify": "center", + "align": "center", + "gap": 0, + "children": [ + { + "type": "text", + "content": "Photo 2" + } + ] + }, + "className": "aspect-video bg-gradient-to-br from-purple-400 to-purple-600 rounded-lg text-white text-2xl font-bold" + } + ], + [ + { + "type": "flex", + "properties": { + "justify": "center", + "align": "center", + "gap": 0, + "children": [ + { + "type": "text", + "content": "Photo 3" + } + ] + }, + "className": "aspect-video bg-gradient-to-br from-green-400 to-green-600 rounded-lg text-white text-2xl font-bold" + } + ], + [ + { + "type": "flex", + "properties": { + "justify": "center", + "align": "center", + "gap": 0, + "children": [ + { + "type": "text", + "content": "Photo 4" + } + ] + }, + "className": "aspect-video bg-gradient-to-br from-orange-400 to-orange-600 rounded-lg text-white text-2xl font-bold" + } + ] + ] +} +``` + +### Product Showcase + +**Products** + +```json +{ + "type": "carousel", + "orientation": "horizontal", + "showArrows": true, + "className": "w-full max-w-sm", + "items": [ + [ + { + "type": "card", + "className": "p-6", + "children": [ + { + "type": "text", + "content": "Product 1", + "className": "text-xl font-bold mb-2" + }, + { + "type": "text", + "content": "Amazing features and quality", + "className": "text-muted-foreground mb-4" + }, + { + "type": "text", + "content": "$99.99", + "className": "text-2xl font-bold text-green-600" + } + ] + } + ], + [ + { + "type": "card", + "className": "p-6", + "children": [ + { + "type": "text", + "content": "Product 2", + "className": "text-xl font-bold mb-2" + }, + { + "type": "text", + "content": "Premium quality guarantee", + "className": "text-muted-foreground mb-4" + }, + { + "type": "text", + "content": "$149.99", + "className": "text-2xl font-bold text-green-600" + } + ] + } + ], + [ + { + "type": "card", + "className": "p-6", + "children": [ + { + "type": "text", + "content": "Product 3", + "className": "text-xl font-bold mb-2" + }, + { + "type": "text", + "content": "Best value for money", + "className": "text-muted-foreground mb-4" + }, + { + "type": "text", + "content": "$79.99", + "className": "text-2xl font-bold text-green-600" + } + ] + } + ] + ] +} +``` + +### Testimonials + +**Customer Reviews** + +```json +{ + "type": "carousel", + "orientation": "horizontal", + "showArrows": true, + "className": "w-full max-w-2xl", + "items": [ + [ + { + "type": "box", + "className": "p-8 bg-slate-50 rounded-lg text-center", + "children": [ + { + "type": "text", + "content": "\"This product changed my life!\"", + "className": "text-xl italic mb-4 block" + }, + { + "type": "text", + "content": "— Sarah Johnson", + "className": "font-semibold" + } + ] + } + ], + [ + { + "type": "box", + "className": "p-8 bg-slate-50 rounded-lg text-center", + "children": [ + { + "type": "text", + "content": "\"Absolutely fantastic experience.\"", + "className": "text-xl italic mb-4 block" + }, + { + "type": "text", + "content": "— Mike Chen", + "className": "font-semibold" + } + ] + } + ], + [ + { + "type": "box", + "className": "p-8 bg-slate-50 rounded-lg text-center", + "children": [ + { + "type": "text", + "content": "\"Highly recommended to everyone!\"", + "className": "text-xl italic mb-4 block" + }, + { + "type": "text", + "content": "— Emma Davis", + "className": "font-semibold" + } + ] + } + ] + ] +} +``` diff --git a/content/docs/sdk/components/complex/data-table.mdx b/content/docs/sdk/components/complex/data-table.mdx new file mode 100644 index 00000000000..7e7fbe1c398 --- /dev/null +++ b/content/docs/sdk/components/complex/data-table.mdx @@ -0,0 +1,646 @@ +--- +title: "Data Table" +description: "Enterprise-grade data table with sorting, filtering, pagination, and row actions" +--- + +The Data Table component provides comprehensive table functionality inspired by Airtable, including multi-column sorting, search, pagination, row selection, and export capabilities. + +## Basic Usage + +**Simple Table** + +```json +{ + "type": "data-table", + "columns": [ + { + "header": "ID", + "accessorKey": "id", + "width": "80px" + }, + { + "header": "Name", + "accessorKey": "name" + }, + { + "header": "Email", + "accessorKey": "email" + }, + { + "header": "Status", + "accessorKey": "status" + } + ], + "data": [ + { + "id": 1, + "name": "John Doe", + "email": "john@example.com", + "status": "Active" + }, + { + "id": 2, + "name": "Jane Smith", + "email": "jane@example.com", + "status": "Active" + }, + { + "id": 3, + "name": "Bob Johnson", + "email": "bob@example.com", + "status": "Inactive" + } + ] +} +``` + +## With All Features + +**Full Featured Table** + +```json +{ + "type": "data-table", + "pagination": true, + "searchable": true, + "selectable": true, + "sortable": true, + "exportable": true, + "rowActions": true, + "pageSize": 5, + "columns": [ + { + "header": "ID", + "accessorKey": "id", + "width": "60px" + }, + { + "header": "Name", + "accessorKey": "name" + }, + { + "header": "Role", + "accessorKey": "role" + }, + { + "header": "Department", + "accessorKey": "department" + }, + { + "header": "Status", + "accessorKey": "status" + } + ], + "data": [ + { + "id": 1, + "name": "Alice Brown", + "role": "Manager", + "department": "Sales", + "status": "Active" + }, + { + "id": 2, + "name": "Bob Wilson", + "role": "Developer", + "department": "Engineering", + "status": "Active" + }, + { + "id": 3, + "name": "Carol Davis", + "role": "Designer", + "department": "Design", + "status": "Active" + }, + { + "id": 4, + "name": "David Lee", + "role": "Developer", + "department": "Engineering", + "status": "Inactive" + }, + { + "id": 5, + "name": "Eve Martinez", + "role": "Manager", + "department": "Marketing", + "status": "Active" + }, + { + "id": 6, + "name": "Frank Taylor", + "role": "Analyst", + "department": "Finance", + "status": "Active" + }, + { + "id": 7, + "name": "Grace Chen", + "role": "Designer", + "department": "Design", + "status": "Active" + }, + { + "id": 8, + "name": "Henry Kim", + "role": "Developer", + "department": "Engineering", + "status": "Inactive" + } + ] +} +``` + +## Schema + +```ts +interface TableColumn { + header: string; // Column header text + accessorKey: string; // Data property key + width?: string; // Column width (e.g., '100px', '20%') + sortable?: boolean; // Enable sorting for this column + masked?: boolean; // Withhold the raw value (see Masked columns) + chips?: boolean; // Cells draw chips: never narrower than the widest one (see Chip columns) + foreignKey?: boolean; // Raw value is a referenced record's id: the cell draws no title tooltip +} + +interface DataTableSchema { + type: 'data-table'; + + // Structure + columns: TableColumn[]; // Column definitions + data: Record[]; // Table data + caption?: string; // Table caption + + // Features + pagination?: boolean; // Enable pagination (default: true) + pageSize?: number; // Items per page (default: 10) + searchable?: boolean; // Enable search (default: true) + selectable?: boolean; // Enable row selection (default: false) + sortable?: boolean; // Enable sorting (default: true) + exportable?: boolean; // Enable CSV export (default: false) + rowActions?: boolean; // Show edit/delete buttons (default: false) + + // Advanced features + resizableColumns?: boolean; // Allow column resizing (default: true) + reorderableColumns?: boolean; // Allow column reordering (default: true) + + // Styling + className?: string; // Tailwind CSS classes on the table wrapper + cellClassName?: string; // Tailwind CSS classes on the utility cells only + // (select / row number / row actions) + + // Base properties + id?: string; + visible?: boolean | string | { dialect?: string; source: string }; + testId?: string; +} +``` + +## Cell styling + +`className` styles the table wrapper. Body cells have **two** class slots, and they +reach **disjoint** cells — neither is a superset of the other, and no cell gets both: + +- `TableColumn.cellClassName` — the **data** cells of that one column. +- `DataTableSchema.cellClassName` — the table's **utility** cells only: the leading + selection-checkbox cell (`selectable`), the row-number cell (`showRowNumbers` without + `selectable`), and the trailing row-actions cell (`rowActions`). It never reaches a + data cell. + +With `selectable` and `showRowNumbers` both on, the number and the checkbox share one +leading cell: the number by default, the checkbox on row hover, on keyboard focus, on a +touch screen and on every row while any row is selected. The header keeps its +select-all box. With row numbers off the leading cell is empty until hover. The +row-actions button shows on hover, on focus and on a selected row, and is hidden by +opacity only, so it stays reachable by keyboard. + +(The empty-state cell and the add-record row take neither.) + +Row density is therefore a **pair** of settings, not one. Per-cell padding is where +row height has to be expressed — height is a property of the cells, not of the row +element, so `rowClassName` cannot express it — so a compact table sets the same +density class on every column *and* on the schema, the second so the checkbox and +row-number cells stay the same height as the data beside them. That is exactly what +`object-grid` does for its `rowHeight` modes: + +```json +{ + "type": "data-table", + "selectable": true, + "showRowNumbers": true, + "cellClassName": "px-2 py-1 text-sm", + "columns": [ + { "header": "Name", "accessorKey": "name", "cellClassName": "px-2 py-1 text-sm" }, + { "header": "Amount", "accessorKey": "amount", "cellClassName": "px-2 py-1 text-sm text-right" } + ], + "data": [ + { "name": "Ada Lovelace", "amount": 120 }, + { "name": "Grace Hopper", "amount": 340 } + ] +} +``` + +Drop the per-column half and the data cells stay at the table primitive's default +`p-4` — the schema-level key alone does not compact a row. + +## Inline editing + +Inline editing is a **host-paired** capability of this table, not a flag a document +sets. The table only *stages* an edit; saving it is the job of callbacks that a host +supplies in code (`onRowSave`, `onCellChange`, `onBatchSave`), and a JSON document +cannot supply a function. So the keys that switch editing on — `editable`, and +`singleClickEdit` for entering edit mode on the first click instead of a +double-click — are set by the host on the `data-table` node it builds, together with +that save path. `object-grid` is that host: it sets both keys on its table and +passes its own save path, which writes through the data source. + +**Do not author `editable` or `singleClickEdit` on a `data-table` node.** Without a +host save path the edits are staged in the table and nothing persists them. To edit +records inline from a document, author an `object-grid` instead — see +[Inline Editing](/docs/sdk/plugins/plugin-grid#inline-editing) in the grid plugin. + +When a host has switched editing on, a cell enters edit mode on double-click (or on +a single click) and the table renders one of its built-in editors — text, number, +date — chosen from the column's `type`. + +"Save All" hands every pending row to `onBatchSave` at once, and reads the outcome +per row. A batch is several writes, and some can land while others are refused. A +host that knows which rows did not persist resolves with `{ failed }`, naming each +by the `rowIndex` it was handed: the table keeps and flags exactly those rows, and +clears the rest. Resolving with nothing means every row persisted. A rejection +says nothing about which rows landed, so the table keeps and flags every row. + +A pending edit belongs to its **record**, not to the position its row had: the +table keeps it under the row's `id` (or, for a row with no `id`, under the row +object itself). When the host hands the table a new read of its rows, in any +order, every edit is drawn, saved and flagged on its own record. An edit whose +record the new rows no longer include (a re-read dropped it, or it sits on +another server page) stays pending and is counted in the toolbar. "Save All" +hands it over with the row it was staged against and a negative `rowIndex` +(`-1`, `-2`, …, unique within the batch), since no position in the rows the +table holds is that record's. Every other entry's `rowIndex` is the row's index +when "Save All" is pressed. + +Cells stay editable while a row save or "Save All" is in flight. When the save +settles, it clears only what it sent: each cell whose pending value is still the +value it sent. An edit made during the save stays pending and drawn, whether it is +on another row, on another cell of the row being saved, or a new value for a cell +being saved (even the value the row loaded with, since the save writes the old +edit). The settle closes only the editor that was open when the save was pressed. + +The host can also supply `renderCellEditor`, a function the table calls first for +every cell it is about to edit; it returns a node to use, or `null` to fall through +to the built-in editor for that column. This is how `object-grid` gives a `select` +or `lookup` cell the same dedicated control the form uses, without the component +layer having to re-implement it. Its context is: + +```plaintext +renderCellEditor?: (ctx: { + column: any; + row: any; // the persisted record + pendingRow: any; // row + this row's staged, unsaved edits (#7188) + value: any; + stage: (v: any) => void; + commit: (v?: any) => void; + cancel: () => void; +}) => ReactNode; +``` + +The returned node is wrapped by the table so it inherits the exit-edit +affordances the built-in editors have: Enter commits from a single-line input, +Escape cancels, and a click outside commits. Use `stage` to record a value while +staying in edit mode, `commit` to save, and `cancel` to discard. + +The context carries the row twice, on purpose: `row` is the persisted record, and +`pendingRow` is that record with the row's staged, unsaved edits merged over it +(the same object as `row` when nothing is staged). A widget that scopes itself by +a sibling field — a `dependsOn` lookup — should read `pendingRow`, so a parent +edited in the same row re-scopes the child before anything is saved; `row` stays +the place to read what the data source last returned. + +## Keyboard navigation + +`keyboardNavigation` is set by the host in code too, beside `editable`, and is off +unless a host turns it on. `object-grid` is that host: it resolves its own +`keyboardNavigation` key (on by default when the grid renders editable) and sets +the answer on its table — see +[Keyboard navigation](/docs/sdk/plugins/plugin-grid#keyboard-navigation) in the grid +plugin (objectui#11068). + +On, the table follows the WAI-ARIA grid pattern: it is exposed as a `grid`, its +data cells take one place in the Tab order instead of one each (the cell that last +held focus), the arrow keys move focus one cell, Home / End go to the ends of the +row and Ctrl+Home / Ctrl+End to the ends of the page, and an edit ended with Enter +or Escape hands focus back to its cell. A widget a cell renders keeps its own Tab +stop. Off, every data cell is its own Tab stop, as before. + +## Chip columns + +A column with `chips: true` draws chips in its cells (a select or status field's +badges). Its minimum width is its widest drawn chip among the rows on screen, +measured once each time the data, the page or the page size changes, so a +single-word value such as "Published" is never cut to an ellipsis. The floor +raises a declared `width` and a user's resize alike; a wider `width` is kept. +Text columns have no floor: they keep their width and truncate first. +`ObjectGrid` sets the flag on select-typed columns. + +## Wide tables + +A table wider than its box shows a shadow at the right edge while columns remain +to the right, and none once it is scrolled to the end. With a right-pinned column +(the grid's Actions column) the shadow sits at that column's left edge, where the +content slides under it. The scrollbar shows while the pointer is over the table +or focus is inside it. The same affordance is exported for a page's own table as +`HorizontalOverflowBox`: + +```tsx +import { HorizontalOverflowBox, Table } from '@object-ui/components'; + + …
+
+``` + +## Masked columns + +A column with `masked: true` holds a credential. The table withholds its raw value on +every path of its own that reads it: + +- Ctrl+C / Cmd+C on one of its cells writes nothing to the clipboard. +- The cell carries no `title` tooltip. +- The table's CSV export leaves the column out. +- The column never enters inline edit, on any trigger, because every editor would + start from the raw value. +- The client-side search leaves the column out, so a term only its raw value contains + matches no row. +- Its sort is disabled: the header does not sort and the header menu offers no sort, + with client and manual sorting alike, and a client sort already set on the column + stops ordering the rows. An order by the raw value would show how it compares with + every other row's. +- Its automatic width is sized from its header, never from its values, so it does not + grow with the credential. + +Every other column copies, shows, exports, edits, searches, sorts and sizes exactly as +before. With `manualSearch` the table searches nothing itself: what the host's search +matches is up to the host. + +What the flag does **not** cover: the CSV export of a lookup column whose value is an +expanded record writes that record whole, so a credential field of the related object +is not pruned. + +The flag withholds; it does not draw. What a reader sees in the cell comes from the +producer's `cell` renderer, and the table draws a column with no `cell` as its value. +You rarely set the flag by hand. Three producers set it for every column whose field +type is drawn as a mask (`password`, `secret`, or a type registered with the mask), +from `isMaskedFieldType()` in `@object-ui/fields`: `object-grid`, the related list and +`object-data-table`. Each reads both the column's `type` and the object's field type, +so a column that shows a `secret` field as `text` keeps the flag once the object's +types are known. + +The related list and `object-data-table` fail closed: while the object definition is +still loading, or after its read failed, they set the flag on every column. + +A column whose type is not known yet is **withheld** (objectui#10657, which folded +objectui#10706). While the object's field types are loading, or after their read +failed, `object-grid`, the related list and `object-data-table` draw such a column as +the mask, never as text, and set the flag on it; it stays withheld when the read +failed. In `object-grid` and `object-data-table` that is a column with no `type` of its +own. The related list draws its cells from the object's types only, so there it is +every column without a `cell` of its own. A host that already holds the object's fields +hands them to `object-grid` as its `objectFields` prop, as the list view does, and then +there is nothing to withhold. + +`object-grid` also leaves every masked or withheld field of the grid's object out of its +own client export (CSV and JSON), draws those fields through their `cell` on its mobile +card, and refuses them as grouping keys (a masked entry is ignored with a console +warning; a withheld one groups again once the types are known). What `object-grid` does +not cover: the server-streamed export (`exportDownload`) sends the masked columns as +before and relies on the server's masking, and the client JSON export writes an +expanded lookup record whole, so a credential field of the related object is not +pruned. + +## Examples + +### Product Inventory + +**Inventory Management** + +```json +{ + "type": "data-table", + "caption": "Product Inventory", + "pagination": true, + "searchable": true, + "sortable": true, + "pageSize": 5, + "columns": [ + { + "header": "SKU", + "accessorKey": "sku", + "width": "100px" + }, + { + "header": "Product", + "accessorKey": "name" + }, + { + "header": "Category", + "accessorKey": "category" + }, + { + "header": "Stock", + "accessorKey": "stock", + "width": "80px" + }, + { + "header": "Price", + "accessorKey": "price", + "width": "100px" + } + ], + "data": [ + { + "sku": "PRD-001", + "name": "Laptop Pro", + "category": "Electronics", + "stock": 45, + "price": "$1,299" + }, + { + "sku": "PRD-002", + "name": "Wireless Mouse", + "category": "Accessories", + "stock": 120, + "price": "$29" + }, + { + "sku": "PRD-003", + "name": "USB-C Cable", + "category": "Accessories", + "stock": 200, + "price": "$15" + }, + { + "sku": "PRD-004", + "name": "Monitor 27\"", + "category": "Electronics", + "stock": 30, + "price": "$399" + }, + { + "sku": "PRD-005", + "name": "Keyboard", + "category": "Accessories", + "stock": 80, + "price": "$79" + }, + { + "sku": "PRD-006", + "name": "Webcam HD", + "category": "Electronics", + "stock": 50, + "price": "$99" + } + ] +} +``` + +### User Management + +**User Table** + +```json +{ + "type": "data-table", + "pagination": true, + "searchable": true, + "selectable": true, + "rowActions": true, + "pageSize": 4, + "columns": [ + { + "header": "User", + "accessorKey": "name" + }, + { + "header": "Email", + "accessorKey": "email" + }, + { + "header": "Role", + "accessorKey": "role", + "width": "120px" + }, + { + "header": "Last Login", + "accessorKey": "lastLogin" + } + ], + "data": [ + { + "name": "John Doe", + "email": "john@company.com", + "role": "Admin", + "lastLogin": "2024-01-20" + }, + { + "name": "Jane Smith", + "email": "jane@company.com", + "role": "Editor", + "lastLogin": "2024-01-19" + }, + { + "name": "Bob Wilson", + "email": "bob@company.com", + "role": "Viewer", + "lastLogin": "2024-01-18" + }, + { + "name": "Alice Brown", + "email": "alice@company.com", + "role": "Editor", + "lastLogin": "2024-01-17" + }, + { + "name": "Charlie Davis", + "email": "charlie@company.com", + "role": "Admin", + "lastLogin": "2024-01-16" + } + ] +} +``` + +### Sales Report + +**Sales Data** + +```json +{ + "type": "data-table", + "caption": "Q1 Sales Report", + "pagination": false, + "searchable": false, + "sortable": true, + "exportable": true, + "columns": [ + { + "header": "Month", + "accessorKey": "month" + }, + { + "header": "Revenue", + "accessorKey": "revenue" + }, + { + "header": "Orders", + "accessorKey": "orders" + }, + { + "header": "Avg Order", + "accessorKey": "avgOrder" + }, + { + "header": "Growth", + "accessorKey": "growth" + } + ], + "data": [ + { + "month": "January", + "revenue": "$124,500", + "orders": "842", + "avgOrder": "$148", + "growth": "+12%" + }, + { + "month": "February", + "revenue": "$135,200", + "orders": "923", + "avgOrder": "$147", + "growth": "+8.6%" + }, + { + "month": "March", + "revenue": "$148,900", + "orders": "1,015", + "avgOrder": "$147", + "growth": "+10.1%" + } + ] +} +``` + +## Features + +The Data Table component provides: + +- **Sorting**: Click column headers to sort (ascending/descending/none) +- **Search**: Real-time search across all columns +- **Pagination**: Navigate through large datasets +- **Selection**: Select multiple rows with checkboxes +- **Export**: Download table data as CSV +- **Row Actions**: Built-in edit and delete buttons +- **Resizable Columns**: Drag column borders to resize +- **Column Reordering**: Drag columns to reorder diff --git a/content/docs/sdk/components/complex/filter-builder.mdx b/content/docs/sdk/components/complex/filter-builder.mdx new file mode 100644 index 00000000000..3ca63bf90e9 --- /dev/null +++ b/content/docs/sdk/components/complex/filter-builder.mdx @@ -0,0 +1,401 @@ +--- +title: "Filter Builder" +description: "Visual query builder for constructing complex filter conditions" +--- + +The Filter Builder component provides a visual interface for creating filter queries: a flat list of conditions combined with AND or OR, over multiple operators. + +## Basic Usage + +**Empty Filter Builder** + +```json +{ + "type": "filter-builder", + "name": "filters", + "label": "Build Filter", + "fields": [ + { + "value": "name", + "label": "Name", + "type": "text" + }, + { + "value": "email", + "label": "Email", + "type": "text" + }, + { + "value": "age", + "label": "Age", + "type": "number" + }, + { + "value": "status", + "label": "Status", + "type": "text" + } + ], + "value": { + "id": "root", + "logic": "and", + "conditions": [] + } +} +``` + +## With Initial Conditions + +**With Conditions** + +```json +{ + "type": "filter-builder", + "name": "userFilters", + "label": "User Filters", + "fields": [ + { + "value": "name", + "label": "Name", + "type": "text" + }, + { + "value": "email", + "label": "Email", + "type": "text" + }, + { + "value": "age", + "label": "Age", + "type": "number" + }, + { + "value": "department", + "label": "Department", + "type": "text" + }, + { + "value": "salary", + "label": "Salary", + "type": "number" + } + ], + "value": { + "id": "root", + "logic": "and", + "conditions": [ + { + "id": "1", + "field": "age", + "operator": "greater_than", + "value": "25" + }, + { + "id": "2", + "field": "department", + "operator": "equals", + "value": "Engineering" + } + ] + } +} +``` + +## Schema + +```ts +interface FilterField { + value: string; // Field identifier + label: string; // Display label + type?: + | 'text' | 'number' | 'currency' | 'percent' | 'rating' + | 'date' | 'datetime' | 'time' + | 'boolean' + | 'select' | 'status' + | 'lookup' | 'master_detail' | 'user'; // Field type + options?: Array<{ value: string; label: string }>; // Static options (select-like) + // Lookup-like fields without `options` render a remote-search picker that + // queries the configured DataSource. The metadata below describes how to + // resolve and display referenced records: + referenceTo?: string; // Referenced object name (e.g. "user") + displayField?: string; // Display field on referenced object (default "name") + idField?: string; // Id field on referenced object (default "id") +} + +interface FilterCondition { + id: string; // Condition identifier + field: string; // Field to filter on + operator: string; // Comparison operator + value: any; // Filter value +} + +interface FilterGroup { + id?: string; // Group identifier (optional) + logic: 'and' | 'or'; // Logic operator (AND/OR) + conditions: FilterCondition[]; // Flat condition rows (no nested groups) +} + +interface FilterBuilderSchema { + type: 'filter-builder'; + + // Required + name: string; // Field name for form submission + fields: FilterField[]; // Available fields for filtering + + // Configuration + label?: string; // Component label + value?: FilterGroup; // Initial/controlled filter value + + // Events + // RUNTIME SLOT (objectui#6124): a React host supplies it; JSON refuses it + // by name. Author behaviour as a node type, e.g. an action:button node. + onChange?: (filter: FilterGroup) => void; // Change handler + + // Styling + className?: string; // Tailwind CSS classes + wrapperClass?: string; // Wrapper container classes + + // Base properties + id?: string; + visible?: boolean | string | { dialect?: string; source: string }; + testId?: string; +} +``` + +## Retired: nested groups, `allowGroups` and `maxDepth` + +A filter group is one level deep. Every entry of `conditions` is one field / +operator / value row, and the group's `logic` combines all of them. The builder +never drew a nested group: it showed a sub-group as a row with blank field and +operator selects and did not show the sub-group's own conditions. Nested +sub-groups and the two keys that configured them, `allowGroups` and `maxDepth`, +are therefore retired (objectui#9306). Validation now refuses each of them by +name. Write the rows into the one group and remove the two keys. + +## Examples + +### Product Filters + +**Product Search** + +```json +{ + "type": "filter-builder", + "name": "productFilters", + "label": "Product Search Filters", + "fields": [ + { + "value": "name", + "label": "Product Name", + "type": "text" + }, + { + "value": "category", + "label": "Category", + "type": "text" + }, + { + "value": "price", + "label": "Price", + "type": "number" + }, + { + "value": "stock", + "label": "Stock Quantity", + "type": "number" + }, + { + "value": "rating", + "label": "Rating", + "type": "number" + } + ], + "value": { + "id": "root", + "logic": "and", + "conditions": [ + { + "id": "1", + "field": "category", + "operator": "equals", + "value": "Electronics" + }, + { + "id": "2", + "field": "price", + "operator": "less_than", + "value": "1000" + }, + { + "id": "3", + "field": "stock", + "operator": "greater_than", + "value": "0" + } + ] + } +} +``` + +### User Query Builder + +**User Filters** + +```json +{ + "type": "filter-builder", + "name": "userQuery", + "label": "Find Users", + "fields": [ + { + "value": "firstName", + "label": "First Name", + "type": "text" + }, + { + "value": "lastName", + "label": "Last Name", + "type": "text" + }, + { + "value": "email", + "label": "Email", + "type": "text" + }, + { + "value": "role", + "label": "Role", + "type": "text" + }, + { + "value": "active", + "label": "Active", + "type": "boolean" + }, + { + "value": "createdAt", + "label": "Created Date", + "type": "date" + } + ], + "value": { + "id": "root", + "logic": "and", + "conditions": [] + } +} +``` + +### Advanced Query + +**Search Interface** + +```json +{ + "type": "stack", + "gap": 4, + "className": "p-6 border rounded-lg max-w-3xl", + "children": [ + { + "type": "text", + "content": "Advanced Search", + "className": "text-xl font-bold" + }, + { + "type": "text", + "content": "Build complex queries with multiple conditions", + "className": "text-sm text-muted-foreground mb-4" + }, + { + "type": "filter-builder", + "name": "advancedFilters", + "fields": [ + { + "value": "title", + "label": "Title", + "type": "text" + }, + { + "value": "author", + "label": "Author", + "type": "text" + }, + { + "value": "publishDate", + "label": "Publish Date", + "type": "date" + }, + { + "value": "views", + "label": "Views", + "type": "number" + }, + { + "value": "likes", + "label": "Likes", + "type": "number" + }, + { + "value": "published", + "label": "Published", + "type": "boolean" + } + ], + "value": { + "id": "root", + "logic": "and", + "conditions": [ + { + "id": "1", + "field": "published", + "operator": "equals", + "value": "true" + }, + { + "id": "2", + "field": "views", + "operator": "greater_than", + "value": "1000" + } + ] + } + }, + { + "type": "flex", + "properties": { + "gap": 2, + "children": [ + { + "type": "button", + "label": "Apply Filters", + "variant": "default" + }, + { + "type": "button", + "label": "Clear", + "variant": "outline" + } + ] + }, + "className": "mt-4" + } + ] +} +``` + +## Supported Operators + +The Filter Builder supports various comparison operators based on field type: + +- **Text fields**: equals, not equals, contains, starts with, ends with +- **Number fields**: equals, not equals, greater than, less than, between +- **Date fields**: equals, before, after, between +- **Boolean fields**: is true, is false + +## Use Cases + +- **Search interfaces**: Build advanced search forms +- **Data filtering**: Filter large datasets in tables +- **Report generation**: Create custom report criteria +- **Query builders**: Visual SQL/NoSQL query construction +- **Access control**: Define permission rules diff --git a/content/docs/sdk/components/complex/filter-ui.mdx b/content/docs/sdk/components/complex/filter-ui.mdx new file mode 100644 index 00000000000..ae5bc296b9e --- /dev/null +++ b/content/docs/sdk/components/complex/filter-ui.mdx @@ -0,0 +1,103 @@ +--- +title: "Filter UI" +description: "Build flexible filter panels for list and view toolbars" +--- + +The Filter UI component renders configurable filters with text, select, date, and boolean inputs. + +## JSON Configuration + +```plaintext +{ + type: 'filter-ui', + layout: 'popover', + showApply: true, + showClear: true, + filters: [ + { field: 'name', label: 'Name', type: 'text', placeholder: 'Search name' }, + { field: 'status', label: 'Status', type: 'select', options: [ + { label: 'Open', value: 'open' }, + { label: 'Closed', value: 'closed' } + ] }, + { field: 'created_at', label: 'Created', type: 'date-range' } + ] +} +``` + +## Basic Usage + +**Filter Ui** + +```json +{ + "type": "filter-ui", + "layout": "popover", + "showApply": true, + "showClear": true, + "filters": [ + { + "field": "name", + "label": "Name", + "type": "text", + "placeholder": "Search name" + }, + { + "field": "status", + "label": "Status", + "type": "select", + "options": [ + { + "label": "Open", + "value": "open" + }, + { + "label": "Closed", + "value": "closed" + }, + { + "label": "In Progress", + "value": "in_progress" + } + ] + }, + { + "field": "priority", + "label": "Priority", + "type": "number" + }, + { + "field": "created_at", + "label": "Created", + "type": "date-range" + } + ] +} +``` + +## Schema + +```ts +interface FilterUISchema { + type: 'filter-ui'; + filters: Array<{ + field: string; + label?: string; + type: 'text' | 'number' | 'select' | 'multi-select' | 'date' | 'date-range' | 'boolean'; + options?: Array<{ label: string; value: any }>; + placeholder?: string; + }>; + values?: Record; + onChange?: string; + showClear?: boolean; + showApply?: boolean; + layout?: 'inline' | 'popover' | 'drawer'; +} +``` + +### Retired: `filters[].operator` + +A filter entry no longer takes an `operator` (objectui#10611). The component never +read one: it picks each control from `type` and reports changes as `{ values }`, a +field-to-value record with no operator in it, and it does no matching of its own. How +each value is matched is up to the host that consumes the change. An authored +`operator` is now refused by name when the schema is validated. diff --git a/content/docs/sdk/components/complex/meta.json b/content/docs/sdk/components/complex/meta.json new file mode 100644 index 00000000000..575a7017d54 --- /dev/null +++ b/content/docs/sdk/components/complex/meta.json @@ -0,0 +1,13 @@ +{ + "title": "Complex", + "pages": [ + "data-table", + "carousel", + "scroll-area", + "resizable", + "filter-builder", + "view-switcher", + "filter-ui", + "sort-ui" + ] +} diff --git a/content/docs/sdk/components/complex/resizable.mdx b/content/docs/sdk/components/complex/resizable.mdx new file mode 100644 index 00000000000..a7b7e8b4e25 --- /dev/null +++ b/content/docs/sdk/components/complex/resizable.mdx @@ -0,0 +1,465 @@ +--- +title: "Resizable" +description: "Resizable panel groups with draggable handles" +--- + +The Resizable component creates adjustable panel layouts with draggable handles, perfect for split views and customizable interfaces. + +## Basic Usage + +**Horizontal Split** + +```json +{ + "type": "resizable", + "direction": "horizontal", + "minHeight": "200px", + "className": "rounded-lg border", + "panels": [ + { + "defaultSize": 50, + "content": [ + { + "type": "flex", + "properties": { + "justify": "center", + "align": "center", + "gap": 0, + "children": [ + { + "type": "text", + "content": "Panel 1" + } + ] + }, + "className": "p-4 h-full bg-slate-50" + } + ] + }, + { + "defaultSize": 50, + "content": [ + { + "type": "flex", + "properties": { + "justify": "center", + "align": "center", + "gap": 0, + "children": [ + { + "type": "text", + "content": "Panel 2" + } + ] + }, + "className": "p-4 h-full bg-slate-100" + } + ] + } + ] +} +``` + +## Vertical Layout + +**Vertical Split** + +```json +{ + "type": "resizable", + "direction": "vertical", + "minHeight": "400px", + "className": "rounded-lg border", + "panels": [ + { + "defaultSize": 60, + "content": [ + { + "type": "box", + "className": "p-4 h-full bg-blue-50", + "children": [ + { + "type": "text", + "content": "Top Panel", + "className": "font-semibold" + } + ] + } + ] + }, + { + "defaultSize": 40, + "content": [ + { + "type": "box", + "className": "p-4 h-full bg-green-50", + "children": [ + { + "type": "text", + "content": "Bottom Panel", + "className": "font-semibold" + } + ] + } + ] + } + ] +} +``` + +## Three Panels + +**Triple Split** + +```json +{ + "type": "resizable", + "direction": "horizontal", + "minHeight": "250px", + "withHandle": true, + "className": "rounded-lg border", + "panels": [ + { + "defaultSize": 25, + "minSize": 15, + "content": [ + { + "type": "box", + "className": "p-4 h-full bg-purple-50", + "children": [ + { + "type": "text", + "content": "Sidebar" + } + ] + } + ] + }, + { + "defaultSize": 50, + "content": [ + { + "type": "box", + "className": "p-4 h-full bg-slate-50", + "children": [ + { + "type": "text", + "content": "Main Content" + } + ] + } + ] + }, + { + "defaultSize": 25, + "minSize": 15, + "content": [ + { + "type": "box", + "className": "p-4 h-full bg-amber-50", + "children": [ + { + "type": "text", + "content": "Inspector" + } + ] + } + ] + } + ] +} +``` + +## Schema + +```ts +import type { SchemaNode } from '@object-ui/types'; + +interface ResizablePanel { + defaultSize: number; // Initial size percentage + minSize?: number; // Minimum size percentage + maxSize?: number; // Maximum size percentage + content: SchemaNode | SchemaNode[]; // Panel content +} + +interface ResizableSchema { + type: 'resizable'; + + // Configuration + direction?: 'horizontal' | 'vertical'; // Panel direction (default: 'horizontal') + minHeight?: string; // Minimum container height + withHandle?: boolean; // Show handle icons (default: true) + + // Panels + panels: ResizablePanel[]; // Array of panel configurations + + // Styling + className?: string; // Tailwind CSS classes + + // Base properties + id?: string; + visible?: boolean | string | { dialect?: string; source: string }; + testId?: string; +} +``` + +## Examples + +### Code Editor Layout + +**Editor Interface** + +```json +{ + "type": "resizable", + "direction": "horizontal", + "minHeight": "400px", + "className": "rounded-lg border", + "panels": [ + { + "defaultSize": 30, + "minSize": 20, + "content": [ + { + "type": "box", + "className": "p-4 h-full bg-slate-900 text-slate-50", + "children": [ + { + "type": "text", + "content": "File Explorer", + "className": "font-semibold mb-4 block" + }, + { + "type": "tree-view", + "className": "bg-slate-800 text-slate-100", + "nodes": [ + { + "id": "1", + "label": "src", + "icon": "folder", + "children": [ + { + "id": "2", + "label": "components", + "icon": "folder" + }, + { + "id": "3", + "label": "index.ts", + "icon": "file" + } + ] + }, + { + "id": "4", + "label": "README.md", + "icon": "file" + } + ] + } + ] + } + ] + }, + { + "defaultSize": 70, + "content": [ + { + "type": "box", + "className": "p-4 h-full bg-slate-950 text-slate-50 font-mono text-sm", + "children": [ + { + "type": "text", + "content": "// Code editor\nfunction hello() {\n console.log(\"Hello World\");\n}", + "className": "whitespace-pre" + } + ] + } + ] + } + ] +} +``` + +### Dashboard Layout + +**Complex Layout** + +```json +{ + "type": "resizable", + "direction": "vertical", + "minHeight": "500px", + "className": "rounded-lg border", + "panels": [ + { + "defaultSize": 70, + "content": [ + { + "type": "resizable", + "direction": "horizontal", + "className": "h-full", + "panels": [ + { + "defaultSize": 65, + "content": [ + { + "type": "box", + "className": "p-6 h-full", + "children": [ + { + "type": "text", + "content": "Main Dashboard", + "className": "text-xl font-bold mb-4" + }, + { + "type": "grid", + "columns": 2, + "gap": 4, + "children": [ + { + "type": "statistic", + "label": "Revenue", + "value": "$45,231" + }, + { + "type": "statistic", + "label": "Users", + "value": "12,543" + } + ] + } + ] + } + ] + }, + { + "defaultSize": 35, + "minSize": 25, + "content": [ + { + "type": "box", + "className": "p-6 h-full bg-slate-50", + "children": [ + { + "type": "text", + "content": "Activity Feed", + "className": "font-semibold" + } + ] + } + ] + } + ] + } + ] + }, + { + "defaultSize": 30, + "minSize": 20, + "content": [ + { + "type": "box", + "className": "p-6 h-full bg-slate-100", + "children": [ + { + "type": "text", + "content": "Details Panel", + "className": "font-semibold" + } + ] + } + ] + } + ] +} +``` + +### Mail Client + +**Mail Layout** + +```json +{ + "type": "resizable", + "direction": "horizontal", + "minHeight": "350px", + "className": "rounded-lg border", + "panels": [ + { + "defaultSize": 20, + "minSize": 15, + "content": [ + { + "type": "box", + "className": "p-4 h-full border-r", + "children": [ + { + "type": "text", + "content": "Folders", + "className": "font-semibold mb-3 block" + }, + { + "type": "text", + "content": "📧 Inbox", + "className": "block py-2 cursor-pointer hover:bg-slate-50 px-2 rounded" + }, + { + "type": "text", + "content": "📤 Sent", + "className": "block py-2 cursor-pointer hover:bg-slate-50 px-2 rounded" + }, + { + "type": "text", + "content": "🗑️ Trash", + "className": "block py-2 cursor-pointer hover:bg-slate-50 px-2 rounded" + } + ] + } + ] + }, + { + "defaultSize": 40, + "content": [ + { + "type": "box", + "className": "p-4 h-full border-r", + "children": [ + { + "type": "text", + "content": "Messages", + "className": "font-semibold mb-3 block" + }, + { + "type": "text", + "content": "Message list...", + "className": "text-sm text-muted-foreground" + } + ] + } + ] + }, + { + "defaultSize": 40, + "content": [ + { + "type": "box", + "className": "p-4 h-full", + "children": [ + { + "type": "text", + "content": "Message Content", + "className": "font-semibold mb-3 block" + }, + { + "type": "text", + "content": "Email body appears here...", + "className": "text-sm text-muted-foreground" + } + ] + } + ] + } + ] +} +``` diff --git a/content/docs/sdk/components/complex/scroll-area.mdx b/content/docs/sdk/components/complex/scroll-area.mdx new file mode 100644 index 00000000000..9ac18f9b102 --- /dev/null +++ b/content/docs/sdk/components/complex/scroll-area.mdx @@ -0,0 +1,1817 @@ +--- +title: "Scroll Area" +description: "Customizable scrollable container with styled scrollbars" +--- + +The Scroll Area component provides a scrollable container with styled scrollbars that match your design system. + +## Basic Usage + +**Vertical Scroll** + +```json +{ + "type": "scroll-area", + "height": "200px", + "className": "rounded-md border p-4", + "children": [ + { + "type": "box", + "children": [ + { + "type": "text", + "content": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + } + ] + } + ] +} +``` + +## Horizontal Scroll + +**Horizontal Scroll** + +```json +{ + "type": "scroll-area", + "width": "100%", + "height": "150px", + "orientation": "horizontal", + "className": "rounded-md border", + "children": [ + { + "type": "flex", + "properties": { + "gap": 4, + "children": [ + { + "type": "flex", + "properties": { + "justify": "center", + "align": "center", + "gap": 0, + "children": [ + { + "type": "text", + "content": "Item 1" + } + ] + }, + "className": "w-40 h-32 bg-slate-200 rounded flex-shrink-0" + }, + { + "type": "flex", + "properties": { + "justify": "center", + "align": "center", + "gap": 0, + "children": [ + { + "type": "text", + "content": "Item 2" + } + ] + }, + "className": "w-40 h-32 bg-slate-200 rounded flex-shrink-0" + }, + { + "type": "flex", + "properties": { + "justify": "center", + "align": "center", + "gap": 0, + "children": [ + { + "type": "text", + "content": "Item 3" + } + ] + }, + "className": "w-40 h-32 bg-slate-200 rounded flex-shrink-0" + }, + { + "type": "flex", + "properties": { + "justify": "center", + "align": "center", + "gap": 0, + "children": [ + { + "type": "text", + "content": "Item 4" + } + ] + }, + "className": "w-40 h-32 bg-slate-200 rounded flex-shrink-0" + }, + { + "type": "flex", + "properties": { + "justify": "center", + "align": "center", + "gap": 0, + "children": [ + { + "type": "text", + "content": "Item 5" + } + ] + }, + "className": "w-40 h-32 bg-slate-200 rounded flex-shrink-0" + }, + { + "type": "flex", + "properties": { + "justify": "center", + "align": "center", + "gap": 0, + "children": [ + { + "type": "text", + "content": "Item 6" + } + ] + }, + "className": "w-40 h-32 bg-slate-200 rounded flex-shrink-0" + }, + { + "type": "flex", + "properties": { + "justify": "center", + "align": "center", + "gap": 0, + "children": [ + { + "type": "text", + "content": "Item 7" + } + ] + }, + "className": "w-40 h-32 bg-slate-200 rounded flex-shrink-0" + }, + { + "type": "flex", + "properties": { + "justify": "center", + "align": "center", + "gap": 0, + "children": [ + { + "type": "text", + "content": "Item 8" + } + ] + }, + "className": "w-40 h-32 bg-slate-200 rounded flex-shrink-0" + }, + { + "type": "flex", + "properties": { + "justify": "center", + "align": "center", + "gap": 0, + "children": [ + { + "type": "text", + "content": "Item 9" + } + ] + }, + "className": "w-40 h-32 bg-slate-200 rounded flex-shrink-0" + }, + { + "type": "flex", + "properties": { + "justify": "center", + "align": "center", + "gap": 0, + "children": [ + { + "type": "text", + "content": "Item 10" + } + ] + }, + "className": "w-40 h-32 bg-slate-200 rounded flex-shrink-0" + } + ] + }, + "className": "p-4" + } + ] +} +``` + +## Custom Heights + +**Short 150px** + +```json +{ + "type": "scroll-area", + "height": "150px", + "className": "rounded-md border p-4", + "children": [ + { + "type": "stack", + "gap": 2, + "children": [ + { + "type": "text", + "content": "Line 1", + "className": "block" + }, + { + "type": "text", + "content": "Line 2", + "className": "block" + }, + { + "type": "text", + "content": "Line 3", + "className": "block" + }, + { + "type": "text", + "content": "Line 4", + "className": "block" + }, + { + "type": "text", + "content": "Line 5", + "className": "block" + }, + { + "type": "text", + "content": "Line 6", + "className": "block" + }, + { + "type": "text", + "content": "Line 7", + "className": "block" + }, + { + "type": "text", + "content": "Line 8", + "className": "block" + }, + { + "type": "text", + "content": "Line 9", + "className": "block" + }, + { + "type": "text", + "content": "Line 10", + "className": "block" + }, + { + "type": "text", + "content": "Line 11", + "className": "block" + }, + { + "type": "text", + "content": "Line 12", + "className": "block" + }, + { + "type": "text", + "content": "Line 13", + "className": "block" + }, + { + "type": "text", + "content": "Line 14", + "className": "block" + }, + { + "type": "text", + "content": "Line 15", + "className": "block" + }, + { + "type": "text", + "content": "Line 16", + "className": "block" + }, + { + "type": "text", + "content": "Line 17", + "className": "block" + }, + { + "type": "text", + "content": "Line 18", + "className": "block" + }, + { + "type": "text", + "content": "Line 19", + "className": "block" + }, + { + "type": "text", + "content": "Line 20", + "className": "block" + } + ] + } + ] +} +``` + +**Tall 300px** + +```json +{ + "type": "scroll-area", + "height": "300px", + "className": "rounded-md border p-4", + "children": [ + { + "type": "stack", + "gap": 2, + "children": [ + { + "type": "text", + "content": "Line 1", + "className": "block" + }, + { + "type": "text", + "content": "Line 2", + "className": "block" + }, + { + "type": "text", + "content": "Line 3", + "className": "block" + }, + { + "type": "text", + "content": "Line 4", + "className": "block" + }, + { + "type": "text", + "content": "Line 5", + "className": "block" + }, + { + "type": "text", + "content": "Line 6", + "className": "block" + }, + { + "type": "text", + "content": "Line 7", + "className": "block" + }, + { + "type": "text", + "content": "Line 8", + "className": "block" + }, + { + "type": "text", + "content": "Line 9", + "className": "block" + }, + { + "type": "text", + "content": "Line 10", + "className": "block" + }, + { + "type": "text", + "content": "Line 11", + "className": "block" + }, + { + "type": "text", + "content": "Line 12", + "className": "block" + }, + { + "type": "text", + "content": "Line 13", + "className": "block" + }, + { + "type": "text", + "content": "Line 14", + "className": "block" + }, + { + "type": "text", + "content": "Line 15", + "className": "block" + }, + { + "type": "text", + "content": "Line 16", + "className": "block" + }, + { + "type": "text", + "content": "Line 17", + "className": "block" + }, + { + "type": "text", + "content": "Line 18", + "className": "block" + }, + { + "type": "text", + "content": "Line 19", + "className": "block" + }, + { + "type": "text", + "content": "Line 20", + "className": "block" + }, + { + "type": "text", + "content": "Line 21", + "className": "block" + }, + { + "type": "text", + "content": "Line 22", + "className": "block" + }, + { + "type": "text", + "content": "Line 23", + "className": "block" + }, + { + "type": "text", + "content": "Line 24", + "className": "block" + }, + { + "type": "text", + "content": "Line 25", + "className": "block" + }, + { + "type": "text", + "content": "Line 26", + "className": "block" + }, + { + "type": "text", + "content": "Line 27", + "className": "block" + }, + { + "type": "text", + "content": "Line 28", + "className": "block" + }, + { + "type": "text", + "content": "Line 29", + "className": "block" + }, + { + "type": "text", + "content": "Line 30", + "className": "block" + } + ] + } + ] +} +``` + +## Schema + +```ts +import type { SchemaNode } from '@object-ui/types'; + +interface ScrollAreaSchema { + type: 'scroll-area'; + + // Dimensions + height?: string | number; // Container height ('200px', '50vh', or 200 = px) + width?: string | number; // Container width ('100%', '50vw', or 300 = px) + + // Configuration + orientation?: 'vertical' | 'horizontal' | 'both'; // Scroll direction (default: 'vertical') + + // Content + children?: SchemaNode | SchemaNode[]; // Scrollable content (the only content channel) + + // Styling + className?: string; // Tailwind CSS classes + + // Base properties + id?: string; + visible?: boolean | string | { dialect?: string; source: string }; + testId?: string; +} +``` + +The scrollable body is authored under `children`, and only under `children`: +the renderer reads `renderChildren(schema.children)` and nothing else, and the +registration's `defaultProps` ships `children` too. A scroll area that puts its +body under `content` is not refused anywhere — `content` is not a member of +`ScrollAreaSchema`, but the zod twin extends a `.passthrough()` base, so it +validates, survives parsing, and reaches the DOM as a stray attribute on an +otherwise empty scroll box. + +`height` and `width` take a number as well as a CSS length; a bare number is +applied as pixels (`height: 200` renders `height: 200px`). Neither has a +render-time default — omitting one leaves the container unsized in that axis. + +## Examples + +### Message List + +**Chat Messages** + +```json +{ + "type": "scroll-area", + "height": "300px", + "className": "rounded-md border", + "children": [ + { + "type": "stack", + "gap": 3, + "className": "p-4", + "children": [ + { + "type": "flex", + "properties": { + "align": "stretch", + "gap": 3, + "children": [ + { + "type": "flex", + "properties": { + "justify": "center", + "align": "center", + "gap": 0, + "children": [ + { + "type": "text", + "content": "A" + } + ] + }, + "className": "w-10 h-10 rounded-full bg-blue-500 text-white font-semibold flex-shrink-0" + }, + { + "type": "box", + "className": "flex-1", + "children": [ + { + "type": "text", + "content": "User 1", + "className": "font-semibold block" + }, + { + "type": "text", + "content": "This is a sample message...", + "className": "text-sm text-muted-foreground block" + } + ] + } + ] + }, + "className": "p-3 rounded-lg bg-slate-50 hover:bg-slate-100" + }, + { + "type": "flex", + "properties": { + "align": "stretch", + "gap": 3, + "children": [ + { + "type": "flex", + "properties": { + "justify": "center", + "align": "center", + "gap": 0, + "children": [ + { + "type": "text", + "content": "B" + } + ] + }, + "className": "w-10 h-10 rounded-full bg-blue-500 text-white font-semibold flex-shrink-0" + }, + { + "type": "box", + "className": "flex-1", + "children": [ + { + "type": "text", + "content": "User 2", + "className": "font-semibold block" + }, + { + "type": "text", + "content": "This is a sample message...", + "className": "text-sm text-muted-foreground block" + } + ] + } + ] + }, + "className": "p-3 rounded-lg bg-slate-50 hover:bg-slate-100" + }, + { + "type": "flex", + "properties": { + "align": "stretch", + "gap": 3, + "children": [ + { + "type": "flex", + "properties": { + "justify": "center", + "align": "center", + "gap": 0, + "children": [ + { + "type": "text", + "content": "C" + } + ] + }, + "className": "w-10 h-10 rounded-full bg-blue-500 text-white font-semibold flex-shrink-0" + }, + { + "type": "box", + "className": "flex-1", + "children": [ + { + "type": "text", + "content": "User 3", + "className": "font-semibold block" + }, + { + "type": "text", + "content": "This is a sample message...", + "className": "text-sm text-muted-foreground block" + } + ] + } + ] + }, + "className": "p-3 rounded-lg bg-slate-50 hover:bg-slate-100" + }, + { + "type": "flex", + "properties": { + "align": "stretch", + "gap": 3, + "children": [ + { + "type": "flex", + "properties": { + "justify": "center", + "align": "center", + "gap": 0, + "children": [ + { + "type": "text", + "content": "D" + } + ] + }, + "className": "w-10 h-10 rounded-full bg-blue-500 text-white font-semibold flex-shrink-0" + }, + { + "type": "box", + "className": "flex-1", + "children": [ + { + "type": "text", + "content": "User 4", + "className": "font-semibold block" + }, + { + "type": "text", + "content": "This is a sample message...", + "className": "text-sm text-muted-foreground block" + } + ] + } + ] + }, + "className": "p-3 rounded-lg bg-slate-50 hover:bg-slate-100" + }, + { + "type": "flex", + "properties": { + "align": "stretch", + "gap": 3, + "children": [ + { + "type": "flex", + "properties": { + "justify": "center", + "align": "center", + "gap": 0, + "children": [ + { + "type": "text", + "content": "E" + } + ] + }, + "className": "w-10 h-10 rounded-full bg-blue-500 text-white font-semibold flex-shrink-0" + }, + { + "type": "box", + "className": "flex-1", + "children": [ + { + "type": "text", + "content": "User 5", + "className": "font-semibold block" + }, + { + "type": "text", + "content": "This is a sample message...", + "className": "text-sm text-muted-foreground block" + } + ] + } + ] + }, + "className": "p-3 rounded-lg bg-slate-50 hover:bg-slate-100" + }, + { + "type": "flex", + "properties": { + "align": "stretch", + "gap": 3, + "children": [ + { + "type": "flex", + "properties": { + "justify": "center", + "align": "center", + "gap": 0, + "children": [ + { + "type": "text", + "content": "F" + } + ] + }, + "className": "w-10 h-10 rounded-full bg-blue-500 text-white font-semibold flex-shrink-0" + }, + { + "type": "box", + "className": "flex-1", + "children": [ + { + "type": "text", + "content": "User 6", + "className": "font-semibold block" + }, + { + "type": "text", + "content": "This is a sample message...", + "className": "text-sm text-muted-foreground block" + } + ] + } + ] + }, + "className": "p-3 rounded-lg bg-slate-50 hover:bg-slate-100" + }, + { + "type": "flex", + "properties": { + "align": "stretch", + "gap": 3, + "children": [ + { + "type": "flex", + "properties": { + "justify": "center", + "align": "center", + "gap": 0, + "children": [ + { + "type": "text", + "content": "G" + } + ] + }, + "className": "w-10 h-10 rounded-full bg-blue-500 text-white font-semibold flex-shrink-0" + }, + { + "type": "box", + "className": "flex-1", + "children": [ + { + "type": "text", + "content": "User 7", + "className": "font-semibold block" + }, + { + "type": "text", + "content": "This is a sample message...", + "className": "text-sm text-muted-foreground block" + } + ] + } + ] + }, + "className": "p-3 rounded-lg bg-slate-50 hover:bg-slate-100" + }, + { + "type": "flex", + "properties": { + "align": "stretch", + "gap": 3, + "children": [ + { + "type": "flex", + "properties": { + "justify": "center", + "align": "center", + "gap": 0, + "children": [ + { + "type": "text", + "content": "H" + } + ] + }, + "className": "w-10 h-10 rounded-full bg-blue-500 text-white font-semibold flex-shrink-0" + }, + { + "type": "box", + "className": "flex-1", + "children": [ + { + "type": "text", + "content": "User 8", + "className": "font-semibold block" + }, + { + "type": "text", + "content": "This is a sample message...", + "className": "text-sm text-muted-foreground block" + } + ] + } + ] + }, + "className": "p-3 rounded-lg bg-slate-50 hover:bg-slate-100" + }, + { + "type": "flex", + "properties": { + "align": "stretch", + "gap": 3, + "children": [ + { + "type": "flex", + "properties": { + "justify": "center", + "align": "center", + "gap": 0, + "children": [ + { + "type": "text", + "content": "I" + } + ] + }, + "className": "w-10 h-10 rounded-full bg-blue-500 text-white font-semibold flex-shrink-0" + }, + { + "type": "box", + "className": "flex-1", + "children": [ + { + "type": "text", + "content": "User 9", + "className": "font-semibold block" + }, + { + "type": "text", + "content": "This is a sample message...", + "className": "text-sm text-muted-foreground block" + } + ] + } + ] + }, + "className": "p-3 rounded-lg bg-slate-50 hover:bg-slate-100" + }, + { + "type": "flex", + "properties": { + "align": "stretch", + "gap": 3, + "children": [ + { + "type": "flex", + "properties": { + "justify": "center", + "align": "center", + "gap": 0, + "children": [ + { + "type": "text", + "content": "J" + } + ] + }, + "className": "w-10 h-10 rounded-full bg-blue-500 text-white font-semibold flex-shrink-0" + }, + { + "type": "box", + "className": "flex-1", + "children": [ + { + "type": "text", + "content": "User 10", + "className": "font-semibold block" + }, + { + "type": "text", + "content": "This is a sample message...", + "className": "text-sm text-muted-foreground block" + } + ] + } + ] + }, + "className": "p-3 rounded-lg bg-slate-50 hover:bg-slate-100" + }, + { + "type": "flex", + "properties": { + "align": "stretch", + "gap": 3, + "children": [ + { + "type": "flex", + "properties": { + "justify": "center", + "align": "center", + "gap": 0, + "children": [ + { + "type": "text", + "content": "K" + } + ] + }, + "className": "w-10 h-10 rounded-full bg-blue-500 text-white font-semibold flex-shrink-0" + }, + { + "type": "box", + "className": "flex-1", + "children": [ + { + "type": "text", + "content": "User 11", + "className": "font-semibold block" + }, + { + "type": "text", + "content": "This is a sample message...", + "className": "text-sm text-muted-foreground block" + } + ] + } + ] + }, + "className": "p-3 rounded-lg bg-slate-50 hover:bg-slate-100" + }, + { + "type": "flex", + "properties": { + "align": "stretch", + "gap": 3, + "children": [ + { + "type": "flex", + "properties": { + "justify": "center", + "align": "center", + "gap": 0, + "children": [ + { + "type": "text", + "content": "L" + } + ] + }, + "className": "w-10 h-10 rounded-full bg-blue-500 text-white font-semibold flex-shrink-0" + }, + { + "type": "box", + "className": "flex-1", + "children": [ + { + "type": "text", + "content": "User 12", + "className": "font-semibold block" + }, + { + "type": "text", + "content": "This is a sample message...", + "className": "text-sm text-muted-foreground block" + } + ] + } + ] + }, + "className": "p-3 rounded-lg bg-slate-50 hover:bg-slate-100" + }, + { + "type": "flex", + "properties": { + "align": "stretch", + "gap": 3, + "children": [ + { + "type": "flex", + "properties": { + "justify": "center", + "align": "center", + "gap": 0, + "children": [ + { + "type": "text", + "content": "M" + } + ] + }, + "className": "w-10 h-10 rounded-full bg-blue-500 text-white font-semibold flex-shrink-0" + }, + { + "type": "box", + "className": "flex-1", + "children": [ + { + "type": "text", + "content": "User 13", + "className": "font-semibold block" + }, + { + "type": "text", + "content": "This is a sample message...", + "className": "text-sm text-muted-foreground block" + } + ] + } + ] + }, + "className": "p-3 rounded-lg bg-slate-50 hover:bg-slate-100" + }, + { + "type": "flex", + "properties": { + "align": "stretch", + "gap": 3, + "children": [ + { + "type": "flex", + "properties": { + "justify": "center", + "align": "center", + "gap": 0, + "children": [ + { + "type": "text", + "content": "N" + } + ] + }, + "className": "w-10 h-10 rounded-full bg-blue-500 text-white font-semibold flex-shrink-0" + }, + { + "type": "box", + "className": "flex-1", + "children": [ + { + "type": "text", + "content": "User 14", + "className": "font-semibold block" + }, + { + "type": "text", + "content": "This is a sample message...", + "className": "text-sm text-muted-foreground block" + } + ] + } + ] + }, + "className": "p-3 rounded-lg bg-slate-50 hover:bg-slate-100" + }, + { + "type": "flex", + "properties": { + "align": "stretch", + "gap": 3, + "children": [ + { + "type": "flex", + "properties": { + "justify": "center", + "align": "center", + "gap": 0, + "children": [ + { + "type": "text", + "content": "O" + } + ] + }, + "className": "w-10 h-10 rounded-full bg-blue-500 text-white font-semibold flex-shrink-0" + }, + { + "type": "box", + "className": "flex-1", + "children": [ + { + "type": "text", + "content": "User 15", + "className": "font-semibold block" + }, + { + "type": "text", + "content": "This is a sample message...", + "className": "text-sm text-muted-foreground block" + } + ] + } + ] + }, + "className": "p-3 rounded-lg bg-slate-50 hover:bg-slate-100" + } + ] + } + ] +} +``` + +### File List + +**Document Browser** + +```json +{ + "type": "scroll-area", + "height": "250px", + "className": "rounded-md border", + "children": [ + { + "type": "box", + "className": "divide-y", + "children": [ + { + "type": "flex", + "properties": { + "align": "center", + "gap": 3, + "children": [ + { + "type": "text", + "content": "📄", + "className": "text-2xl" + }, + { + "type": "box", + "children": [ + { + "type": "text", + "content": "document_1.pdf", + "className": "block font-medium" + }, + { + "type": "text", + "content": "396KB", + "className": "text-xs text-muted-foreground block" + } + ] + } + ] + }, + "className": "p-3 hover:bg-slate-50 cursor-pointer" + }, + { + "type": "flex", + "properties": { + "align": "center", + "gap": 3, + "children": [ + { + "type": "text", + "content": "📄", + "className": "text-2xl" + }, + { + "type": "box", + "children": [ + { + "type": "text", + "content": "document_2.pdf", + "className": "block font-medium" + }, + { + "type": "text", + "content": "328KB", + "className": "text-xs text-muted-foreground block" + } + ] + } + ] + }, + "className": "p-3 hover:bg-slate-50 cursor-pointer" + }, + { + "type": "flex", + "properties": { + "align": "center", + "gap": 3, + "children": [ + { + "type": "text", + "content": "📄", + "className": "text-2xl" + }, + { + "type": "box", + "children": [ + { + "type": "text", + "content": "document_3.pdf", + "className": "block font-medium" + }, + { + "type": "text", + "content": "405KB", + "className": "text-xs text-muted-foreground block" + } + ] + } + ] + }, + "className": "p-3 hover:bg-slate-50 cursor-pointer" + }, + { + "type": "flex", + "properties": { + "align": "center", + "gap": 3, + "children": [ + { + "type": "text", + "content": "📄", + "className": "text-2xl" + }, + { + "type": "box", + "children": [ + { + "type": "text", + "content": "document_4.pdf", + "className": "block font-medium" + }, + { + "type": "text", + "content": "369KB", + "className": "text-xs text-muted-foreground block" + } + ] + } + ] + }, + "className": "p-3 hover:bg-slate-50 cursor-pointer" + }, + { + "type": "flex", + "properties": { + "align": "center", + "gap": 3, + "children": [ + { + "type": "text", + "content": "📄", + "className": "text-2xl" + }, + { + "type": "box", + "children": [ + { + "type": "text", + "content": "document_5.pdf", + "className": "block font-medium" + }, + { + "type": "text", + "content": "379KB", + "className": "text-xs text-muted-foreground block" + } + ] + } + ] + }, + "className": "p-3 hover:bg-slate-50 cursor-pointer" + }, + { + "type": "flex", + "properties": { + "align": "center", + "gap": 3, + "children": [ + { + "type": "text", + "content": "📄", + "className": "text-2xl" + }, + { + "type": "box", + "children": [ + { + "type": "text", + "content": "document_6.pdf", + "className": "block font-medium" + }, + { + "type": "text", + "content": "378KB", + "className": "text-xs text-muted-foreground block" + } + ] + } + ] + }, + "className": "p-3 hover:bg-slate-50 cursor-pointer" + }, + { + "type": "flex", + "properties": { + "align": "center", + "gap": 3, + "children": [ + { + "type": "text", + "content": "📄", + "className": "text-2xl" + }, + { + "type": "box", + "children": [ + { + "type": "text", + "content": "document_7.pdf", + "className": "block font-medium" + }, + { + "type": "text", + "content": "458KB", + "className": "text-xs text-muted-foreground block" + } + ] + } + ] + }, + "className": "p-3 hover:bg-slate-50 cursor-pointer" + }, + { + "type": "flex", + "properties": { + "align": "center", + "gap": 3, + "children": [ + { + "type": "text", + "content": "📄", + "className": "text-2xl" + }, + { + "type": "box", + "children": [ + { + "type": "text", + "content": "document_8.pdf", + "className": "block font-medium" + }, + { + "type": "text", + "content": "438KB", + "className": "text-xs text-muted-foreground block" + } + ] + } + ] + }, + "className": "p-3 hover:bg-slate-50 cursor-pointer" + }, + { + "type": "flex", + "properties": { + "align": "center", + "gap": 3, + "children": [ + { + "type": "text", + "content": "📄", + "className": "text-2xl" + }, + { + "type": "box", + "children": [ + { + "type": "text", + "content": "document_9.pdf", + "className": "block font-medium" + }, + { + "type": "text", + "content": "132KB", + "className": "text-xs text-muted-foreground block" + } + ] + } + ] + }, + "className": "p-3 hover:bg-slate-50 cursor-pointer" + }, + { + "type": "flex", + "properties": { + "align": "center", + "gap": 3, + "children": [ + { + "type": "text", + "content": "📄", + "className": "text-2xl" + }, + { + "type": "box", + "children": [ + { + "type": "text", + "content": "document_10.pdf", + "className": "block font-medium" + }, + { + "type": "text", + "content": "403KB", + "className": "text-xs text-muted-foreground block" + } + ] + } + ] + }, + "className": "p-3 hover:bg-slate-50 cursor-pointer" + }, + { + "type": "flex", + "properties": { + "align": "center", + "gap": 3, + "children": [ + { + "type": "text", + "content": "📄", + "className": "text-2xl" + }, + { + "type": "box", + "children": [ + { + "type": "text", + "content": "document_11.pdf", + "className": "block font-medium" + }, + { + "type": "text", + "content": "37KB", + "className": "text-xs text-muted-foreground block" + } + ] + } + ] + }, + "className": "p-3 hover:bg-slate-50 cursor-pointer" + }, + { + "type": "flex", + "properties": { + "align": "center", + "gap": 3, + "children": [ + { + "type": "text", + "content": "📄", + "className": "text-2xl" + }, + { + "type": "box", + "children": [ + { + "type": "text", + "content": "document_12.pdf", + "className": "block font-medium" + }, + { + "type": "text", + "content": "435KB", + "className": "text-xs text-muted-foreground block" + } + ] + } + ] + }, + "className": "p-3 hover:bg-slate-50 cursor-pointer" + }, + { + "type": "flex", + "properties": { + "align": "center", + "gap": 3, + "children": [ + { + "type": "text", + "content": "📄", + "className": "text-2xl" + }, + { + "type": "box", + "children": [ + { + "type": "text", + "content": "document_13.pdf", + "className": "block font-medium" + }, + { + "type": "text", + "content": "254KB", + "className": "text-xs text-muted-foreground block" + } + ] + } + ] + }, + "className": "p-3 hover:bg-slate-50 cursor-pointer" + }, + { + "type": "flex", + "properties": { + "align": "center", + "gap": 3, + "children": [ + { + "type": "text", + "content": "📄", + "className": "text-2xl" + }, + { + "type": "box", + "children": [ + { + "type": "text", + "content": "document_14.pdf", + "className": "block font-medium" + }, + { + "type": "text", + "content": "419KB", + "className": "text-xs text-muted-foreground block" + } + ] + } + ] + }, + "className": "p-3 hover:bg-slate-50 cursor-pointer" + }, + { + "type": "flex", + "properties": { + "align": "center", + "gap": 3, + "children": [ + { + "type": "text", + "content": "📄", + "className": "text-2xl" + }, + { + "type": "box", + "children": [ + { + "type": "text", + "content": "document_15.pdf", + "className": "block font-medium" + }, + { + "type": "text", + "content": "259KB", + "className": "text-xs text-muted-foreground block" + } + ] + } + ] + }, + "className": "p-3 hover:bg-slate-50 cursor-pointer" + }, + { + "type": "flex", + "properties": { + "align": "center", + "gap": 3, + "children": [ + { + "type": "text", + "content": "📄", + "className": "text-2xl" + }, + { + "type": "box", + "children": [ + { + "type": "text", + "content": "document_16.pdf", + "className": "block font-medium" + }, + { + "type": "text", + "content": "149KB", + "className": "text-xs text-muted-foreground block" + } + ] + } + ] + }, + "className": "p-3 hover:bg-slate-50 cursor-pointer" + }, + { + "type": "flex", + "properties": { + "align": "center", + "gap": 3, + "children": [ + { + "type": "text", + "content": "📄", + "className": "text-2xl" + }, + { + "type": "box", + "children": [ + { + "type": "text", + "content": "document_17.pdf", + "className": "block font-medium" + }, + { + "type": "text", + "content": "120KB", + "className": "text-xs text-muted-foreground block" + } + ] + } + ] + }, + "className": "p-3 hover:bg-slate-50 cursor-pointer" + }, + { + "type": "flex", + "properties": { + "align": "center", + "gap": 3, + "children": [ + { + "type": "text", + "content": "📄", + "className": "text-2xl" + }, + { + "type": "box", + "children": [ + { + "type": "text", + "content": "document_18.pdf", + "className": "block font-medium" + }, + { + "type": "text", + "content": "185KB", + "className": "text-xs text-muted-foreground block" + } + ] + } + ] + }, + "className": "p-3 hover:bg-slate-50 cursor-pointer" + }, + { + "type": "flex", + "properties": { + "align": "center", + "gap": 3, + "children": [ + { + "type": "text", + "content": "📄", + "className": "text-2xl" + }, + { + "type": "box", + "children": [ + { + "type": "text", + "content": "document_19.pdf", + "className": "block font-medium" + }, + { + "type": "text", + "content": "211KB", + "className": "text-xs text-muted-foreground block" + } + ] + } + ] + }, + "className": "p-3 hover:bg-slate-50 cursor-pointer" + }, + { + "type": "flex", + "properties": { + "align": "center", + "gap": 3, + "children": [ + { + "type": "text", + "content": "📄", + "className": "text-2xl" + }, + { + "type": "box", + "children": [ + { + "type": "text", + "content": "document_20.pdf", + "className": "block font-medium" + }, + { + "type": "text", + "content": "137KB", + "className": "text-xs text-muted-foreground block" + } + ] + } + ] + }, + "className": "p-3 hover:bg-slate-50 cursor-pointer" + } + ] + } + ] +} +``` + +### Code Block + +**Code Preview** + +```json +{ + "type": "scroll-area", + "height": "200px", + "width": "100%", + "orientation": "both", + "className": "rounded-md border bg-slate-950 text-slate-50", + "children": [ + { + "type": "box", + "className": "p-4 font-mono text-sm", + "children": [ + { + "type": "text", + "content": "function example() {\n const data = { name: 'ObjectUI', version: '1.0.0', features: ['components', 'layouts', 'forms'] };\n return data.features.map(f => f.toUpperCase());\n}\n\nconst result = example();", + "className": "whitespace-pre block" + } + ] + } + ] +} +``` diff --git a/content/docs/sdk/components/complex/sort-ui.mdx b/content/docs/sdk/components/complex/sort-ui.mdx new file mode 100644 index 00000000000..a85152128c0 --- /dev/null +++ b/content/docs/sdk/components/complex/sort-ui.mdx @@ -0,0 +1,72 @@ +--- +title: "Sort UI" +description: "Configure sorting with dropdowns or button toggles" +--- + +The Sort UI component exposes single or multi-field sorting controls. + +## JSON Configuration + +```plaintext +{ + type: 'sort-ui', + variant: 'dropdown', + multiple: true, + fields: [ + { field: 'name', label: 'Name' }, + { field: 'created_at', label: 'Created At' } + ], + sort: [{ field: 'name', direction: 'asc' }] +} +``` + +## Basic Usage + +**Sort Ui** + +```json +{ + "type": "sort-ui", + "variant": "dropdown", + "multiple": true, + "fields": [ + { + "field": "name", + "label": "Name" + }, + { + "field": "created_at", + "label": "Created At" + }, + { + "field": "status", + "label": "Status" + } + ], + "sort": [ + { + "field": "name", + "direction": "asc" + } + ] +} +``` + +## Schema + +```ts +interface SortUISchema { + type: 'sort-ui'; + fields: Array<{ + field: string; + label?: string; + }>; + sort?: Array<{ + field: string; + direction: 'asc' | 'desc'; + }>; + onChange?: string; + multiple?: boolean; + variant?: 'dropdown' | 'buttons'; +} +``` diff --git a/content/docs/sdk/components/complex/view-switcher.mdx b/content/docs/sdk/components/complex/view-switcher.mdx new file mode 100644 index 00000000000..5e11e91ed11 --- /dev/null +++ b/content/docs/sdk/components/complex/view-switcher.mdx @@ -0,0 +1,96 @@ +--- +title: "View Switcher" +description: "Switch between multiple view configurations in one container" +--- + +The View Switcher component renders a selector and the active view schema in one place. + +## JSON Configuration + +```plaintext +{ + type: 'view-switcher', + variant: 'tabs', + position: 'top', + defaultView: 'grid', + views: [ + { + type: 'grid', + label: 'Grid', + schema: { type: 'text', content: 'Grid content' } + }, + { + type: 'kanban', + label: 'Kanban', + schema: { type: 'text', content: 'Kanban content' } + } + ] +} +``` + +## Basic Usage + +**View Switcher** + +```json +{ + "type": "view-switcher", + "variant": "tabs", + "position": "top", + "defaultView": "grid", + "views": [ + { + "type": "grid", + "label": "Grid", + "schema": { + "type": "text", + "content": "Grid content" + } + }, + { + "type": "kanban", + "label": "Kanban", + "schema": { + "type": "text", + "content": "Kanban content" + } + }, + { + "type": "calendar", + "label": "Calendar", + "schema": { + "type": "text", + "content": "Calendar content" + } + } + ] +} +``` + +## Schema + +```ts +import type { SchemaNode } from '@object-ui/types'; + +interface ViewSwitcherSchema { + type: 'view-switcher'; + views: Array<{ + type: 'list' | 'detail' | 'grid' | 'kanban' | 'calendar' | 'timeline' | 'map'; + label?: string; + icon?: string; + schema?: SchemaNode; + }>; + defaultView?: string; + activeView?: string; + variant?: 'tabs' | 'buttons' | 'dropdown'; + position?: 'top' | 'bottom' | 'left' | 'right'; + onViewChange?: string; + persistPreference?: boolean; + storageKey?: string; + allowCreateView?: boolean; // Show "+" button to create views + viewActions?: Array<{ // Per-view action icons + type: 'share' | 'settings' | 'duplicate' | 'delete'; + icon?: string; + }>; +} +``` diff --git a/content/docs/sdk/components/data-display/avatar.mdx b/content/docs/sdk/components/data-display/avatar.mdx new file mode 100644 index 00000000000..5313bab6c8c --- /dev/null +++ b/content/docs/sdk/components/data-display/avatar.mdx @@ -0,0 +1,38 @@ +--- +title: "Avatar" +description: "User profile image or fallback initials" +--- + +## Examples + +**Avatar With Image** + +```json +{ + "type": "avatar", + "src": "https://github.com/shadcn.png", + "alt": "User" +} +``` + +**Avatar With Fallback** + +```json +{ + "type": "avatar", + "fallback": "JD" +} +``` + +## Schema + +```ts +interface AvatarSchema { + type: 'avatar'; + src?: string; + alt?: string; + fallback?: string; + size?: 'sm' | 'md' | 'lg'; + className?: string; +} +``` diff --git a/content/docs/sdk/components/data-display/breadcrumb.mdx b/content/docs/sdk/components/data-display/breadcrumb.mdx new file mode 100644 index 00000000000..4b85c2fb55f --- /dev/null +++ b/content/docs/sdk/components/data-display/breadcrumb.mdx @@ -0,0 +1,160 @@ +--- +title: "Breadcrumb" +description: "Display the current location within a navigational hierarchy" +--- + +The Breadcrumb component shows the current page's location within the site hierarchy. + +## Basic Usage + +**Basic Breadcrumb** + +```json +{ + "type": "breadcrumb", + "items": [ + { + "label": "Home", + "href": "/" + }, + { + "label": "Products", + "href": "/products" + }, + { + "label": "Category", + "href": "/products/category" + }, + { + "label": "Item" + } + ] +} +``` + +## With Icons + +**With Icons** + +```json +{ + "type": "breadcrumb", + "items": [ + { + "label": "Home", + "href": "/", + "icon": "home" + }, + { + "label": "Docs", + "href": "/docs", + "icon": "book" + }, + { + "label": "Components", + "icon": "panels-top-left" + } + ] +} +``` + +## Separator + +An item trail is joined by `separator`, a plain string. Leave it unauthored and +the trail separates with the declared default, `/`: + +**Custom Separator** + +```json +{ + "type": "breadcrumb", + "separator": ">", + "items": [ + { + "label": "Home", + "href": "/" + }, + { + "label": "Products", + "href": "/products" + }, + { + "label": "Category", + "href": "/products/category" + }, + { + "label": "Item" + } + ] +} +``` + +Any string is accepted, including an empty one (`""`) for no visible separator +at all. + +## Collapsing a long trail + +`maxItems` bounds how many crumbs are **rendered**. When the trail is longer, +the first crumb and the last `maxItems - 1` survive and an ellipsis marks what +was elided — so the current page, the crumb the trail exists to name, is never +the one dropped: + +**Collapsed Trail** — `maxItems: 3` over a five-crumb trail: the root, an elision marker, and the last two crumbs. + +```json +{ + "type": "breadcrumb", + "maxItems": 3, + "items": [ + { + "label": "Home", + "href": "/" + }, + { + "label": "Products", + "href": "/products" + }, + { + "label": "Electronics", + "href": "/products/electronics" + }, + { + "label": "Laptops", + "href": "/products/electronics/laptops" + }, + { + "label": "ObjectBook Pro" + } + ] +} +``` + +A trail already within `maxItems` is rendered whole, and a `maxItems` that +cannot mean a count (below `1`) is ignored rather than emptying the trail. + +## Icons + +An item's `icon` is a **kebab-case Lucide icon name**, resolved against lucide's +runtime `icons` record — the same surface `ui:button` and the `action:*` family +resolve against. A name that is not a live key of that record (an unknown, or a +retired spelling such as `layout`) renders **no glyph**, never a fallback glyph +and never the literal name as text. The glyph is drawn for every crumb: the +final one (rendered as the current page) as well as the linked ones before it. + +## Schema + +```ts +interface BreadcrumbItem { + label: string; + href?: string; + icon?: string; // kebab-case Lucide icon name (e.g. "panels-top-left") +} + +interface BreadcrumbSchema { + type: 'breadcrumb'; + items: BreadcrumbItem[]; // Breadcrumb items + separator?: string; // Separator between crumbs (default "/") + maxItems?: number; // Maximum crumbs to display before collapsing + className?: string; +} +``` diff --git a/content/docs/sdk/components/data-display/kbd.mdx b/content/docs/sdk/components/data-display/kbd.mdx new file mode 100644 index 00000000000..72a56474c48 --- /dev/null +++ b/content/docs/sdk/components/data-display/kbd.mdx @@ -0,0 +1,102 @@ +--- +title: "Kbd" +description: "Display keyboard shortcuts and hotkeys" +--- + +The Kbd component displays keyboard shortcuts in a styled format. + +## Basic Usage + +**Copy Shortcut** + +```json +{ + "type": "kbd", + "keys": [ + "Ctrl", + "C" + ] +} +``` + +## Multiple Key Combinations + +**Command Palette** + +```json +{ + "type": "kbd", + "keys": [ + "Ctrl", + "Shift", + "P" + ] +} +``` + +**Search** + +```json +{ + "type": "kbd", + "keys": [ + "Cmd", + "K" + ] +} +``` + +**Submit** + +```json +{ + "type": "kbd", + "keys": [ + "Alt", + "Enter" + ] +} +``` + +## Schema + +```ts +interface KbdSchema { + type: 'kbd'; + keys: string[]; // Keyboard keys + className?: string; +} +``` + +## Examples + +### In Documentation + +**Inline Usage** + +```json +{ + "type": "flex", + "properties": { + "align": "center", + "gap": 2, + "children": [ + { + "type": "text", + "content": "Press" + }, + { + "type": "kbd", + "keys": [ + "Ctrl", + "S" + ] + }, + { + "type": "text", + "content": "to save" + } + ] + } +} +``` diff --git a/content/docs/sdk/components/data-display/list.mdx b/content/docs/sdk/components/data-display/list.mdx new file mode 100644 index 00000000000..baf8b0b6ccc --- /dev/null +++ b/content/docs/sdk/components/data-display/list.mdx @@ -0,0 +1,67 @@ +--- +title: "List" +description: "Display a list of items" +--- + +## Examples + +**Basic List** + +```json +{ + "type": "list", + "items": [ + { + "content": "Item 1" + }, + { + "content": "Item 2" + }, + { + "content": "Item 3" + } + ] +} +``` + +## Schema + +```ts +import type { SchemaNode } from '@object-ui/types'; + +interface ListSchema { + type: 'list'; + title?: string; // heading drawn above the list + bind?: string; // path to an array in the host scope, read first + items?: SchemaNode[]; // drawn when `bind` resolves no array + ordered?: boolean; + className?: string; + wrapperClass?: string; +} +``` + +A `list` needs at least one of `bind` and `items`. The renderer reads `bind` through +`useDataScope` first and falls back to `items` when the bound value is not an array, so +a node may carry both. A bind-only list draws one entry per element of the bound array: + +```json +{ + "type": "list", + "bind": "customerNames" +} +``` + +A `list` with neither key is refused at the node by `safeValidateSchema` and the strict +face, with `params.code` set to `LIST_ENTRIES_REQUIRED`. Until objectui#11405 `items` was +required, so the bind-only list above was refused at `items`. The TypeScript face declares +`items` optional and cannot express the at-least-one rule; the validator enforces it. + + + `wrapperClass` is now typed. The renderer has read it all along — it lands on the + wrapper element around the list — but it went undeclared until objectui#7722, + surviving on what was then `BaseSchema`'s index signature (objectui#8347 removed it) and the zod mirror's `.passthrough()`. + Declared as `string` on both faces, a **non-string** `wrapperClass` that used to + parse green is now refused by name — on `ListSchema` and through + `safeValidateSchema` alike. Pass the Tailwind class string; a number or an object + gets a parse error where it used to get silence. + diff --git a/content/docs/sdk/components/data-display/meta.json b/content/docs/sdk/components/data-display/meta.json new file mode 100644 index 00000000000..744980ea783 --- /dev/null +++ b/content/docs/sdk/components/data-display/meta.json @@ -0,0 +1,11 @@ +{ + "title": "Data Display", + "pages": [ + "avatar", + "list", + "breadcrumb", + "kbd", + "statistic", + "tree-view" + ] +} diff --git a/content/docs/sdk/components/data-display/statistic.mdx b/content/docs/sdk/components/data-display/statistic.mdx new file mode 100644 index 00000000000..c0971d791fe --- /dev/null +++ b/content/docs/sdk/components/data-display/statistic.mdx @@ -0,0 +1,222 @@ +--- +title: "Statistic" +description: "Display key metrics and statistical values with trend indicators" +--- + +The Statistic component displays numerical data and key metrics with optional labels, descriptions, and trend indicators. + +## Basic Usage + +**Simple Statistic** + +```json +{ + "type": "statistic", + "label": "Total Revenue", + "value": "$45,231.89" +} +``` + +## With Trend Indicators + +**Positive Trend** + +```json +{ + "type": "statistic", + "label": "Active Users", + "value": "12,543", + "trend": "up", + "description": "+12.5% from last month" +} +``` + +**Negative Trend** + +```json +{ + "type": "statistic", + "label": "Bounce Rate", + "value": "24.8%", + "trend": "down", + "description": "-3.2% from last month" +} +``` + +**Neutral** + +```json +{ + "type": "statistic", + "label": "Conversion Rate", + "value": "3.24%", + "trend": "neutral", + "description": "No change" +} +``` + +## With Descriptions + +**With Description** + +```json +{ + "type": "statistic", + "label": "Total Sales", + "value": "145,892", + "description": "Across all products this quarter" +} +``` + +## Schema + +```ts +interface StatisticSchema { + type: 'statistic'; + + // Content + label?: string; // Label/title of the statistic + value: string | number; // Main value to display + description?: string; // Additional description text + icon?: string; // Optional icon name + + // Trend indicator + trend?: 'up' | 'down' | 'neutral'; // Trend direction + + // Styling + className?: string; // Tailwind CSS classes + + // Base properties + id?: string; + visible?: boolean | string | { dialect?: string; source: string }; + testId?: string; +} +``` + +## Examples + +### Dashboard Metrics + +**Metrics Grid** + +```json +{ + "type": "grid", + "columns": 3, + "gap": 4, + "children": [ + { + "type": "statistic", + "label": "Total Revenue", + "value": "$45,231.89", + "trend": "up", + "description": "+20.1% from last month" + }, + { + "type": "statistic", + "label": "Subscriptions", + "value": "2,350", + "trend": "up", + "description": "+180 this month" + }, + { + "type": "statistic", + "label": "Active Now", + "value": "573", + "trend": "up", + "description": "+201 since last hour" + } + ] +} +``` + +### Sales Dashboard + +**Sales Dashboard** + +```json +{ + "type": "container", + "maxWidth": "4xl", + "centered": false, + "padding": 0, + "children": [ + { + "type": "text", + "content": "Sales Overview", + "className": "text-2xl font-bold mb-6" + }, + { + "type": "grid", + "columns": 2, + "gap": 4, + "children": [ + { + "type": "statistic", + "label": "Revenue", + "value": "$125,430", + "trend": "up", + "description": "+15.2% vs last quarter" + }, + { + "type": "statistic", + "label": "Orders", + "value": "3,842", + "trend": "up", + "description": "+8.7% vs last quarter" + }, + { + "type": "statistic", + "label": "Average Order", + "value": "$32.65", + "trend": "down", + "description": "-2.1% vs last quarter" + }, + { + "type": "statistic", + "label": "Refund Rate", + "value": "2.4%", + "trend": "neutral", + "description": "No significant change" + } + ] + } + ] +} +``` + +### Simple Stats Row + +**Social Stats** + +```json +{ + "type": "flex", + "properties": { + "gap": 6, + "children": [ + { + "type": "statistic", + "label": "Views", + "value": "124.5K" + }, + { + "type": "statistic", + "label": "Likes", + "value": "8.2K" + }, + { + "type": "statistic", + "label": "Comments", + "value": "342" + }, + { + "type": "statistic", + "label": "Shares", + "value": "89" + } + ] + }, + "className": "p-4" +} +``` diff --git a/content/docs/sdk/components/data-display/tree-view.mdx b/content/docs/sdk/components/data-display/tree-view.mdx new file mode 100644 index 00000000000..b41f1494bbc --- /dev/null +++ b/content/docs/sdk/components/data-display/tree-view.mdx @@ -0,0 +1,297 @@ +--- +title: "Tree View" +description: "Hierarchical tree structure for displaying nested data" +--- + +The Tree View component displays hierarchical data in an expandable tree structure, perfect for file explorers, organizational charts, and nested navigation. + +## Basic Usage + +**File Tree** + +```json +{ + "type": "tree-view", + "title": "File Explorer", + "nodes": [ + { + "id": "1", + "label": "Documents", + "icon": "folder", + "children": [ + { + "id": "1-1", + "label": "Resume.pdf", + "icon": "file" + }, + { + "id": "1-2", + "label": "Cover Letter.docx", + "icon": "file" + } + ] + }, + { + "id": "2", + "label": "Photos", + "icon": "folder", + "children": [ + { + "id": "2-1", + "label": "Vacation.jpg", + "icon": "file" + }, + { + "id": "2-2", + "label": "Family.jpg", + "icon": "file" + } + ] + }, + { + "id": "3", + "label": "README.md", + "icon": "file" + } + ] +} +``` + +## Nested Structure + +**Deep Nesting** + +```json +{ + "type": "tree-view", + "title": "Project Structure", + "nodes": [ + { + "id": "src", + "label": "src", + "icon": "folder", + "children": [ + { + "id": "components", + "label": "components", + "icon": "folder", + "children": [ + { + "id": "button", + "label": "Button.tsx", + "icon": "file" + }, + { + "id": "input", + "label": "Input.tsx", + "icon": "file" + } + ] + }, + { + "id": "utils", + "label": "utils", + "icon": "folder", + "children": [ + { + "id": "helpers", + "label": "helpers.ts", + "icon": "file" + } + ] + }, + { + "id": "index", + "label": "index.ts", + "icon": "file" + } + ] + }, + { + "id": "public", + "label": "public", + "icon": "folder", + "children": [ + { + "id": "logo", + "label": "logo.svg", + "icon": "file" + } + ] + }, + { + "id": "package", + "label": "package.json", + "icon": "file" + } + ] +} +``` + +## Schema + +```ts +interface TreeNode { + id: string; // Unique node identifier + label: string; // Node label text + icon?: string; // Icon name ('folder' or 'file') + children?: TreeNode[]; // Child nodes + data?: any; // Additional node data +} + +interface TreeViewSchema { + type: 'tree-view'; + + // Data — inline nodes, or bind the tree with `bind` (read first) + nodes?: TreeNode[]; // Tree nodes (the one inline spelling) + title?: string; // Tree title + + // Selection + defaultExpandedIds?: string[]; // Default expanded node IDs + defaultSelectedIds?: string[]; // Default selected node IDs + expandedIds?: string[]; // Controlled expanded node IDs + selectedIds?: string[]; // Controlled selected node IDs + multiSelect?: boolean; // Enable multi-selection (default: false) + + // Appearance + showLines?: boolean; // Show connecting lines (default: true) + + // Events + // RUNTIME SLOT (objectui#6124): a React host supplies it; JSON refuses it + // by name. Author behaviour as a node type, e.g. an action:button node. + onNodeClick?: (node: TreeNode) => void; // Node click handler + + // Styling + className?: string; // Tailwind CSS classes + + // Base properties + id?: string; + visible?: boolean | string | { dialect?: string; source: string }; + testId?: string; +} +``` + +> **Retired: `data`** (objectui#6951, ADR-0049 enforce-or-remove). `data` was a +> second spelling of the tree's one inline-nodes slot — the renderer read it only +> as the last limb of `bind || nodes || data` — and it is no longer part of +> `TreeViewSchema` on either face. A `tree-view` node that authors `data` now +> fails validation with: +> +> > RETIRED (ADR-0049) — `data` is no longer part of TreeViewSchema; write +> > `nodes` (or bind the tree with `bind`). … +> +> Rename the key to `nodes`; the array is unchanged. `bind` is unchanged and is +> still read first, so a `bind`-only tree-view stays a legal document, and +> `nodes` stays optional (no presence rule was added). `TreeNode.data` — the +> per-node payload — is a different member and is untouched. + +## Examples + +### Organization Chart + +**Org Chart** + +```json +{ + "type": "tree-view", + "title": "Organization", + "nodes": [ + { + "id": "ceo", + "label": "CEO", + "children": [ + { + "id": "cto", + "label": "CTO", + "children": [ + { + "id": "dev1", + "label": "Lead Developer" + }, + { + "id": "dev2", + "label": "Senior Developer" + } + ] + }, + { + "id": "cmo", + "label": "CMO", + "children": [ + { + "id": "marketing1", + "label": "Marketing Manager" + }, + { + "id": "marketing2", + "label": "Content Lead" + } + ] + } + ] + } + ] +} +``` + +### Navigation Menu + +**Sidebar Navigation** + +```json +{ + "type": "tree-view", + "title": "Navigation", + "nodes": [ + { + "id": "dashboard", + "label": "Dashboard", + "icon": "folder" + }, + { + "id": "products", + "label": "Products", + "icon": "folder", + "children": [ + { + "id": "all-products", + "label": "All Products", + "icon": "file" + }, + { + "id": "categories", + "label": "Categories", + "icon": "file" + }, + { + "id": "inventory", + "label": "Inventory", + "icon": "file" + } + ] + }, + { + "id": "orders", + "label": "Orders", + "icon": "folder", + "children": [ + { + "id": "pending", + "label": "Pending", + "icon": "file" + }, + { + "id": "completed", + "label": "Completed", + "icon": "file" + } + ] + }, + { + "id": "settings", + "label": "Settings", + "icon": "folder" + } + ] +} +``` diff --git a/content/docs/sdk/components/disclosure/collapsible.mdx b/content/docs/sdk/components/disclosure/collapsible.mdx new file mode 100644 index 00000000000..b7beba6a1fb --- /dev/null +++ b/content/docs/sdk/components/disclosure/collapsible.mdx @@ -0,0 +1,43 @@ +--- +title: "Collapsible" +description: "Toggle visibility of content" +--- + +## Examples + +**Basic Collapsible** + +```json +{ + "type": "collapsible", + "trigger": { + "type": "button", + "label": "Toggle" + }, + "content": [ + { + "type": "text", + "content": "Hidden content" + } + ] +} +``` + +## Schema + +```ts +import type { SchemaNode } from '@object-ui/types'; + +interface CollapsibleSchema { + type: 'collapsible'; + trigger: SchemaNode | SchemaNode[]; + content: SchemaNode | SchemaNode[]; + defaultOpen?: boolean; +} +``` + +The body is authored under `content`, not `children`: the renderer reads +`schema.content` inside `CollapsibleContent`, with no `children` fallback of any +kind, and the registration's `defaultProps` ships `content` too. A collapsible +that puts its body under `children` is refused by the zod twin — `content` is +required — and paints an empty body wherever nothing validates it. diff --git a/content/docs/sdk/components/disclosure/meta.json b/content/docs/sdk/components/disclosure/meta.json new file mode 100644 index 00000000000..d0a3b19a11e --- /dev/null +++ b/content/docs/sdk/components/disclosure/meta.json @@ -0,0 +1,7 @@ +{ + "title": "Disclosure", + "pages": [ + "collapsible", + "toggle-group" + ] +} diff --git a/content/docs/sdk/components/disclosure/toggle-group.mdx b/content/docs/sdk/components/disclosure/toggle-group.mdx new file mode 100644 index 00000000000..c8b2327fe59 --- /dev/null +++ b/content/docs/sdk/components/disclosure/toggle-group.mdx @@ -0,0 +1,115 @@ +--- +title: "Toggle Group" +description: "A group of toggle buttons where one or more can be selected" +--- + +The Toggle Group component allows users to toggle between multiple options. + +## Basic Usage + +**Single Selection** + +```json +{ + "type": "toggle-group", + "selectionType": "single", + "items": [ + { + "value": "left", + "label": "Left" + }, + { + "value": "center", + "label": "Center" + }, + { + "value": "right", + "label": "Right" + } + ] +} +``` + +## Multiple Selection + +**Multiple Selection** + +```json +{ + "type": "toggle-group", + "selectionType": "multiple", + "items": [ + { + "value": "bold", + "label": "Bold" + }, + { + "value": "italic", + "label": "Italic" + }, + { + "value": "underline", + "label": "Underline" + } + ] +} +``` + +## With Labels + +The third item sets `disabled: true`, which the renderer forwards to that toggle +only — the rest of the group stays interactive. + +**With Labels** + +```json +{ + "type": "toggle-group", + "selectionType": "single", + "items": [ + { + "value": "list", + "label": "List" + }, + { + "value": "grid", + "label": "Grid" + }, + { + "value": "table", + "label": "Table", + "disabled": true + } + ] +} +``` + +## Schema + +```ts +interface ToggleGroupItem { + value: string; + label: string; + disabled?: boolean; // Disables this item only +} + +interface ToggleGroupSchema { + type: 'toggle-group'; + selectionType?: 'single' | 'multiple'; // Selection mode + items: ToggleGroupItem[]; // Toggle items + value?: string | string[]; // Selected value(s) + variant?: 'default' | 'outline'; + size?: 'sm' | 'default' | 'lg'; + + // Events + // RUNTIME SLOT (objectui#6124): a React host supplies it; JSON refuses it + // by name. Author behaviour as a node type, e.g. an action:button node. + onValueChange?: (value: string | string[]) => void; + + // States + disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope + + // Styling + className?: string; +} +``` diff --git a/content/docs/sdk/components/feedback/empty.mdx b/content/docs/sdk/components/feedback/empty.mdx new file mode 100644 index 00000000000..f22f1b583c6 --- /dev/null +++ b/content/docs/sdk/components/feedback/empty.mdx @@ -0,0 +1,103 @@ +--- +title: "Empty" +description: "Empty state placeholder with optional action" +--- + +The Empty component displays a placeholder when there's no content to show. + +## Basic Usage + +**Basic Empty State** + +```json +{ + "type": "empty", + "title": "No results found", + "description": "Try adjusting your search or filters" +} +``` + +## With Icon + +**With Icon** + +```json +{ + "type": "empty", + "icon": "inbox", + "title": "No messages", + "description": "Your inbox is empty" +} +``` + +## With Action + +**With Action Button** + +```json +{ + "type": "empty", + "icon": "folder-plus", + "title": "No projects yet", + "description": "Get started by creating your first project", + "action": { + "type": "button", + "label": "Create Project", + "variant": "default" + } +} +``` + +## Schema + +```ts +import type { SchemaNode } from '@object-ui/types'; + +interface EmptySchema { + type: 'empty'; + icon?: string; // Lucide icon name + title?: string; // Main message + description?: string; // Supporting text + action?: SchemaNode; // Call to action button (any node; a bare string renders as text) + className?: string; +} +``` + +## Examples + +### Search Results + +**Empty Search Results** + +```json +{ + "type": "empty", + "icon": "search", + "title": "No results for \"example\"", + "description": "Try searching with different keywords", + "action": { + "type": "button", + "label": "Clear Search", + "variant": "outline" + } +} +``` + +### Empty List + +**Empty Team List** + +```json +{ + "type": "empty", + "icon": "users", + "title": "No team members", + "description": "Invite people to join your team", + "action": { + "type": "button", + "label": "Invite Team Members", + "variant": "default", + "icon": "user-plus" + } +} +``` diff --git a/content/docs/sdk/components/feedback/loading.mdx b/content/docs/sdk/components/feedback/loading.mdx new file mode 100644 index 00000000000..ca065ba00a1 --- /dev/null +++ b/content/docs/sdk/components/feedback/loading.mdx @@ -0,0 +1,51 @@ +--- +title: "Loading" +description: "Loading spinner to indicate processing" +--- + +## Examples + +**Basic Loading** + +```json +{ + "type": "loading" +} +``` + +**With Text** + +```json +{ + "type": "stack", + "gap": 2, + "align": "center", + "children": [ + { + "type": "loading" + }, + { + "type": "text", + "content": "Loading...", + "variant": "caption" + } + ] +} +``` + +## Schema + +```ts +interface LoadingSchema { + type: 'loading'; + label?: string; // Text drawn under the spinner + size?: 'sm' | 'md' | 'lg'; + className?: string; +} +``` + + + The message under the spinner is `label`, the key `LoadingSchema` declares. + Until objectui#11347 the renderer read an undeclared `text` instead and left + `label` inert. `text` is not a key of this node: write `label`. + diff --git a/content/docs/sdk/components/feedback/meta.json b/content/docs/sdk/components/feedback/meta.json new file mode 100644 index 00000000000..c0f19aea731 --- /dev/null +++ b/content/docs/sdk/components/feedback/meta.json @@ -0,0 +1,12 @@ +{ + "title": "Feedback", + "pages": [ + "loading", + "progress", + "skeleton", + "spinner", + "empty", + "toast", + "sonner" + ] +} diff --git a/content/docs/sdk/components/feedback/progress.mdx b/content/docs/sdk/components/feedback/progress.mdx new file mode 100644 index 00000000000..12727407381 --- /dev/null +++ b/content/docs/sdk/components/feedback/progress.mdx @@ -0,0 +1,48 @@ +--- +title: "Progress" +description: "Progress bar to show completion status" +--- + +## Examples + +**Progress Bar** + +```json +{ + "type": "progress", + "value": 60 +} +``` + +**Different Values** + +```json +{ + "type": "stack", + "gap": 3, + "children": [ + { + "type": "progress", + "value": 25 + }, + { + "type": "progress", + "value": 50 + }, + { + "type": "progress", + "value": 75 + } + ] +} +``` + +## Schema + +```ts +interface ProgressSchema { + type: 'progress'; + value: number; // 0-100 + className?: string; +} +``` diff --git a/content/docs/sdk/components/feedback/skeleton.mdx b/content/docs/sdk/components/feedback/skeleton.mdx new file mode 100644 index 00000000000..5a4f58f1dd3 --- /dev/null +++ b/content/docs/sdk/components/feedback/skeleton.mdx @@ -0,0 +1,43 @@ +--- +title: "Skeleton" +description: "Placeholder for loading content" +--- + +## Examples + +**Text Skeleton** + +```json +{ + "type": "stack", + "gap": 2, + "children": [ + { + "type": "skeleton", + "width": "100%", + "height": 20 + }, + { + "type": "skeleton", + "width": "80%", + "height": 20 + }, + { + "type": "skeleton", + "width": "90%", + "height": 20 + } + ] +} +``` + +## Schema + +```ts +interface SkeletonSchema { + type: 'skeleton'; + width?: string | number; + height?: string | number; + className?: string; +} +``` diff --git a/content/docs/sdk/components/feedback/sonner.mdx b/content/docs/sdk/components/feedback/sonner.mdx new file mode 100644 index 00000000000..94faf7e953c --- /dev/null +++ b/content/docs/sdk/components/feedback/sonner.mdx @@ -0,0 +1,118 @@ +--- +title: "Sonner" +description: "Toast notifications using Sonner library" +--- + +The Sonner component provides beautiful toast notifications with a rich API. + +## Basic Usage + +A `sonner` node renders its own trigger button. Clicking the button raises the toast the +node declares, so the whole demo is one schema node with no handler to wire up. +`buttonLabel` and `buttonVariant` style that trigger; `message`, `description` and +`variant` describe the toast it raises. + +**Basic Sonner Toast** + +```json +{ + "type": "sonner", + "message": "Event has been created", + "buttonLabel": "Show Sonner Toast" +} +``` + +## Toast Types + +**Success** + +```json +{ + "type": "sonner", + "variant": "success", + "message": "Operation completed successfully", + "buttonLabel": "Success" +} +``` + +**Error** + +```json +{ + "type": "sonner", + "variant": "error", + "message": "Something went wrong", + "buttonLabel": "Error", + "buttonVariant": "destructive" +} +``` + +**Warning** + +```json +{ + "type": "sonner", + "variant": "warning", + "message": "Please review your input", + "buttonLabel": "Warning" +} +``` + +**Info** + +```json +{ + "type": "sonner", + "variant": "info", + "message": "New update available", + "buttonLabel": "Info" +} +``` + +## With Description + +`description` adds a secondary line under the message. + +**Message With Description** — A sonner toast carrying both a message and a secondary description line. + +```json +{ + "type": "sonner", + "variant": "success", + "message": "File uploaded", + "description": "Your file has been uploaded successfully", + "buttonLabel": "Upload File" +} +``` + +## Schema + +```ts +interface SonnerSchema { + type: 'sonner'; + message?: string; // Toast message + title?: string; // Alias for message + description?: string; // Additional description + variant?: 'default' | 'success' | 'error' | 'warning' | 'info'; + + // Trigger button + buttonLabel?: string; // Trigger button text (default: 'Show Toast') + buttonVariant?: 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link'; +} +``` + +## Examples + +### Custom Trigger Button + +**Custom Trigger Button** — A success toast whose trigger button is styled through buttonVariant. + +```json +{ + "type": "sonner", + "variant": "success", + "message": "Changes saved!", + "buttonLabel": "Save Changes", + "buttonVariant": "outline" +} +``` diff --git a/content/docs/sdk/components/feedback/spinner.mdx b/content/docs/sdk/components/feedback/spinner.mdx new file mode 100644 index 00000000000..6fbe19e4793 --- /dev/null +++ b/content/docs/sdk/components/feedback/spinner.mdx @@ -0,0 +1,100 @@ +--- +title: "Spinner" +description: "Loading spinner indicator" +--- + +The Spinner component displays a loading indicator. + +## Basic Usage + +**Default Spinner** + +```json +{ + "type": "spinner" +} +``` + +## Sizes + +**Small** + +```json +{ + "type": "spinner", + "size": "sm" +} +``` + +**Medium** + +```json +{ + "type": "spinner", + "size": "md" +} +``` + +**Large** + +```json +{ + "type": "spinner", + "size": "lg" +} +``` + +## Schema + +```ts +interface SpinnerSchema { + type: 'spinner'; + size?: 'sm' | 'md' | 'lg'; + className?: string; +} +``` + +## Examples + +### In Button + +**Loading Button** + +```json +{ + "type": "button", + "label": "Loading...", + "disabled": true, + "children": [ + { + "type": "spinner", + "size": "sm" + }, + { + "type": "text", + "content": "Please wait" + } + ] +} +``` + +### Centered + +**Centered Spinner** + +```json +{ + "type": "flex", + "properties": { + "justify": "center", + "align": "center", + "children": [ + { + "type": "spinner", + "size": "lg" + } + ] + }, + "className": "h-32" +} +``` diff --git a/content/docs/sdk/components/feedback/toast.mdx b/content/docs/sdk/components/feedback/toast.mdx new file mode 100644 index 00000000000..e9c55f2057a --- /dev/null +++ b/content/docs/sdk/components/feedback/toast.mdx @@ -0,0 +1,115 @@ +--- +title: "Toast" +description: "Brief notification messages that appear temporarily" +--- + +The Toast component displays brief notifications to users that appear temporarily and then disappear. + +## Basic Usage + +A `toast` node renders its own trigger button. Clicking the button raises the toast the +node declares — the title, description, variant and `durationMs` are all read off the node +itself, so the whole demo is one schema node with no handler to wire up. + +**Basic Toast** + +```json +{ + "type": "toast", + "title": "Success", + "description": "Your changes have been saved." +} +``` + +## Variants + +`variant` selects the toast style. `ToastSchema` declares exactly five members — +`default`, `success`, `warning`, `error` and `info`; the four below plus `success`, +which the Success Message example uses. + +**Default** + +```json +{ + "type": "toast", + "variant": "default", + "title": "Notification", + "description": "This is a default toast message." +} +``` + +**Error Variant** — The 'error' toast variant. 'destructive' is a button variant, not a toast variant. + +```json +{ + "type": "toast", + "variant": "error", + "title": "Error", + "description": "Something went wrong." +} +``` + +**Info Variant** — The 'info' toast variant, used here for an update notice. + +```json +{ + "type": "toast", + "variant": "info", + "title": "Update Available", + "description": "A new version is available." +} +``` + +**Warning Variant** — The 'warning' toast variant, used here for a deletion notice. + +```json +{ + "type": "toast", + "variant": "warning", + "title": "Item Deleted", + "description": "The item has been removed." +} +``` + +## Schema + +```ts +interface ToastSchema { + type: 'toast'; + title?: string; // Toast title + description?: string; // Toast message + variant?: 'default' | 'success' | 'warning' | 'error' | 'info'; + + // Timing + durationMs?: number; // Auto-dismiss time in ms +} +``` + +## Examples + +### Success Message + +**Success Toast** + +```json +{ + "type": "toast", + "variant": "success", + "title": "Saved Successfully", + "description": "Your changes have been saved.", + "durationMs": 3000 +} +``` + +### Error Message + +**Error Toast** + +```json +{ + "type": "toast", + "variant": "error", + "title": "Submission Failed", + "description": "Please check your input and try again." +} +``` diff --git a/content/docs/sdk/components/form/calendar.mdx b/content/docs/sdk/components/form/calendar.mdx new file mode 100644 index 00000000000..cec6a4ba24e --- /dev/null +++ b/content/docs/sdk/components/form/calendar.mdx @@ -0,0 +1,146 @@ +--- +title: "Calendar" +description: "Date selection component with single, multiple, and range modes" +--- + +The Calendar component allows users to select dates in various modes including single date, multiple dates, or date ranges. + +## Basic Usage + +**Simple Calendar** + +```json +{ + "type": "ui:calendar", + "mode": "single", + "className": "rounded-md border" +} +``` + +## Selection Modes + +**Single Date** + +```json +{ + "type": "ui:calendar", + "mode": "single", + "className": "rounded-md border" +} +``` + +**Date Range** + +```json +{ + "type": "ui:calendar", + "mode": "range", + "className": "rounded-md border" +} +``` + +## Multiple Selection + +**Multiple Dates** + +```json +{ + "type": "ui:calendar", + "mode": "multiple", + "className": "rounded-md border shadow-sm" +} +``` + +## Selected Date + +Each day in `value` and `defaultValue` is an ISO 8601 date string in JSON (`"2026-09-15"` or `"2026-09-15T09:00:00Z"`); a TypeScript caller may also pass a `Date`. A date-only string selects the day it names in every time zone. + +The shape of the selection follows `mode`: + +| `mode` | `value` / `defaultValue` | +|---|---| +| `single` (default) | one day | +| `multiple` | a list of days | +| `range` | `{ "from": …, "to": … }` — `to` is optional | + +```json +{ "type": "ui:calendar", "mode": "single", "defaultValue": "2026-09-15" } +``` + +```json +{ "type": "ui:calendar", "mode": "multiple", "defaultValue": ["2026-09-15", "2026-09-17"] } +``` + +```json +{ "type": "ui:calendar", "mode": "range", "defaultValue": { "from": "2026-09-15", "to": "2026-09-19" } } +``` + +Schema validation refuses a selection whose shape does not fit its mode — a single day under `mode: "multiple"`, for example — instead of letting it reach the renderer. + +The calendar opens on the month of its first selected day: the day itself in `single` mode, the first valid listed day in `multiple` mode (an entry that is not a date is skipped), and `from` in `range` mode. With no selection it opens on the current month. It reads that month once, when it first renders: a `value` that changes afterwards does not move the month it shows. + +## Schema + +```ts +type Day = Date | string; // one calendar day: an ISO 8601 date string in JSON, or a Date + +interface CalendarSchema { + type: 'calendar'; + + // Selection + mode?: 'single' | 'multiple' | 'range'; // Selection mode (default: 'single') + defaultValue?: Day | Day[] | { from: Day; to?: Day }; // Default selection, shaped by mode (Day = ISO 8601 string in JSON, or Date) + value?: Day | Day[] | { from: Day; to?: Day }; // Controlled selection, shaped by mode + + // Constraints + minDate?: Date | string; // Minimum selectable date + maxDate?: Date | string; // Maximum selectable date + disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope + + // Styling + className?: string; // Tailwind CSS classes + + // Base properties + id?: string; + visible?: boolean | string | { dialect?: string; source: string }; + testId?: string; +} +``` + +## Examples + +### In a Form + +**Form Integration** + +```json +{ + "type": "stack", + "gap": 4, + "className": "max-w-sm p-4 border rounded-lg", + "children": [ + { + "type": "label", + "label": "Select a date", + "className": "text-sm font-medium" + }, + { + "type": "ui:calendar", + "mode": "single", + "className": "rounded-md border" + } + ] +} +``` + +### With Custom Styling + +**Custom Style** + +```json +{ + "type": "ui:calendar", + "mode": "single", + "className": "rounded-xl border-2 shadow-lg" +} +``` diff --git a/content/docs/sdk/components/form/checkbox.mdx b/content/docs/sdk/components/form/checkbox.mdx new file mode 100644 index 00000000000..3223837e4d8 --- /dev/null +++ b/content/docs/sdk/components/form/checkbox.mdx @@ -0,0 +1,56 @@ +--- +title: "Checkbox" +description: "Toggle selection with checkboxes" +--- + +## Examples + +**Basic Checkbox** + +```json +{ + "type": "checkbox", + "label": "Accept terms and conditions" +} +``` + +**Multiple Checkboxes** + +```json +{ + "type": "stack", + "gap": 2, + "children": [ + { + "type": "checkbox", + "label": "Option 1" + }, + { + "type": "checkbox", + "label": "Option 2" + }, + { + "type": "checkbox", + "label": "Option 3" + } + ] +} +``` + +## Schema + +```ts +interface CheckboxSchema { + type: 'checkbox'; + name?: string; + label?: string; + defaultChecked?: boolean; + checked?: boolean; + required?: boolean; + disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope + description?: string; + error?: string; + className?: string; + wrapperClass?: string; +} +``` diff --git a/content/docs/sdk/components/form/combobox.mdx b/content/docs/sdk/components/form/combobox.mdx new file mode 100644 index 00000000000..956d522ec6d --- /dev/null +++ b/content/docs/sdk/components/form/combobox.mdx @@ -0,0 +1,208 @@ +--- +title: "Combobox" +description: "Searchable dropdown for selecting from a list of options" +--- + +The Combobox component combines a text input with a dropdown list, allowing users to search and select from options. + +## Basic Usage + +**Basic Combobox** + +```json +{ + "type": "combobox", + "placeholder": "Select framework...", + "options": [ + { + "value": "next", + "label": "Next.js" + }, + { + "value": "react", + "label": "React" + }, + { + "value": "vue", + "label": "Vue" + }, + { + "value": "angular", + "label": "Angular" + } + ] +} +``` + +## With Search + +**Searchable Combobox** + +```json +{ + "type": "combobox", + "placeholder": "Select language...", + "options": [ + { + "value": "js", + "label": "JavaScript" + }, + { + "value": "ts", + "label": "TypeScript" + }, + { + "value": "py", + "label": "Python" + }, + { + "value": "go", + "label": "Go" + }, + { + "value": "rust", + "label": "Rust" + }, + { + "value": "java", + "label": "Java" + } + ] +} +``` + +## States + +**Disabled** + +```json +{ + "type": "combobox", + "placeholder": "Disabled combobox", + "disabled": true, + "options": [ + { + "value": "1", + "label": "Option 1" + } + ] +} +``` + +**With Value** + +```json +{ + "type": "combobox", + "placeholder": "Select option...", + "value": "2", + "options": [ + { + "value": "1", + "label": "Option 1" + }, + { + "value": "2", + "label": "Option 2" + }, + { + "value": "3", + "label": "Option 3" + } + ] +} +``` + +## Schema + +```ts +interface ComboboxOption { + value: string; + label: string; + disabled?: boolean; // Option renders dimmed and cannot be selected +} + +interface ComboboxSchema { + type: 'combobox'; + options?: ComboboxOption[]; // Available options (absent: an empty list) + value?: string; // Selected value + name?: string; // Form-serialization key, placed on the trigger button + + // Text + placeholder?: string; // Button placeholder + description?: string; // Help text below the control, announced via aria-describedby + + // States + disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope + + // Styling + className?: string; +} +``` + + + There is no `defaultValue` on a combobox — authoring it is refused by name, + on both the TypeScript face and `safeValidateSchema`. Write `value`. + + A combobox node shows the option named by `value` and its selection does not + change, so "the value it starts at" and "the value it shows" are one question; + the key was retired under ADR-0049 rather than turned into a second spelling of + `value`. A `select` node does take `defaultValue`, because it hands the control + a change handler and so has a selection that can move away from its default — + and unlike `combobox`, `select` is also a built-in **form field** type, where + the form supplies the default itself. + + +## Help Text + +```json +{ + "type": "combobox", + "name": "country", + "description": "Where the account is billed.", + "placeholder": "Select country...", + "options": [ + { "value": "us", "label": "United States" }, + { "value": "de", "label": "Germany" } + ] +} +``` + +## Examples + +### Dynamic Options + +**Country Selector** + +```json +{ + "type": "combobox", + "placeholder": "Select country...", + "options": [ + { + "value": "us", + "label": "United States" + }, + { + "value": "uk", + "label": "United Kingdom" + }, + { + "value": "ca", + "label": "Canada" + }, + { + "value": "au", + "label": "Australia" + }, + { + "value": "de", + "label": "Germany" + }, + { + "value": "fr", + "label": "France" + } + ] +} +``` diff --git a/content/docs/sdk/components/form/command.mdx b/content/docs/sdk/components/form/command.mdx new file mode 100644 index 00000000000..cec4bef5993 --- /dev/null +++ b/content/docs/sdk/components/form/command.mdx @@ -0,0 +1,129 @@ +--- +title: "Command" +description: "Fast command menu with search and keyboard navigation" +--- + +The Command component provides a fast, searchable command menu with keyboard navigation. + +## Basic Usage + +**Command Menu** + +```json +{ + "type": "command", + "placeholder": "Type a command or search...", + "groups": [ + { + "heading": "Suggestions", + "items": [ + { + "value": "calendar", + "label": "Calendar", + "icon": "calendar" + }, + { + "value": "search", + "label": "Search Emoji", + "icon": "face-slightly-smiling" + }, + { + "value": "calculator", + "label": "Calculator", + "icon": "calculator" + } + ] + }, + { + "heading": "Settings", + "items": [ + { + "value": "profile", + "label": "Profile", + "icon": "user" + }, + { + "value": "billing", + "label": "Billing", + "icon": "credit-card" + }, + { + "value": "settings", + "label": "Settings", + "icon": "settings" + } + ] + } + ] +} +``` + +## Icons + +An item's `icon` is a **kebab-case Lucide icon name**, resolved against lucide's +runtime `icons` record — the same surface `ui:button` and the `action:*` family +resolve against. A name that is not a live key of that record (an unknown, or a +retired spelling such as `smile`) renders **no glyph**, never a fallback glyph +and never the literal name as text. + +## Schema + +```ts +interface CommandItem { + value: string; + label: string; + icon?: string; // kebab-case Lucide icon name (e.g. "face-slightly-smiling") +} + +interface CommandGroup { + heading?: string; + items: CommandItem[]; +} + +interface CommandSchema { + type: 'command'; + placeholder?: string; // Search placeholder + groups?: CommandGroup[]; // Command groups (absent: only the empty text) + emptyText?: string; // Text when no results + + // Styling + className?: string; +} +``` + +## Examples + +### File Command Palette + +A single-group palette of file commands, with its own search placeholder. + +**File Command Palette** + +```json +{ + "type": "command", + "placeholder": "Search commands...", + "groups": [ + { + "heading": "File", + "items": [ + { + "value": "new", + "label": "New File", + "icon": "file-plus" + }, + { + "value": "open", + "label": "Open File", + "icon": "folder-open" + }, + { + "value": "save", + "label": "Save", + "icon": "save" + } + ] + } + ] +} +``` diff --git a/content/docs/sdk/components/form/date-picker.mdx b/content/docs/sdk/components/form/date-picker.mdx new file mode 100644 index 00000000000..df9ded703cb --- /dev/null +++ b/content/docs/sdk/components/form/date-picker.mdx @@ -0,0 +1,141 @@ +--- +title: "Date Picker" +description: "Input for selecting dates with a calendar popup" +--- + +The Date Picker component allows users to select a date from a calendar interface. + +## Basic Usage + +**Basic Date Picker** + +```json +{ + "type": "date-picker", + "placeholder": "Pick a date" +} +``` + +## With Default Date + +**With Default Value** + +```json +{ + "type": "date-picker", + "placeholder": "Pick a date", + "value": "2024-01-15" +} +``` + +The calendar popup opens on the month of the selected date, so a date in another month or year is in view when the popup opens. With no date it opens on the current month. + +## States + +**Disabled** + +```json +{ + "type": "date-picker", + "placeholder": "Pick a date", + "disabled": true +} +``` + +**Full Width** + +```json +{ + "type": "date-picker", + "placeholder": "Pick a date", + "className": "w-full" +} +``` + +## Schema + +```ts +interface DatePickerSchema { + type: 'date-picker'; + value?: string | Date; // Selected date (ISO string or Date) + placeholder?: string; // Placeholder text + + // Events + // RUNTIME SLOT (objectui#6124): a React host supplies it; JSON refuses it + // by name. Author behaviour as a node type, e.g. an action:button node. + onChange?: (date: Date | undefined) => void; + + // States + disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope + + // Styling + className?: string; + wrapperClass?: string; +} +``` + + + `wrapperClass` is now typed. The renderer has read it all along — it lands on the + wrapper element around the date picker and its label — but it went undeclared until + objectui#7722, surviving on what was then `BaseSchema`'s index signature (objectui#8347 removed it) and the zod mirror's + `.passthrough()`. Declared as `string` on both faces, a **non-string** + `wrapperClass` that used to parse green is now refused by name — on + `DatePickerSchema` and through `safeValidateSchema` alike. Pass the Tailwind class + string; a number or an object gets a parse error where it used to get silence. + + +## Examples + +### In a Form + +**Form Field** + +```json +{ + "type": "flex", + "properties": { + "direction": "col", + "gap": 4, + "children": [ + { + "type": "label", + "text": "Birth Date", + "htmlFor": "birthdate" + }, + { + "type": "date-picker", + "id": "birthdate", + "placeholder": "Select your birth date" + } + ] + } +} +``` + +### Date Range + +**Date Range Selector** + +```json +{ + "type": "flex", + "properties": { + "gap": 2, + "align": "center", + "children": [ + { + "type": "date-picker", + "placeholder": "Start date" + }, + { + "type": "text", + "content": "to" + }, + { + "type": "date-picker", + "placeholder": "End date" + } + ] + } +} +``` diff --git a/content/docs/sdk/components/form/file-upload.mdx b/content/docs/sdk/components/form/file-upload.mdx new file mode 100644 index 00000000000..f44f09061d7 --- /dev/null +++ b/content/docs/sdk/components/form/file-upload.mdx @@ -0,0 +1,157 @@ +--- +title: "File Upload" +description: "Modern file upload component with drag-and-drop support" +--- + +The File Upload component provides a futuristic interface for uploading files with visual feedback, progress indication, and multiple file support. + +## Basic Usage + +**Simple Upload** + +```json +{ + "type": "file-upload", + "id": "basic-upload", + "label": "Upload files", + "buttonText": "Choose files" +} +``` + +## Single vs Multiple + +**Single File** + +```json +{ + "type": "file-upload", + "id": "single-upload", + "label": "Single file", + "multiple": false, + "buttonText": "Select one file" +} +``` + +**Multiple Files** + +```json +{ + "type": "file-upload", + "id": "multi-upload", + "label": "Multiple files", + "multiple": true, + "buttonText": "Select multiple files" +} +``` + +## File Type Restrictions + +**Images Only** + +```json +{ + "type": "file-upload", + "id": "image-upload", + "label": "Upload images", + "accept": "image/*", + "multiple": true, + "buttonText": "Choose images" +} +``` + +## Schema + +```text +interface FileUploadSchema { + type: 'file-upload'; + + // Required + id: string; // Field identifier + + // Configuration + name?: string; // Field name for form submission + label?: string; // Label text + buttonText?: string; // Upload button text + + // File constraints + accept?: string; // Accepted file types (e.g., 'image/*', '.pdf,.doc') + multiple?: boolean; // Allow multiple files (default: false) + maxSize?: number; // Maximum file size in bytes + maxFiles?: number; // Maximum number of files (for multiple) + + // States + disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope + + // Help text + description?: string; // Help text or description + error?: string; // Error message + + // Styling + className?: string; // Tailwind CSS classes + wrapperClass?: string; // Wrapper container classes + + // Base properties + visible?: boolean | string | { dialect?: string; source: string }; + testId?: string; +} +``` + +## Examples + +### Document Upload + +**Document Upload** + +```json +{ + "type": "file-upload", + "id": "doc-upload", + "label": "Upload documents", + "accept": ".pdf,.doc,.docx,.txt", + "multiple": true, + "buttonText": "Select documents", + "description": "Accepted formats: PDF, DOC, DOCX, TXT" +} +``` + +### Profile Picture + +**Avatar Upload** + +```json +{ + "type": "file-upload", + "id": "avatar-upload", + "label": "Profile Picture", + "accept": "image/png,image/jpeg", + "multiple": false, + "buttonText": "Upload photo" +} +``` + +### Full Example + +**Form Example** + +```json +{ + "type": "stack", + "gap": 6, + "className": "max-w-md p-6 border rounded-lg", + "children": [ + { + "type": "text", + "content": "Project Files", + "className": "text-xl font-semibold" + }, + { + "type": "file-upload", + "id": "project-files", + "label": "Upload project files", + "accept": "*", + "multiple": true, + "buttonText": "Drop files here or click to upload" + } + ] +} +``` diff --git a/content/docs/sdk/components/form/form.mdx b/content/docs/sdk/components/form/form.mdx new file mode 100644 index 00000000000..41dba7b9f09 --- /dev/null +++ b/content/docs/sdk/components/form/form.mdx @@ -0,0 +1,165 @@ +--- +title: "Form" +description: "Form container with validation and submission handling" +--- + +The Form component provides a complete form solution with validation and submission handling. + +## Basic Usage + +**Login Form** + +```json +{ + "type": "form", + "fields": [ + { + "name": "email", + "label": "Email", + "type": "input", + "inputType": "email", + "placeholder": "Enter your email", + "required": true + }, + { + "name": "password", + "label": "Password", + "type": "input", + "inputType": "password", + "placeholder": "Enter your password", + "required": true + } + ], + "submitLabel": "Sign In" +} +``` + +## With Validation + +**Registration Form** + +```json +{ + "type": "form", + "fields": [ + { + "name": "username", + "label": "Username", + "type": "input", + "placeholder": "Choose a username", + "required": true, + "minLength": 3, + "maxLength": 20 + }, + { + "name": "email", + "label": "Email", + "type": "input", + "inputType": "email", + "required": true, + "pattern": "^[^@]+@[^@]+\\.[^@]+$" + }, + { + "name": "age", + "label": "Age", + "type": "input", + "inputType": "number", + "min": 18, + "max": 120 + } + ], + "submitLabel": "Register" +} +``` + +## Schema + +```ts +interface FormField { + name: string; + label?: string; + type: 'input' | 'textarea' | 'select' | 'checkbox' | 'radio-group' | 'date-picker'; + placeholder?: string; + required?: boolean; + disabled?: boolean; + + // Validation + minLength?: number; + maxLength?: number; + min?: number; + max?: number; + step?: number; // a number, never 'any'; without one, a number input steps by 10^-scale, or 'any' with no scale + pattern?: string; + + // Options (for select, radio-group) + options?: Array<{ value: string; label: string }>; + + // Styling: the classes of the control this field draws, joined with its own; never the field row + className?: string; +} + +interface FormSchema { + type: 'form'; + fields: FormField[]; // Form fields + defaultValues?: Record; // Initial values, keyed by field name (a field has no default key of its own) + submitLabel?: string; // Submit button text (default 'Submit') + + // Events + // RUNTIME SLOT (objectui#6124): a React host supplies it; JSON refuses it + // by name. Author behaviour as a node type, e.g. an action:button node. + onSubmit?: (data: Record) => void | Promise; + + // Layout + columns?: number; // Grid layout + + // Styling + className?: string; +} +``` + +## Examples + +### Multi-column Form + +**Contact Form** + +```json +{ + "type": "form", + "columns": 2, + "fields": [ + { + "name": "firstName", + "label": "First Name", + "type": "input", + "required": true + }, + { + "name": "lastName", + "label": "Last Name", + "type": "input", + "required": true + }, + { + "name": "email", + "label": "Email", + "type": "input", + "inputType": "email", + "required": true + }, + { + "name": "phone", + "label": "Phone", + "type": "input", + "inputType": "tel" + }, + { + "name": "address", + "label": "Address", + "type": "textarea", + "colSpan": 2 + } + ], + "submitLabel": "Submit" +} +``` diff --git a/content/docs/sdk/components/form/input-otp.mdx b/content/docs/sdk/components/form/input-otp.mdx new file mode 100644 index 00000000000..78f053eef8d --- /dev/null +++ b/content/docs/sdk/components/form/input-otp.mdx @@ -0,0 +1,110 @@ +--- +title: "Input OTP" +description: "One-time password input with auto-focus" +--- + +The Input OTP component provides a secure way to enter one-time passwords or verification codes. + +## Basic Usage + +**6 Digit Otp** + +```json +{ + "type": "input-otp", + "length": 6 +} +``` + +## With Separator + +**With Visual Separator** + +```json +{ + "type": "input-otp", + "length": 6, + "separator": true +} +``` + +## Different Lengths + +**4 Digit** + +```json +{ + "type": "input-otp", + "length": 4 +} +``` + +**8 Digit** + +```json +{ + "type": "input-otp", + "length": 8 +} +``` + +## Schema + +```ts +interface InputOTPSchema { + type: 'input-otp'; + length?: number; // Number of digits (default 6) + value?: string; // Current value + separator?: boolean; // Show visual separator + + // Events + // RUNTIME SLOT (objectui#6124): a React host supplies it; JSON refuses it + // by name. Author behaviour as a node type, e.g. an action:button node. + onChange?: (value: string) => void; + + // States + disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope + + // Styling + className?: string; +} +``` + + + `length` is the slot count, and the renderer now reads it. Until objectui#11347 + it read an undeclared `maxLength` instead and ignored `length`, so the 4- and + 8-digit examples above drew six slots. `maxLength` is not a key of this node: + write `length`. + + +## Examples + +### In a Form + +**Verification Form** + +```json +{ + "type": "flex", + "properties": { + "direction": "col", + "gap": 4, + "children": [ + { + "type": "label", + "text": "Enter verification code" + }, + { + "type": "input-otp", + "length": 6, + "separator": true + }, + { + "type": "text", + "content": "We sent a code to your email", + "className": "text-sm text-muted-foreground" + } + ] + } +} +``` diff --git a/content/docs/sdk/components/form/input.mdx b/content/docs/sdk/components/form/input.mdx new file mode 100644 index 00000000000..c219599ce82 --- /dev/null +++ b/content/docs/sdk/components/form/input.mdx @@ -0,0 +1,71 @@ +--- +title: "Input" +description: "Basic text input field for user data entry" +--- + +## Examples + +**Basic Input** + +```json +{ + "type": "input", + "placeholder": "Enter text..." +} +``` + +**With Label** + +```json +{ + "type": "input", + "name": "email", + "label": "Email", + "placeholder": "you@example.com" +} +``` + +**Input Types** + +```json +{ + "type": "stack", + "gap": 3, + "children": [ + { + "type": "input", + "label": "Email", + "inputType": "email", + "placeholder": "email@example.com" + }, + { + "type": "input", + "label": "Password", + "inputType": "password", + "placeholder": "••••••••" + }, + { + "type": "input", + "label": "Number", + "inputType": "number", + "placeholder": "123" + } + ] +} +``` + +## Schema + +```ts +interface InputSchema { + type: 'input'; + name?: string; + label?: string; + inputType?: 'text' | 'password' | 'email' | 'number' | 'tel' | 'url'; + placeholder?: string; + defaultValue?: string | number; + required?: boolean; + disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope + readonly?: boolean; +} +``` diff --git a/content/docs/sdk/components/form/meta.json b/content/docs/sdk/components/form/meta.json new file mode 100644 index 00000000000..d26ad833626 --- /dev/null +++ b/content/docs/sdk/components/form/meta.json @@ -0,0 +1,20 @@ +{ + "title": "Form", + "pages": [ + "input", + "textarea", + "select", + "combobox", + "checkbox", + "radio-group", + "switch", + "slider", + "date-picker", + "input-otp", + "command", + "form", + "calendar", + "file-upload", + "toggle" + ] +} diff --git a/content/docs/sdk/components/form/radio-group.mdx b/content/docs/sdk/components/form/radio-group.mdx new file mode 100644 index 00000000000..e2ac1f692e7 --- /dev/null +++ b/content/docs/sdk/components/form/radio-group.mdx @@ -0,0 +1,240 @@ +--- +title: "Radio Group" +description: "A set of checkable buttons where only one can be selected at a time" +--- + +The Radio Group component allows users to select one option from a set of mutually exclusive options. + +## Basic Usage + +**Basic Radio Group** + +```json +{ + "type": "radio-group", + "options": [ + { + "value": "option1", + "label": "Option 1" + }, + { + "value": "option2", + "label": "Option 2" + }, + { + "value": "option3", + "label": "Option 3" + } + ] +} +``` + +## With Default Value + +**With Default Selection** + +```json +{ + "type": "radio-group", + "value": "option2", + "options": [ + { + "value": "option1", + "label": "Option 1" + }, + { + "value": "option2", + "label": "Option 2" + }, + { + "value": "option3", + "label": "Option 3" + } + ] +} +``` + +## Layout Options + +**Vertical Layout** + +```json +{ + "type": "radio-group", + "orientation": "vertical", + "options": [ + { + "value": "sm", + "label": "Small" + }, + { + "value": "md", + "label": "Medium" + }, + { + "value": "lg", + "label": "Large" + } + ] +} +``` + +**Horizontal Layout** + +```json +{ + "type": "radio-group", + "orientation": "horizontal", + "options": [ + { + "value": "sm", + "label": "Small" + }, + { + "value": "md", + "label": "Medium" + }, + { + "value": "lg", + "label": "Large" + } + ] +} +``` + +## States + +**Disabled** + +```json +{ + "type": "radio-group", + "disabled": true, + "options": [ + { + "value": "option1", + "label": "Option 1" + }, + { + "value": "option2", + "label": "Option 2" + } + ] +} +``` + +**Individual Disabled** + +```json +{ + "type": "radio-group", + "options": [ + { + "value": "option1", + "label": "Option 1" + }, + { + "value": "option2", + "label": "Option 2", + "disabled": true + }, + { + "value": "option3", + "label": "Option 3" + } + ] +} +``` + +## Schema + +```ts +interface RadioOption { + value: string; + label: string; + disabled?: boolean; +} + +interface RadioGroupSchema { + type: 'radio-group'; + options: RadioOption[]; // Available options + value?: string; // Selected value + + // Layout + orientation?: 'horizontal' | 'vertical'; + + // States + disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope + + // Styling + className?: string; +} +``` + +## Examples + +### In a Form + +**Form Field** + +```json +{ + "type": "flex", + "properties": { + "direction": "col", + "gap": 4, + "children": [ + { + "type": "label", + "text": "Notification Preference" + }, + { + "type": "radio-group", + "value": "email", + "options": [ + { + "value": "email", + "label": "Email notifications" + }, + { + "value": "sms", + "label": "SMS notifications" + }, + { + "value": "none", + "label": "No notifications" + } + ] + } + ] + } +} +``` + +### With Descriptions + +**With Descriptions** + +```json +{ + "type": "radio-group", + "value": "free", + "options": [ + { + "value": "free", + "label": "Free", + "description": "Basic features for personal use" + }, + { + "value": "pro", + "label": "Pro", + "description": "Advanced features for professionals" + }, + { + "value": "enterprise", + "label": "Enterprise", + "description": "Full suite for teams" + } + ] +} +``` diff --git a/content/docs/sdk/components/form/select.mdx b/content/docs/sdk/components/form/select.mdx new file mode 100644 index 00000000000..921bbf675ff --- /dev/null +++ b/content/docs/sdk/components/form/select.mdx @@ -0,0 +1,77 @@ +--- +title: "Select" +description: "Dropdown selection from a list of options" +--- + +## Examples + +**Basic Select** + +```json +{ + "type": "select", + "label": "Choose an option", + "options": [ + { + "label": "Option 1", + "value": "1" + }, + { + "label": "Option 2", + "value": "2" + }, + { + "label": "Option 3", + "value": "3" + } + ] +} +``` + +**With Placeholder** + +```json +{ + "type": "select", + "placeholder": "Select...", + "options": [ + { + "label": "Red", + "value": "red" + }, + { + "label": "Green", + "value": "green" + }, + { + "label": "Blue", + "value": "blue" + } + ] +} +``` + +## Schema + +```ts +interface SelectSchema { + type: 'select'; + name?: string; + label?: string; + options: { label: string; value: string | number }[]; + placeholder?: string; + required?: boolean; + disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope + wrapperClass?: string; +} +``` + + + `wrapperClass` is now typed. The renderer has read it all along — it lands on the + wrapper element around the select and its label — but it went undeclared until + objectui#7722, surviving on what was then `BaseSchema`'s index signature (objectui#8347 removed it) and the zod mirror's + `.passthrough()`. Declared as `string` on both faces, a **non-string** + `wrapperClass` that used to parse green is now refused by name — on `SelectSchema` + and through `safeValidateSchema` alike. Pass the Tailwind class string; a number or + an object gets a parse error where it used to get silence. + diff --git a/content/docs/sdk/components/form/slider.mdx b/content/docs/sdk/components/form/slider.mdx new file mode 100644 index 00000000000..fa03b2e6b48 --- /dev/null +++ b/content/docs/sdk/components/form/slider.mdx @@ -0,0 +1,32 @@ +--- +title: "Slider" +description: "Select a value from a range" +--- + +## Examples + +**Basic Slider** + +```json +{ + "type": "slider", + "label": "Volume", + "defaultValue": [ + 50 + ] +} +``` + +## Schema + +```ts +interface SliderSchema { + type: 'slider'; + name?: string; + label?: string; + min?: number; + max?: number; + step?: number; + defaultValue?: number | number[]; +} +``` diff --git a/content/docs/sdk/components/form/switch.mdx b/content/docs/sdk/components/form/switch.mdx new file mode 100644 index 00000000000..73156223f68 --- /dev/null +++ b/content/docs/sdk/components/form/switch.mdx @@ -0,0 +1,61 @@ +--- +title: "Switch" +description: "Toggle switch for binary options" +--- + +## Examples + +**Basic Switch** + +```json +{ + "type": "switch", + "label": "Enable notifications" +} +``` + +**Multiple Switches** + +```json +{ + "type": "stack", + "gap": 3, + "children": [ + { + "type": "switch", + "label": "Email notifications" + }, + { + "type": "switch", + "label": "Push notifications" + }, + { + "type": "switch", + "label": "SMS notifications" + } + ] +} +``` + +## Schema + +```ts +interface SwitchSchema { + type: 'switch'; + name?: string; + label?: string; + defaultChecked?: boolean; + disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope + wrapperClass?: string; +} +``` + + + `wrapperClass` is now typed. The renderer has read it all along — it lands on the + wrapper element around the switch and its label — but it went undeclared until + objectui#7722, surviving on what was then `BaseSchema`'s index signature (objectui#8347 removed it) and the zod mirror's + `.passthrough()`. Declared as `string` on both faces, a **non-string** + `wrapperClass` that used to parse green is now refused by name — on `SwitchSchema` + and through `safeValidateSchema` alike. Pass the Tailwind class string; a number or + an object gets a parse error where it used to get silence. + diff --git a/content/docs/sdk/components/form/textarea.mdx b/content/docs/sdk/components/form/textarea.mdx new file mode 100644 index 00000000000..2be7f409be9 --- /dev/null +++ b/content/docs/sdk/components/form/textarea.mdx @@ -0,0 +1,51 @@ +--- +title: "Textarea" +description: "Multi-line text input for longer content" +--- + +## Examples + +**Basic Textarea** + +```json +{ + "type": "textarea", + "placeholder": "Enter your message..." +} +``` + +**With Label** + +```json +{ + "type": "textarea", + "label": "Description", + "placeholder": "Describe your issue...", + "rows": 5 +} +``` + +## Schema + +```ts +interface TextareaSchema { + type: 'textarea'; + name?: string; + label?: string; + placeholder?: string; + rows?: number; + required?: boolean; + disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope + wrapperClass?: string; +} +``` + + + `wrapperClass` is now typed. The renderer has read it all along — it lands on the + wrapper element around the textarea and its label — but it went undeclared until + objectui#7722, surviving on what was then `BaseSchema`'s index signature (objectui#8347 removed it) and the zod mirror's + `.passthrough()`. Declared as `string` on both faces, a **non-string** + `wrapperClass` that used to parse green is now refused by name — on `TextareaSchema` + and through `safeValidateSchema` alike. Pass the Tailwind class string; a number or + an object gets a parse error where it used to get silence. + diff --git a/content/docs/sdk/components/form/toggle.mdx b/content/docs/sdk/components/form/toggle.mdx new file mode 100644 index 00000000000..2a9f9f3a438 --- /dev/null +++ b/content/docs/sdk/components/form/toggle.mdx @@ -0,0 +1,242 @@ +--- +title: "Toggle" +description: "Two-state button for toggling options on and off" +--- + +The Toggle component is a button that can be toggled between on and off states, useful for settings and preferences. + +## Basic Usage + +**Simple Toggle** + +```json +{ + "type": "toggle", + "label": "Toggle" +} +``` + +## Variants + +**Default** + +```json +{ + "type": "toggle", + "label": "Default", + "variant": "default" +} +``` + +**Outline** + +```json +{ + "type": "toggle", + "label": "Outline", + "variant": "outline" +} +``` + +## Sizes + +**Toggle Sizes** + +```json +{ + "type": "flex", + "properties": { + "gap": 2, + "align": "center", + "children": [ + { + "type": "toggle", + "label": "Small", + "size": "sm" + }, + { + "type": "toggle", + "label": "Default", + "size": "default" + }, + { + "type": "toggle", + "label": "Large", + "size": "lg" + } + ] + } +} +``` + +## States + +**Pressed State** + +```json +{ + "type": "toggle", + "label": "Pressed", + "pressed": true +} +``` + +**Disabled State** + +```json +{ + "type": "toggle", + "label": "Disabled", + "disabled": true +} +``` + +## Schema + +(Schema documentation temporarily disabled) + +## Examples + +### With Icons + +**Settings Toggles** + +```json +{ + "type": "flex", + "properties": { + "gap": 2, + "children": [ + { + "type": "toggle", + "label": "🔔 Notifications", + "variant": "outline" + }, + { + "type": "toggle", + "label": "🌙 Dark Mode", + "variant": "outline" + }, + { + "type": "toggle", + "label": "🔊 Sound", + "variant": "outline" + } + ] + } +} +``` + +### In a Settings Panel + +**Settings Example** + +```json +{ + "type": "stack", + "gap": 4, + "className": "max-w-md p-6 border rounded-lg", + "children": [ + { + "type": "text", + "content": "Preferences", + "className": "text-lg font-semibold mb-4" + }, + { + "type": "flex", + "properties": { + "justify": "between", + "align": "center", + "children": [ + { + "type": "text", + "content": "Email notifications", + "className": "text-sm" + }, + { + "type": "toggle", + "ariaLabel": "Email notifications" + } + ] + }, + "className": "py-2" + }, + { + "type": "flex", + "properties": { + "justify": "between", + "align": "center", + "children": [ + { + "type": "text", + "content": "Push notifications", + "className": "text-sm" + }, + { + "type": "toggle", + "ariaLabel": "Push notifications", + "pressed": true + } + ] + }, + "className": "py-2" + }, + { + "type": "flex", + "properties": { + "justify": "between", + "align": "center", + "children": [ + { + "type": "text", + "content": "SMS notifications", + "className": "text-sm" + }, + { + "type": "toggle", + "ariaLabel": "SMS notifications" + } + ] + }, + "className": "py-2" + } + ] +} +``` + +### Toolbar Toggles + +**Text Formatting** + +```json +{ + "type": "flex", + "properties": { + "gap": 1, + "children": [ + { + "type": "toggle", + "label": "B", + "size": "sm", + "variant": "outline", + "ariaLabel": "Bold" + }, + { + "type": "toggle", + "label": "I", + "size": "sm", + "variant": "outline", + "ariaLabel": "Italic" + }, + { + "type": "toggle", + "label": "U", + "size": "sm", + "variant": "outline", + "ariaLabel": "Underline" + } + ] + }, + "className": "p-2 bg-muted rounded-lg" +} +``` diff --git a/content/docs/sdk/components/index.mdx b/content/docs/sdk/components/index.mdx new file mode 100644 index 00000000000..ea23d545a78 --- /dev/null +++ b/content/docs/sdk/components/index.mdx @@ -0,0 +1,97 @@ +--- +title: "Renderer Reference" +description: "Renderer types registered by the Object UI component packages, one page per type" +--- + +Each page in this reference documents one renderer type: the `type` value you write in a JSON schema, the +props it reads, and example schemas. The types are registered by `@object-ui/components` and the other +component packages when they are imported, through the [component registry](/docs/sdk/guide/component-registry). + +The page slug is the type name, so `dialog` is documented at `/docs/sdk/components/overlay/dialog`. + +The components a metadata author can place in an ObjectStack page are listed in `sdui.manifest.json`; those +are documented with the rest of the page metadata under [Views & Apps](/docs/ui). + +## Basic + +- [Separator](/docs/sdk/components/basic/separator) (`separator`) — Visual divider between content sections +- [Button Group](/docs/sdk/components/basic/button-group) (`button-group`) — Group multiple buttons together with shared styling +- [Pagination](/docs/sdk/components/basic/pagination) (`pagination`) — Navigate through pages of content +- [Navigation Menu](/docs/sdk/components/basic/navigation-menu) (`navigation-menu`) — Accessible navigation menu with dropdown support +- [Sidebar](/docs/sdk/components/basic/sidebar) (`sidebar`) — An app-level sidebar region that works in any host +- [Div (Deprecated)](/docs/sdk/components/basic/div) (`div`) — Generic container element - use Shadcn components instead + +## Form + +- [Input](/docs/sdk/components/form/input) (`input`) — Basic text input field for user data entry +- [Textarea](/docs/sdk/components/form/textarea) (`textarea`) — Multi-line text input for longer content +- [Select](/docs/sdk/components/form/select) (`select`) — Dropdown selection from a list of options +- [Combobox](/docs/sdk/components/form/combobox) (`combobox`) — Searchable dropdown for selecting from a list of options +- [Checkbox](/docs/sdk/components/form/checkbox) (`checkbox`) — Toggle selection with checkboxes +- [Radio Group](/docs/sdk/components/form/radio-group) (`radio-group`) — A set of checkable buttons where only one can be selected at a time +- [Switch](/docs/sdk/components/form/switch) (`switch`) — Toggle switch for binary options +- [Slider](/docs/sdk/components/form/slider) (`slider`) — Select a value from a range +- [Date Picker](/docs/sdk/components/form/date-picker) (`date-picker`) — Input for selecting dates with a calendar popup +- [Input OTP](/docs/sdk/components/form/input-otp) (`input-otp`) — One-time password input with auto-focus +- [Command](/docs/sdk/components/form/command) (`command`) — Fast command menu with search and keyboard navigation +- [Form](/docs/sdk/components/form/form) (`form`) — Form container with validation and submission handling +- [Calendar](/docs/sdk/components/form/calendar) (`calendar`) — Date selection component with single, multiple, and range modes +- [File Upload](/docs/sdk/components/form/file-upload) (`file-upload`) — Modern file upload component with drag-and-drop support +- [Toggle](/docs/sdk/components/form/toggle) (`toggle`) — Two-state button for toggling options on and off + +## Layout + +- [Aspect Ratio](/docs/sdk/components/layout/aspect-ratio) (`aspect-ratio`) — Maintain a consistent width to height ratio +- [Page](/docs/sdk/components/layout/page) (`page`) — Full-page container with title, description, and content sections + +## Data Display + +- [Avatar](/docs/sdk/components/data-display/avatar) (`avatar`) — User profile image or fallback initials +- [List](/docs/sdk/components/data-display/list) (`list`) — Display a list of items +- [Breadcrumb](/docs/sdk/components/data-display/breadcrumb) (`breadcrumb`) — Display the current location within a navigational hierarchy +- [Kbd](/docs/sdk/components/data-display/kbd) (`kbd`) — Display keyboard shortcuts and hotkeys +- [Statistic](/docs/sdk/components/data-display/statistic) (`statistic`) — Display key metrics and statistical values with trend indicators +- [Tree View](/docs/sdk/components/data-display/tree-view) (`tree-view`) — Hierarchical tree structure for displaying nested data + +## Feedback + +- [Loading](/docs/sdk/components/feedback/loading) (`loading`) — Loading spinner to indicate processing +- [Progress](/docs/sdk/components/feedback/progress) (`progress`) — Progress bar to show completion status +- [Skeleton](/docs/sdk/components/feedback/skeleton) (`skeleton`) — Placeholder for loading content +- [Spinner](/docs/sdk/components/feedback/spinner) (`spinner`) — Loading spinner indicator +- [Empty](/docs/sdk/components/feedback/empty) (`empty`) — Empty state placeholder with optional action +- [Toast](/docs/sdk/components/feedback/toast) (`toast`) — Brief notification messages that appear temporarily +- [Sonner](/docs/sdk/components/feedback/sonner) (`sonner`) — Toast notifications using Sonner library + +## Overlay + +- [Dialog](/docs/sdk/components/overlay/dialog) (`dialog`) — Modal dialog overlay for focused interactions +- [Drawer](/docs/sdk/components/overlay/drawer) (`drawer`) — Sliding panel from edge of screen +- [Tooltip](/docs/sdk/components/overlay/tooltip) (`tooltip`) — Contextual information on hover +- [Popover](/docs/sdk/components/overlay/popover) (`popover`) — Floating content panel +- [Alert Dialog](/docs/sdk/components/overlay/alert-dialog) (`alert-dialog`) — A modal dialog that interrupts the user with important content and expects a response +- [Sheet](/docs/sdk/components/overlay/sheet) (`sheet`) — Slide-in panel from the edge of the screen +- [Hover Card](/docs/sdk/components/overlay/hover-card) (`hover-card`) — Display rich content in a popup when hovering over a trigger +- [Context Menu](/docs/sdk/components/overlay/context-menu) (`context-menu`) — Display a menu on right-click or long-press +- [Dropdown Menu](/docs/sdk/components/overlay/dropdown-menu) (`dropdown-menu`) — Display a menu of actions or options triggered by a button +- [Menubar](/docs/sdk/components/overlay/menubar) (`menubar`) — Horizontal menu bar with dropdown menus + +## Disclosure + +- [Collapsible](/docs/sdk/components/disclosure/collapsible) (`collapsible`) — Toggle visibility of content +- [Toggle Group](/docs/sdk/components/disclosure/toggle-group) (`toggle-group`) — A group of toggle buttons where one or more can be selected + +## Navigation + +- [Header Bar](/docs/sdk/components/navigation/header-bar) (`header-bar`) — Application header with sidebar trigger and breadcrumb navigation + +## Complex + +- [Data Table](/docs/sdk/components/complex/data-table) (`data-table`) — Enterprise-grade data table with sorting, filtering, pagination, and row actions +- [Carousel](/docs/sdk/components/complex/carousel) (`carousel`) — Slideshow component for cycling through content +- [Scroll Area](/docs/sdk/components/complex/scroll-area) (`scroll-area`) — Customizable scrollable container with styled scrollbars +- [Resizable](/docs/sdk/components/complex/resizable) (`resizable`) — Resizable panel groups with draggable handles +- [Filter Builder](/docs/sdk/components/complex/filter-builder) (`filter-builder`) — Visual query builder for constructing complex filter conditions +- [View Switcher](/docs/sdk/components/complex/view-switcher) (`view-switcher`) — Switch between multiple view configurations in one container +- [Filter UI](/docs/sdk/components/complex/filter-ui) (`filter-ui`) — Build flexible filter panels for list and view toolbars +- [Sort UI](/docs/sdk/components/complex/sort-ui) (`sort-ui`) — Configure sorting with dropdowns or button toggles diff --git a/content/docs/sdk/components/layout/aspect-ratio.mdx b/content/docs/sdk/components/layout/aspect-ratio.mdx new file mode 100644 index 00000000000..d381319b1ff --- /dev/null +++ b/content/docs/sdk/components/layout/aspect-ratio.mdx @@ -0,0 +1,100 @@ +--- +title: "Aspect Ratio" +description: "Maintain a consistent width to height ratio" +--- + +The Aspect Ratio component maintains a consistent width to height ratio for content. + +## Basic Usage + +**16 9 Aspect Ratio** + +```json +{ + "type": "aspect-ratio", + "ratio": 1.7777777777777777, + "image": "https://images.unsplash.com/photo-1588345921523-c2dcdb7f1dcd", + "alt": "Photo" +} +``` + +## Different Ratios + +**Square** + +```json +{ + "type": "aspect-ratio", + "ratio": 1, + "children": { + "type": "card", + "className": "flex h-full items-center justify-center", + "children": "Square (1:1)" + } +} +``` + +**4 3** + +```json +{ + "type": "aspect-ratio", + "ratio": 1.3333333333333333, + "children": { + "type": "card", + "className": "flex h-full items-center justify-center", + "children": "4:3 Ratio" + } +} +``` + +**Ultrawide** + +```json +{ + "type": "aspect-ratio", + "ratio": 2.3333333333333335, + "children": { + "type": "card", + "className": "flex h-full items-center justify-center", + "children": "21:9 Ratio" + } +} +``` + +## Schema + +The box holds its content one of two ways, and the renderer reads exactly one of them: +set `image` for a photo, which is drawn to fill the box; otherwise the box renders +`children`. + +```ts +import type { SchemaNode } from '@object-ui/types'; + +interface AspectRatioSchema { + type: 'aspect-ratio'; + ratio?: number; // Width / Height ratio (default: 16 / 9) + image?: string; // Image URL, drawn to fill the box + alt?: string; // Alt text for `image` + children?: SchemaNode | SchemaNode[]; // Content, when no `image` is set + className?: string; +} +``` + +## Examples + +### Video Container + +**Video Aspect Ratio** + +```json +{ + "type": "aspect-ratio", + "ratio": 1.7777777777777777, + "children": { + "type": "card", + "className": "bg-muted flex h-full items-center justify-center", + "children": "Video Player (16:9)" + } +} +``` diff --git a/content/docs/sdk/components/layout/meta.json b/content/docs/sdk/components/layout/meta.json new file mode 100644 index 00000000000..ca9b965bebe --- /dev/null +++ b/content/docs/sdk/components/layout/meta.json @@ -0,0 +1,7 @@ +{ + "title": "Layout", + "pages": [ + "aspect-ratio", + "page" + ] +} diff --git a/content/docs/sdk/components/layout/page.mdx b/content/docs/sdk/components/layout/page.mdx new file mode 100644 index 00000000000..f4f6723ead5 --- /dev/null +++ b/content/docs/sdk/components/layout/page.mdx @@ -0,0 +1,295 @@ +--- +title: "Page" +description: "Full-page container with title, description, and content sections" +--- + +The Page component provides a standardized full-page layout with built-in support for titles, descriptions, and organized content sections. + +## Basic Usage + +**Simple Page** + +```json +{ + "type": "page", + "pageType": "app", + "title": "Dashboard", + "description": "Welcome to your dashboard", + "children": [ + { + "type": "text", + "content": "Page content goes here" + } + ] +} +``` + +## With Title and Description + +**Page With Header** + +```json +{ + "type": "page", + "pageType": "app", + "title": "Analytics Overview", + "description": "Track your key metrics and performance indicators", + "children": [ + { + "type": "grid", + "columns": 3, + "gap": 4, + "children": [ + { + "type": "statistic", + "label": "Total Users", + "value": "12,543" + }, + { + "type": "statistic", + "label": "Revenue", + "value": "$45,231" + }, + { + "type": "statistic", + "label": "Growth", + "value": "+12.5%" + } + ] + } + ] +} +``` + +`pageType` decides whether the page draws its own `title` and `description`. `app`, `list`, +`utility` and `home` draw both, unless a titled `page:header` block in the page takes over the +heading. A page with no `pageType` is a `record` page, which draws neither: its heading is a +[`page:header`](/docs/ui/pages) block. So both examples above name `"pageType": "app"`. +The [layout guide's Content Width](/docs/sdk/guide/layout#content-width) section covers the width +each type sets. + +When a titled `page:header` takes over the heading, it also draws the page's `description`, +under its title and subtitle, so the description never sits above the title. + +## Region Block Grid + +A page's `regions` lay their blocks out on a 12-column grid. A block's width comes from its +kind, not from a key on the block: a metric tile (`element:number`, `object-metric`, +`statistic`) spans 3 columns, a chart (`chart`, `object-chart`) spans 6, and every other block +spans all 12. The spans fold by screen width: + +| Screen width | Metric tile | Chart | Other blocks | +|---|---|---|---| +| 1280px and wider | 3 | 6 | 12 | +| 768px to 1279px | 6 | 12 | 12 | +| under 768px | 12 | 12 | 12 | + +So four metric tiles share one row on a wide screen and take two rows on a 1024px one. A +`sidebar` or `aside` region beside `main` is a narrow column, so its blocks always span 12. +On the `dashboard` template each region is a cell of the page's region grid and lays its +own blocks out the same way. A page's `children` (no `regions`) stack one block per row. + +A `page:card` or `page:section` lays its children on the same grid. The container spans 12 +in its region, and its children take their own kind's span, so a card holding four metric +tiles shows them in one row on a wide screen. Studio's page designer shows each block's +span on its card, and on the child cards of a `page:card` or `page:section`. + +## Schema + +```ts +import type { SchemaNode } from '@object-ui/types'; + +interface PageSchema { + type: 'page'; + + // Header + title?: string; // Page title; not drawn on a 'record' page + icon?: string; // Page icon (Lucide icon name) + description?: string; // Page description; not drawn on a 'record' page + + // Page kind. Default 'record': its heading is a `page:header` block. + pageType?: 'record' | 'home' | 'app' | 'utility' | 'list'; + + // Content + children?: SchemaNode | SchemaNode[]; // Main content — one node or a list + + // Styling + className?: string; // Tailwind CSS classes + + // Base properties + id?: string; + visible?: boolean | string | { dialect?: string; source: string }; + testId?: string; +} +``` + +## Examples + +### Dashboard Page + +**Full Dashboard** + +```json +{ + "type": "page", + "pageType": "home", + "title": "Dashboard", + "description": "Monitor your business metrics in real-time", + "children": [ + { + "type": "grid", + "columns": 4, + "gap": 4, + "className": "mb-6", + "children": [ + { + "type": "statistic", + "label": "Revenue", + "value": "$45,231", + "trend": "up", + "description": "+20.1%" + }, + { + "type": "statistic", + "label": "Orders", + "value": "2,350", + "trend": "up", + "description": "+180" + }, + { + "type": "statistic", + "label": "Customers", + "value": "12,543", + "trend": "up", + "description": "+573" + }, + { + "type": "statistic", + "label": "Active", + "value": "573", + "trend": "neutral", + "description": "No change" + } + ] + }, + { + "type": "card", + "title": "Recent Activity", + "children": [ + { + "type": "text", + "content": "Activity content..." + } + ] + } + ] +} +``` + +### Settings Page + +**Settings Layout** + +```json +{ + "type": "page", + "pageType": "utility", + "title": "Settings", + "description": "Manage your account settings and preferences", + "children": [ + { + "type": "card", + "className": "mb-6", + "children": [ + { + "type": "text", + "content": "Profile Settings", + "className": "text-lg font-semibold mb-4" + }, + { + "type": "input", + "label": "Name", + "placeholder": "Enter your name" + }, + { + "type": "input", + "label": "Email", + "placeholder": "Enter your email" + } + ] + }, + { + "type": "card", + "children": [ + { + "type": "text", + "content": "Notifications", + "className": "text-lg font-semibold mb-4" + }, + { + "type": "flex", + "properties": { + "justify": "between", + "children": [ + { + "type": "text", + "content": "Email notifications" + }, + { + "type": "switch" + } + ] + } + } + ] + } + ] +} +``` + +### Content Page + +**Documentation Page** + +```json +{ + "type": "page", + "pageType": "app", + "title": "Getting Started", + "description": "Learn how to use the platform", + "children": [ + { + "type": "container", + "maxWidth": false, + "centered": false, + "padding": 0, + "className": "prose prose-slate", + "children": [ + { + "type": "text", + "content": "Welcome!", + "className": "text-2xl font-bold mb-4" + }, + { + "type": "text", + "content": "This guide will help you get started with the platform.", + "className": "mb-4" + }, + { + "type": "separator", + "className": "my-6" + }, + { + "type": "text", + "content": "Step 1: Setup", + "className": "text-xl font-semibold mb-2" + }, + { + "type": "text", + "content": "Follow these instructions to set up your account..." + } + ] + } + ] +} +``` diff --git a/content/docs/sdk/components/meta.json b/content/docs/sdk/components/meta.json new file mode 100644 index 00000000000..65e4f2b53eb --- /dev/null +++ b/content/docs/sdk/components/meta.json @@ -0,0 +1,14 @@ +{ + "title": "Renderer Reference", + "pages": [ + "basic", + "form", + "layout", + "data-display", + "feedback", + "overlay", + "disclosure", + "navigation", + "complex" + ] +} diff --git a/content/docs/sdk/components/navigation/header-bar.mdx b/content/docs/sdk/components/navigation/header-bar.mdx new file mode 100644 index 00000000000..2e30fcb4523 --- /dev/null +++ b/content/docs/sdk/components/navigation/header-bar.mdx @@ -0,0 +1,281 @@ +--- +title: "Header Bar" +description: "Application header with sidebar trigger and breadcrumb navigation" +--- + +The Header Bar component provides a standard application header with sidebar trigger button and breadcrumb navigation. + +## Basic Usage + +**Simple Header** + +```json +{ + "type": "header-bar", + "crumbs": [ + { + "label": "Home", + "href": "#" + }, + { + "label": "Current Page" + } + ] +} +``` + +## With Multiple Breadcrumbs + +**Deep Navigation** + +```json +{ + "type": "header-bar", + "crumbs": [ + { + "label": "Dashboard", + "href": "#" + }, + { + "label": "Products", + "href": "#" + }, + { + "label": "Electronics", + "href": "#" + }, + { + "label": "Laptops" + } + ] +} +``` + +## Crumb Icons + +A crumb's `icon` is a **kebab-case Lucide icon name**, resolved against lucide's +runtime `icons` record — the same surface `ui:breadcrumb`, `ui:button` and the +`action:*` family resolve against, reached through the same shared resolver. A +name that is not a live key of that record (an unknown, or a retired spelling +such as `layout`) renders **no glyph**, never a fallback glyph and never the +literal name as text. + +**Crumbs With Icons** — `crumbs[].icon` — kebab-case Lucide names resolved to glyphs, the same declared key a `ui:breadcrumb` item carries. + +```json +{ + "type": "header-bar", + "crumbs": [ + { + "label": "Home", + "href": "#", + "icon": "home" + }, + { + "label": "Settings", + "href": "#", + "icon": "settings" + }, + { + "label": "Profile", + "icon": "user" + } + ] +} +``` + +`crumbs` and a `ui:breadcrumb`'s `items` are the **same** declared item shape, +so an icon authored on either draws the same glyph. + +## Styling + +`className` is merged onto the header's root `header` element, after the +header's own classes, through `cn()` (`tailwind-merge`). A utility you write that +conflicts with one of the defaults replaces it; any other class is added. + +```json +{ + "type": "header-bar", + "className": "h-20 sm:h-20 bg-muted/40", + "crumbs": [ + { "label": "Home", "href": "#" }, + { "label": "Reports" } + ] +} +``` + +A conflict is decided per variant. The default height is `h-14`, and `sm:h-16` +from the `sm` breakpoint up, so `h-20` alone replaces only the first: from `sm` +up the header is still `h-16`. Write `sm:h-20` too, as above, for one height at +every width. + +Without `className`, the header renders its default classes unchanged. + +## Schema + +```ts +import type { SchemaNode } from '@object-ui/types'; + +interface BreadcrumbItem { + label: string; // Breadcrumb text + href?: string; // Link URL (optional for last item) + icon?: string; // kebab-case Lucide icon name (e.g. "panels-top-left") + siblings?: Array<{ label: string; href: string }>; // Quick-switch dropdown +} + +interface HeaderBarSchema { + type: 'header-bar'; + + // Breadcrumbs + crumbs?: BreadcrumbItem[]; // Breadcrumb items + + // Search + search?: { + enabled: boolean; // Draws the search input when true + placeholder?: string; // Input placeholder text + shortcut?: string; // Hint in the input from `sm` up, e.g. "⌘K" (text only) + }; + + // Right side, after the search input + actions?: SchemaNode[]; // Nodes rendered in order + rightContent?: SchemaNode; // One node, rendered after `actions` + + // Styling + className?: string; // Tailwind classes, merged onto the root header element + + // Base properties + id?: string; + visible?: boolean | string | { dialect?: string; source: string }; + testId?: string; +} +``` + +### Retired keys + +`title`, `logo`, `nav`, `left`, `center`, `right`, `sticky` and `height` are retired +on this node (objectui#10387, alongside `variant` from objectui#10286). The renderer +never read any of them, so an authored value drew the same header as leaving it out. +They now fail validation by name, and the TypeScript declaration types each one +`never`. Delete the key: + +- `title` — name the current page with the last entry of `crumbs`. +- `logo` — put brand content in `rightContent` or `actions`. +- `nav` — put links in `crumbs`, or use a `navigation-menu` / `sidebar` node. +- `left` / `center` — put custom content in `rightContent` or `actions`. +- `right` — use `actions` (a node list) or `rightContent` (one node). +- `sticky` — make the parent layout sticky; the header itself never was. +- `height` — set the height with `className` instead (see [Styling](#styling)). By + default it is `h-14`, and `sm:h-16` from the `sm` breakpoint up. + +## Examples + +### Application Header + +**App Navigation** + +```json +{ + "type": "header-bar", + "crumbs": [ + { + "label": "Projects", + "href": "#" + }, + { + "label": "Web App" + } + ] +} +``` + +### Settings Navigation + +**Settings Path** + +```json +{ + "type": "header-bar", + "crumbs": [ + { + "label": "Settings", + "href": "#" + }, + { + "label": "Account", + "href": "#" + }, + { + "label": "Profile" + } + ] +} +``` + +### Admin Panel + +**Admin Breadcrumbs** + +```json +{ + "type": "header-bar", + "crumbs": [ + { + "label": "Admin", + "href": "#" + }, + { + "label": "Users", + "href": "#" + }, + { + "label": "Edit User", + "href": "#" + }, + { + "label": "John Doe" + } + ] +} +``` + +## Usage Notes + +- The Header Bar component includes a sidebar trigger button that works with the Sidebar component +- The last breadcrumb item is typically rendered as plain text (current page) +- A crumb may carry an `icon` (see [Crumb Icons](#crumb-icons)) and `siblings`, which turns it into a quick-switch dropdown +- All other breadcrumb items should have an `href` to enable navigation +- The header is `h-14` (56px) tall, and `h-16` (64px) from the `sm` breakpoint up; `className` overrides either (see [Styling](#styling)) +- Includes a vertical separator between the sidebar trigger and breadcrumbs + +## Integration + +This component is designed to work with the sidebar layout pattern: + +```plaintext +{ + type: 'div', + children: [ + { type: 'sidebar', /* sidebar config */ }, + { + type: 'div', + className: 'flex-1', + children: [ + { + type: 'header-bar', + crumbs: [ + { label: 'Home', href: '#' }, + { label: 'Current' } + ] + }, + { + type: 'main', + className: 'p-6', + children: [ + // Main content + ] + } + ] + } + ] +} +``` diff --git a/content/docs/sdk/components/navigation/meta.json b/content/docs/sdk/components/navigation/meta.json new file mode 100644 index 00000000000..9117a04b922 --- /dev/null +++ b/content/docs/sdk/components/navigation/meta.json @@ -0,0 +1,6 @@ +{ + "title": "Navigation", + "pages": [ + "header-bar" + ] +} diff --git a/content/docs/sdk/components/overlay/alert-dialog.mdx b/content/docs/sdk/components/overlay/alert-dialog.mdx new file mode 100644 index 00000000000..330032c39e2 --- /dev/null +++ b/content/docs/sdk/components/overlay/alert-dialog.mdx @@ -0,0 +1,115 @@ +--- +title: "Alert Dialog" +description: "A modal dialog that interrupts the user with important content and expects a response" +--- + +The Alert Dialog component is used to interrupt the user with important content and require a confirmation before proceeding. + +## Basic Usage + +**Basic Alert Dialog** + +```json +{ + "type": "alert-dialog", + "title": "Are you sure?", + "description": "This action cannot be undone.", + "trigger": { + "type": "button", + "label": "Delete Account" + }, + "cancelText": "Cancel", + "actionText": "Continue", + "actionVariant": "destructive" +} +``` + +## Variants + +**Destructive Action** + +```json +{ + "type": "alert-dialog", + "title": "Confirm Deletion", + "description": "This will permanently delete your account and all associated data.", + "trigger": { + "type": "button", + "label": "Delete", + "variant": "destructive" + }, + "cancelText": "Cancel", + "actionText": "Delete", + "actionVariant": "destructive" +} +``` + +**Confirmation Dialog** + +```json +{ + "type": "alert-dialog", + "title": "Save Changes?", + "description": "You have unsaved changes. Do you want to save them before leaving?", + "trigger": { + "type": "button", + "label": "Close", + "variant": "outline" + }, + "cancelText": "Don't Save", + "actionText": "Save" +} +``` + +## Schema + +```ts +import type { SchemaNode } from '@object-ui/types'; + +interface AlertDialogSchema { + type: 'alert-dialog'; + title?: string; // Dialog title + description?: string; // Dialog description + + // Trigger + trigger?: SchemaNode | SchemaNode[]; // Component that triggers the dialog + + // Body and footer + content?: SchemaNode | SchemaNode[]; // Rendered between the header and the footer + cancelText?: string; // Cancel button label; no cancel button when omitted + actionText?: string; // Confirm button label; no confirm button when omitted + actionVariant?: 'default' | 'destructive'; // Confirm button variant; red confirm when 'destructive' + + // Open state + defaultOpen?: boolean; // Initial state when uncontrolled (default: false) + open?: boolean; // Controlled open state + + // Styling + className?: string; +} +``` + +The footer is driven by the two label keys: the cancel button renders only when `cancelText` is set and the confirm button only when `actionText` is set — neither has a renderer default (the designer palette seeds `Cancel` / `Continue`). The confirm button's click handler, `onAction`, is a runtime slot a React host supplies through the TypeScript interface; it has no JSON spelling, and the validator refuses it by name. + +The confirm button's look is `actionVariant`. Omit it and the button stays the default primary; write `'destructive'` for the red confirm a delete dialog wants. Those are the two values it takes — not the six a `button` node takes — because the renderer applies the variant as a class override on top of the primitive's built-in styling, and the remaining upstream variants set no background and/or no text colour, so the default's would show through. The cancel button is always `outline` and has no variant key. (The older spelling `confirmVariant` is retired and refused by name — it never reached the DOM.) + +## Examples + +### With Custom Actions + +**Custom Actions** + +```json +{ + "type": "alert-dialog", + "title": "Confirm Action", + "description": "Are you sure you want to proceed with this action?", + "trigger": { + "type": "button", + "label": "Proceed", + "variant": "default" + }, + "cancelText": "Go Back", + "actionText": "Yes, Continue" +} +``` diff --git a/content/docs/sdk/components/overlay/context-menu.mdx b/content/docs/sdk/components/overlay/context-menu.mdx new file mode 100644 index 00000000000..5772cdb798b --- /dev/null +++ b/content/docs/sdk/components/overlay/context-menu.mdx @@ -0,0 +1,96 @@ +--- +title: "Context Menu" +description: "Display a menu on right-click or long-press" +--- + +The Context Menu component displays a menu when right-clicking on an element. + +## Basic Usage + +**Basic Context Menu** + +```json +{ + "type": "context-menu", + "trigger": { + "type": "card", + "children": "Right-click here", + "className": "p-8 text-center border-dashed" + }, + "items": [ + { + "label": "Copy", + "icon": "copy" + }, + { + "label": "Cut", + "icon": "scissors" + }, + { + "label": "Paste", + "icon": "clipboard" + }, + { + "separator": true + }, + { + "label": "Delete", + "icon": "trash" + } + ] +} +``` + +## Icons + +An item's `icon` is a **kebab-case Lucide icon name**, resolved against lucide's +runtime `icons` record — the same surface `ui:button`, `ui:dropdown-menu` and the +`action:*` family resolve against. It is read on **both** arms: a leaf item and a +submenu trigger. A name that is not a live key of that record (an unknown or a +retired spelling such as `edit`) renders **no glyph**, never a fallback glyph and +never the literal name as text. + +## Dividers + +An item is either a command or a divider between groups of commands — never +both, and a divider never carries a `label` (objectui#6523). Author a divider +as `{ "separator": true }`; the retired `{ "type": "separator" }` spelling is +refused at parse time rather than silently ignored. + +## Schema + +```ts +import type { SchemaNode } from '@object-ui/types'; + +interface ContextMenuCommandItem { + label: string; + icon?: string; // kebab-case Lucide icon name (e.g. "trash") + shortcut?: string; // e.g. "Ctrl+C" — a single string, not an array + disabled?: boolean; + // RUNTIME SLOT (objectui#6124): a React host supplies it; JSON refuses it + // by name. Author behaviour as a node type, e.g. an action:button node. + onClick?: () => void; // Click handler + children?: ContextMenuItem[]; // Submenu items — drawn as a nested submenu +} + +interface ContextMenuDividerItem { + separator: true; // renders as a divider — no label +} + +type ContextMenuItem = ContextMenuCommandItem | ContextMenuDividerItem; + +interface ContextMenuSchema { + type: 'context-menu'; + trigger?: SchemaNode | SchemaNode[]; // Trigger element — a placeholder renders when absent + items: ContextMenuItem[]; // Menu items + triggerClassName?: string; // Classes for the right-clickable area + contentClassName?: string; // Classes for the menu panel + modal?: boolean; // Forwarded to the Radix root + className?: string; +} +``` + +The item's `onClick` is a runtime slot a React host supplies through the +TypeScript `MenuItem` interface; it has no JSON spelling, and the validator +refuses it by name on the item. The menu schema itself declares no authorable +event slot either, so there is no menu-level `onSelect`. diff --git a/content/docs/sdk/components/overlay/dialog.mdx b/content/docs/sdk/components/overlay/dialog.mdx new file mode 100644 index 00000000000..a574d1fdd8a --- /dev/null +++ b/content/docs/sdk/components/overlay/dialog.mdx @@ -0,0 +1,41 @@ +--- +title: "Dialog" +description: "Modal dialog overlay for focused interactions" +--- + +## Examples + +**Dialog Trigger** + +```json +{ + "type": "dialog", + "trigger": { + "type": "button", + "label": "Open Dialog" + }, + "title": "Dialog Title", + "description": "Dialog description", + "content": [ + { + "type": "text", + "content": "Dialog content goes here" + } + ] +} +``` + +## Schema + +```ts +import type { SchemaNode } from '@object-ui/types'; + +interface DialogSchema { + type: 'dialog'; + trigger: SchemaNode | SchemaNode[]; + title?: string; + description?: string; + content?: SchemaNode | SchemaNode[]; + footer?: SchemaNode[]; +} +``` diff --git a/content/docs/sdk/components/overlay/drawer.mdx b/content/docs/sdk/components/overlay/drawer.mdx new file mode 100644 index 00000000000..6a9c53e7d0e --- /dev/null +++ b/content/docs/sdk/components/overlay/drawer.mdx @@ -0,0 +1,39 @@ +--- +title: "Drawer" +description: "Sliding panel from edge of screen" +--- + +## Examples + +**Basic Drawer** + +```json +{ + "type": "drawer", + "trigger": { + "type": "button", + "label": "Open Drawer" + }, + "title": "Drawer Title", + "content": [ + { + "type": "text", + "content": "Drawer content" + } + ] +} +``` + +## Schema + +```ts +import type { SchemaNode } from '@object-ui/types'; + +interface DrawerSchema { + type: 'drawer'; + trigger: SchemaNode | SchemaNode[]; + title?: string; + content?: SchemaNode | SchemaNode[]; + side?: 'left' | 'right' | 'top' | 'bottom'; +} +``` diff --git a/content/docs/sdk/components/overlay/dropdown-menu.mdx b/content/docs/sdk/components/overlay/dropdown-menu.mdx new file mode 100644 index 00000000000..d385ff18040 --- /dev/null +++ b/content/docs/sdk/components/overlay/dropdown-menu.mdx @@ -0,0 +1,115 @@ +--- +title: "Dropdown Menu" +description: "Display a menu of actions or options triggered by a button" +--- + +The Dropdown Menu component displays a list of actions or options when triggered. + +## Basic Usage + +**Basic Dropdown Menu** + +```json +{ + "type": "dropdown-menu", + "trigger": { + "type": "button", + "label": "Open Menu" + }, + "items": [ + { + "label": "Profile" + }, + { + "label": "Settings" + }, + { + "separator": true + }, + { + "label": "Logout" + } + ] +} +``` + +## With Icons + +**With Icons** + +```json +{ + "type": "dropdown-menu", + "trigger": { + "type": "button", + "label": "Actions", + "icon": "ellipsis" + }, + "items": [ + { + "label": "Edit", + "icon": "square-pen" + }, + { + "label": "Copy", + "icon": "copy" + }, + { + "label": "Delete", + "icon": "trash" + } + ] +} +``` + +## Icons + +An item's `icon` is a **kebab-case Lucide icon name**, resolved against lucide's +runtime `icons` record — the same surface `ui:button` and the `action:*` family +resolve against. A name that is not a live key of that record (an unknown or a +retired spelling such as `edit`) renders **no glyph**, never a fallback glyph +and never the literal name as text. + +## Dividers + +An item is either a command or a divider between groups of commands — never +both, and a divider never carries a `label` (objectui#6523). Author a divider +as `{ "separator": true }`; the retired `{ "type": "separator" }` spelling is +refused at parse time rather than silently ignored. + +## Schema + +```ts +import type { SchemaNode } from '@object-ui/types'; + +interface DropdownMenuCommandItem { + label: string; + icon?: string; // kebab-case Lucide icon name (e.g. "square-pen") + shortcut?: string; // e.g. "Ctrl+S" — a single string, not an array + disabled?: boolean; + // RUNTIME SLOT (objectui#6124): a React host supplies it; JSON refuses it + // by name. Author behaviour as a node type, e.g. an action:button node. + onClick?: () => void; // Click handler + children?: DropdownMenuItem[]; // Submenu items — drawn as a nested submenu +} + +interface DropdownMenuDividerItem { + separator: true; // renders as a divider — no label +} + +type DropdownMenuItem = DropdownMenuCommandItem | DropdownMenuDividerItem; + +interface DropdownMenuSchema { + type: 'dropdown-menu'; + trigger: SchemaNode | SchemaNode[]; // Trigger component + items: DropdownMenuItem[]; // Menu items + + // Styling + className?: string; +} +``` + +The item's `onClick` is a runtime slot a React host supplies through the +TypeScript `MenuItem` interface; it has no JSON spelling, and the validator +refuses it by name on the item. The menu schema itself declares no authorable +event slot either, so there is no menu-level `onSelect`. diff --git a/content/docs/sdk/components/overlay/hover-card.mdx b/content/docs/sdk/components/overlay/hover-card.mdx new file mode 100644 index 00000000000..87eb3127956 --- /dev/null +++ b/content/docs/sdk/components/overlay/hover-card.mdx @@ -0,0 +1,42 @@ +--- +title: "Hover Card" +description: "Display rich content in a popup when hovering over a trigger" +--- + +The Hover Card component displays rich content when hovering over an element. + +## Basic Usage + +**Basic Hover Card** + +```json +{ + "type": "hover-card", + "trigger": { + "type": "button", + "variant": "link", + "label": "Hover over me" + }, + "content": { + "type": "text", + "content": "This is the hover card content" + } +} +``` + +## Schema + +```ts +import type { SchemaNode } from '@object-ui/types'; + +interface HoverCardSchema { + type: 'hover-card'; + trigger: SchemaNode | SchemaNode[]; // Trigger element + content: SchemaNode | SchemaNode[]; // Card content + side?: 'top' | 'right' | 'bottom' | 'left'; + align?: 'start' | 'center' | 'end'; + openDelayMs?: number; // Delay before opening (ms) + closeDelayMs?: number; // Delay before closing (ms) + className?: string; +} +``` diff --git a/content/docs/sdk/components/overlay/menubar.mdx b/content/docs/sdk/components/overlay/menubar.mdx new file mode 100644 index 00000000000..3bf9f95b12d --- /dev/null +++ b/content/docs/sdk/components/overlay/menubar.mdx @@ -0,0 +1,126 @@ +--- +title: "Menubar" +description: "Horizontal menu bar with dropdown menus" +--- + +The Menubar component provides a horizontal menu bar similar to desktop applications. + +## Basic Usage + +**Application Menubar** + +```json +{ + "type": "menubar", + "menus": [ + { + "label": "File", + "items": [ + { + "label": "New Tab" + }, + { + "label": "New Window" + }, + { + "separator": true + }, + { + "label": "Exit" + } + ] + }, + { + "label": "Edit", + "items": [ + { + "label": "Undo" + }, + { + "label": "Redo" + }, + { + "separator": true + }, + { + "label": "Cut" + }, + { + "label": "Copy" + }, + { + "label": "Paste" + } + ] + }, + { + "label": "View", + "items": [ + { + "label": "Zoom In" + }, + { + "label": "Zoom Out" + }, + { + "label": "Reset Zoom" + } + ] + } + ] +} +``` + +## Icons + +An item's `icon` is a **kebab-case Lucide icon name**, resolved against lucide's +runtime `icons` record — the same surface `ui:dropdown-menu`, `ui:context-menu` +and the `action:*` family resolve against. It is read on **every** arm that draws +an item: a top-level item, a submenu trigger, and an item inside a submenu, at any +depth. A name that is not a live key of that record (an unknown or a retired +spelling such as `edit`) renders **no glyph**, never a fallback glyph and never +the literal name as text. + +## Dividers + +An item is either a command or a divider between groups of commands — never +both, and a divider never carries a `label` (objectui#6523). Author a divider +as `{ "separator": true }`; the retired `{ "type": "separator" }` spelling is +refused at parse time rather than silently ignored. + +## Schema + +```ts +interface MenubarCommandItem { + label: string; + icon?: string; + shortcut?: string; // e.g. "Ctrl+T" — a single string, not an array + disabled?: boolean; + // RUNTIME SLOT (objectui#6124): a React host supplies it; JSON refuses it + // by name. Author behaviour as a node type, e.g. an action:button node. + onClick?: () => void; // Click handler + children?: MenubarItem[]; // Submenu items — drawn as a nested submenu +} + +interface MenubarDividerItem { + separator: true; // renders as a divider — no label +} + +type MenubarItem = MenubarCommandItem | MenubarDividerItem; + +interface MenubarMenu { + label: string; + items: MenubarItem[]; +} + +interface MenubarSchema { + type: 'menubar'; + menus?: MenubarMenu[]; // Menu definitions + className?: string; +} +``` + +The item's `onClick` is a runtime slot a React host supplies through the +TypeScript `MenuItem` interface; it has no JSON spelling, and the validator +refuses it by name on the item. The menu schema itself declares no authorable +event slot either, so there is no menu-level `onSelect`. diff --git a/content/docs/sdk/components/overlay/meta.json b/content/docs/sdk/components/overlay/meta.json new file mode 100644 index 00000000000..54855a1bfaf --- /dev/null +++ b/content/docs/sdk/components/overlay/meta.json @@ -0,0 +1,15 @@ +{ + "title": "Overlay", + "pages": [ + "dialog", + "drawer", + "tooltip", + "popover", + "alert-dialog", + "sheet", + "hover-card", + "context-menu", + "dropdown-menu", + "menubar" + ] +} diff --git a/content/docs/sdk/components/overlay/popover.mdx b/content/docs/sdk/components/overlay/popover.mdx new file mode 100644 index 00000000000..7a19fa9bc3a --- /dev/null +++ b/content/docs/sdk/components/overlay/popover.mdx @@ -0,0 +1,37 @@ +--- +title: "Popover" +description: "Floating content panel" +--- + +## Examples + +**Basic Popover** + +```json +{ + "type": "popover", + "trigger": { + "type": "button", + "label": "Open Popover" + }, + "content": [ + { + "type": "text", + "content": "Popover content" + } + ] +} +``` + +## Schema + +```ts +import type { SchemaNode } from '@object-ui/types'; + +interface PopoverSchema { + type: 'popover'; + trigger: SchemaNode | SchemaNode[]; + content?: SchemaNode | SchemaNode[]; + side?: 'top' | 'right' | 'bottom' | 'left'; +} +``` diff --git a/content/docs/sdk/components/overlay/sheet.mdx b/content/docs/sdk/components/overlay/sheet.mdx new file mode 100644 index 00000000000..c79f22432a2 --- /dev/null +++ b/content/docs/sdk/components/overlay/sheet.mdx @@ -0,0 +1,80 @@ +--- +title: "Sheet" +description: "Slide-in panel from the edge of the screen" +--- + +The Sheet component displays content in a panel that slides in from the edge of the screen. + +## Basic Usage + +**Basic Sheet** + +```json +{ + "type": "sheet", + "trigger": { + "type": "button", + "label": "Open Sheet" + }, + "title": "Sheet Title", + "description": "Sheet description goes here.", + "content": { + "type": "text", + "content": "Sheet content" + } +} +``` + +## Sides + +**Left Side** + +```json +{ + "type": "sheet", + "side": "left", + "trigger": { + "type": "button", + "label": "Left" + }, + "title": "Left Sheet", + "content": { + "type": "text", + "content": "Content" + } +} +``` + +**Right Side** + +```json +{ + "type": "sheet", + "side": "right", + "trigger": { + "type": "button", + "label": "Right" + }, + "title": "Right Sheet", + "content": { + "type": "text", + "content": "Content" + } +} +``` + +## Schema + +```ts +import type { SchemaNode } from '@object-ui/types'; + +interface SheetSchema { + type: 'sheet'; + trigger?: SchemaNode | SchemaNode[]; // Trigger component + title?: string; // Sheet title + description?: string; // Sheet description + content?: SchemaNode | SchemaNode[]; // Sheet content + side?: 'left' | 'right' | 'top' | 'bottom'; + className?: string; +} +``` diff --git a/content/docs/sdk/components/overlay/tooltip.mdx b/content/docs/sdk/components/overlay/tooltip.mdx new file mode 100644 index 00000000000..053a0c81602 --- /dev/null +++ b/content/docs/sdk/components/overlay/tooltip.mdx @@ -0,0 +1,48 @@ +--- +title: "Tooltip" +description: "Contextual information on hover" +--- + +## Examples + +**Basic Tooltip** + +```json +{ + "type": "tooltip", + "content": "Helpful information", + "trigger": [ + { + "type": "button", + "label": "Hover me" + } + ] +} +``` + +## Schema + +```ts +import type { SchemaNode } from '@object-ui/types'; + +interface TooltipSchema { + type: 'tooltip'; + trigger?: SchemaNode | SchemaNode[]; // Element the tooltip attaches to + content?: string; // Tooltip text — read first + children?: SchemaNode | SchemaNode[]; // Rich content — the fallback half + side?: 'top' | 'right' | 'bottom' | 'left'; + align?: 'start' | 'center' | 'end'; + delayDurationMs?: number; // Delay before showing (ms) +} +``` + +The renderer reads `schema.trigger` for the trigger and +`schema.content || children` for the body. `content` is text only: a node or +a list of nodes there is refused by validation, because the renderer places +`content` directly in the tooltip body and a node there fails to render +(objectui#10295). Author rich content under `children` instead. When both are +authored, a non-empty `content` wins and `children` is not rendered. + +The trigger is authored under `trigger`, not `children` (objectui#6939). A +tooltip that puts its trigger under `children` validates, but paints an empty +trigger: `children` is the tooltip's rich-content slot, not its trigger. diff --git a/content/docs/sdk/core/meta.json b/content/docs/sdk/core/meta.json new file mode 100644 index 00000000000..464f8116743 --- /dev/null +++ b/content/docs/sdk/core/meta.json @@ -0,0 +1,6 @@ +{ + "title": "Core", + "pages": [ + "schema-renderer" + ] +} diff --git a/content/docs/sdk/core/schema-renderer.mdx b/content/docs/sdk/core/schema-renderer.mdx new file mode 100644 index 00000000000..0d3e2725638 --- /dev/null +++ b/content/docs/sdk/core/schema-renderer.mdx @@ -0,0 +1,251 @@ +--- +title: "SchemaRenderer" +description: "Core component for rendering JSON schemas into React components" +--- + +The SchemaRenderer is the heart of ObjectUI. It takes a JSON schema and dynamically renders the appropriate React components based on the schema's `type` field. + +## How It Works + +The SchemaRenderer uses the Component Registry to look up the appropriate component for each schema type and renders it with the provided props. + +```tsx +import { SchemaRenderer } from '@object-ui/react'; + + +``` + +## Basic Example + +**Schemarenderer In Action** + +```json +{ + "type": "card", + "title": "Dynamic Card", + "children": [ + { + "type": "text", + "content": "This entire UI is defined in JSON and rendered by SchemaRenderer", + "className": "text-sm text-muted-foreground" + } + ] +} +``` + +## Nested Schemas + +The SchemaRenderer automatically handles nested schemas: + +**Nested Schema Example** + +```json +{ + "type": "stack", + "gap": 4, + "children": [ + { + "type": "card", + "title": "Parent Component", + "children": [ + { + "type": "flex", + "properties": { + "gap": 2, + "children": [ + { + "type": "badge", + "label": "Nested" + }, + { + "type": "badge", + "label": "Components", + "variant": "outline" + } + ] + } + } + ] + }, + { + "type": "card", + "title": "Sibling Component", + "children": [ + { + "type": "text", + "content": "All rendered from a single schema tree" + } + ] + } + ] +} +``` + +## Schema Structure + +```ts +// abridged: `BaseSchema` in @object-ui/types carries the full member list +interface BaseSchema { + type: string; // Component type (e.g., 'card', 'button', 'text') + id?: string; // Unique identifier + className?: string; // Tailwind CSS classes + children?: BaseSchema | BaseSchema[]; // Child components +} +``` + +Every node type extends `BaseSchema` with its own keys, and none of them +carries an index signature (objectui#8347): in a typed literal, a key the +node's type does not declare, a misspelling included, is a compile error. +A child in a slot is checked against its own `type`'s declaration. + +## Error Handling + +When an unknown component type is encountered, SchemaRenderer displays a helpful error message: + +**Unknown Component Type** + +```json +{ + "type": "unknown-component", + "someData": "test" +} +``` + +## Component Registry + +The SchemaRenderer uses the Component Registry to resolve component types: + +```tsx +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRenderer } from '@object-ui/react'; +import type { BaseSchema } from '@object-ui/types'; + +function MyWidgetComponent() { + return
My widget
; +} + +// Register a custom component +ComponentRegistry.register('my-widget', MyWidgetComponent); + +// Declare its node type. A node slot and the `schema` prop take the node types +// `@object-ui/types` declares, plus each type an application adds to +// `CustomNodeRegistry`, so the node below is checked against `MyWidgetSchema`. +interface MyWidgetSchema extends BaseSchema { + type: 'my-widget'; + customProp?: string; +} + +declare module '@object-ui/types' { + interface CustomNodeRegistry { + 'my-widget': MyWidgetSchema; + } +} + +// Now you can use it in schemas + +``` + +## Usage in Applications + +### Simple Rendering + +```tsx +import { SchemaRenderer } from '@object-ui/react'; +import type { PageNodeSchema } from '@object-ui/types'; + +function App() { + const schema: PageNodeSchema = { + type: 'page', + pageType: 'app', + title: 'Dashboard', + children: [ + { type: 'text', content: 'Welcome to your dashboard' } + ] + }; + + return ; +} +``` + +### Dynamic Schemas + +```tsx +import { SchemaRenderer } from '@object-ui/react'; +import { useState, useEffect } from 'react'; + +function DynamicPage() { + const [schema, setSchema] = useState(null); + + useEffect(() => { + // Fetch schema from API + fetch('/api/page-schema') + .then(res => res.json()) + .then(setSchema); + }, []); + + if (!schema) return
Loading...
; + + return ; +} +``` + +### With Props + +You can pass additional props to the rendered component: + +```plaintext + console.log('Clicked!')} + className="extra-classes" +/> +``` + +## Data Attributes + +SchemaRenderer automatically adds data attributes for debugging: + +- `data-obj-id`: The schema's id field +- `data-obj-type`: The schema's type field + +These can be used for debugging, testing, or styling: + +```css +[data-obj-type="card"] { + /* Target all card components */ +} +``` + +## Advanced Features + +### String Rendering + +If a schema is just a string, it's rendered as text: + +```plaintext + +// Renders: Hello World +``` + +### Null/Undefined Handling + +Null or undefined schemas render nothing: + +```plaintext + +// Renders: null +``` + +## Related + +- [Component Registry](/docs/sdk/guide/component-registry) - Register custom components +- [Schema Rendering Guide](/docs/sdk/guide/schema-rendering) - Deep dive into schema rendering diff --git a/content/docs/sdk/guide/architecture-overview.mdx b/content/docs/sdk/guide/architecture-overview.mdx new file mode 100644 index 00000000000..c85aeec8e6b --- /dev/null +++ b/content/docs/sdk/guide/architecture-overview.mdx @@ -0,0 +1,338 @@ +--- +title: Architecture Overview +description: Deep dive into the ObjectUI 3-layer architecture, data flow, plugin system, state management, and action system. +--- + +# Architecture Overview + +ObjectUI is a **Server-Driven UI (SDUI) engine** that transforms JSON schemas into fully interactive React interfaces built on Shadcn/Tailwind. This document covers the internal architecture, data flow, and extension points. + +## The 3-Layer Architecture + +ObjectUI enforces a strict separation across three layers. Each layer has clear constraints on what it may import and what it may contain. + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Layer 1: @objectstack/spec (The Protocol) │ +│ Pure TypeScript type definitions — 12 export modules │ +│ ❌ No runtime code. No React. No dependencies. │ +└──────────────────────────────┬──────────────────────────────────────┘ + │ imports (never redefines) +┌──────────────────────────────▼──────────────────────────────────────┐ +│ Layer 2: @object-ui/types (The Bridge) │ +│ Re-exports spec types + ObjectUI-specific schemas │ +│ ❌ No runtime code. Zero runtime dependencies. │ +└──────────────────────────────┬──────────────────────────────────────┘ + │ consumed by +┌──────────────────────────────▼──────────────────────────────────────┐ +│ Layer 3: Implementations (The Runtime) │ +│ core, react, components (91+), fields (35+), plugins, etc. │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### Layer 1 — `@objectstack/spec` (The Protocol) + +The upstream JSON specification for all ObjectStack products. ObjectUI **imports** these types but never redefines them. Located externally and consumed as `@objectstack/spec`; the range each package installs is declared in that package's own `package.json`, under `dependencies`. + +### Layer 2 — `@object-ui/types` (The Bridge) + +Lives in `packages/types/`. Re-exports spec types and adds ObjectUI-specific schemas (component schemas, widget props, field props). Has **zero** runtime dependencies — only type-level imports of `@objectstack/spec` and `zod` for validation schemas. Marked `sideEffects: false`. + +### Layer 3 — Implementations (The Runtime) + +All packages that ship runnable code: `core`, `react`, `components`, `fields`, `layout`, `i18n`, `auth`, `permissions`, and every `plugin-*` package. + +## Package Dependency Graph + +``` +@objectstack/spec + │ + ▼ +@object-ui/types ◄─────────────────────────────────────┐ + │ │ + ▼ │ +@object-ui/core │ + │ (registry, evaluator, actions, validation) │ + ▼ │ +@object-ui/react ──────► @object-ui/i18n │ + │ (SchemaRenderer, hooks, contexts) │ + ├──────────────────────────────────────┐ │ + ▼ ▼ │ +@object-ui/components @object-ui/fields │ + │ (91+ Shadcn atoms) (35+ inputs) │ + ▼ ▼ │ +@object-ui/layout @object-ui/plugin-* │ + (AppShell, Header, (grid, kanban, charts, │ + Sidebar, routing) dashboard, form, etc.) │ + │ │ + └─────────────────┘ + (all packages import types) +``` + +**Strict rules:** + +| Package | May Import | Must NOT Import | +|---------|-----------|-----------------| +| `types` | `@objectstack/spec` | Any runtime package | +| `core` | `types`, `lodash`, `zod` | React, any UI library | +| `react` | `core`, `types`, `i18n` | Plugin packages directly | +| `components` | `types` (for props) | `core`, `react` | +| `fields` | `types` (for `FieldWidgetProps`) | `core` business logic | +| `plugin-*` | Any package | Other plugins | + +## Data Flow: Schema → Screen + +The rendering pipeline transforms a JSON schema into a live React component tree: + +``` + JSON Schema (from API or static file) + │ + ▼ + ┌─────────────┐ ┌──────────────────┐ + │ SchemaRenderer │───►│ ExpressionEvaluator │ + │ (packages/ │ │ Resolves ${...} │ + │ react/src/) │ │ expressions │ + └──────┬────────┘ └──────────────────┘ + │ + ▼ + ┌─────────────────┐ + │ ComponentRegistry │ ← registry.resolve(schema.type) + │ (packages/core/ │ + │ src/registry/) │ + └──────┬──────────┘ + │ + ▼ + ┌──────────────────┐ + │ React Component │ ← Wrapped in ErrorBoundary + │ (Button, Grid, │ with ARIA attributes + │ Kanban, etc.) │ + └──────────────────┘ +``` + +1. **SchemaRenderer** (`packages/react/src/SchemaRenderer.tsx`) receives a JSON schema object. +2. It evaluates dynamic expressions via `ExpressionEvaluator` (`packages/core/src/evaluator/`). +3. It looks up the component type in the `ComponentRegistry` (`packages/core/src/registry/Registry.ts`). +4. The matched component renders with schema props, wrapped in a per-component `ErrorBoundary` with ARIA accessibility attributes (`aria-label`, `aria-describedby`, `role`). + +## The Plugin System + +Plugins are self-contained packages that register heavy or complex views (grids, kanbans, charts). They follow a consistent pattern defined in `packages/core/src/registry/PluginSystem.ts`. + +### Plugin Lifecycle + +``` + import 'plugin-kanban' + │ + ▼ + PluginSystem.load(plugin) + │ ├─ Check dependencies + │ ├─ Prevent duplicate loading + │ └─ Call plugin.register(scope) + ▼ + PluginScope.registerComponent(type, Component, meta) + │ └─ Auto-prefixes with plugin namespace + ▼ + ComponentRegistry.register('object-kanban', ObjectKanbanRenderer, { + namespace: 'plugin-kanban', + category: 'view' + }) +``` + +### Lazy Loading + +Plugins use `React.lazy()` wrapped by `LazyPluginLoader` (`packages/react/src/LazyPluginLoader.tsx`): + +- **Retry logic**: 2 retries with 1-second delay by default +- **Custom fallbacks**: Loading skeleton + error boundary +- **Tree-shaking**: Plugins are code-split from the initial bundle + +### Plugin Registration Pattern + +Every plugin follows the same structure (see `packages/plugin-kanban/`, `packages/plugin-grid/`): + +```typescript +// 1. Lazy-load the heavy implementation +const LazyKanban = React.lazy(() => import('./KanbanImpl')); + +// 2. Define a thin wrapper with Suspense +const KanbanRenderer: React.FC = ({ schema }) => ( + }> + + +); + +// 3. Register in the global registry +ComponentRegistry.register('object-kanban', KanbanRenderer, { + namespace: 'plugin-kanban', + label: 'Object Kanban', + category: 'view', + inputs: [/* schema config */] +}); +``` + +Scaffold a new plugin with `npx @object-ui/create-plugin`. + +## State Management + +ObjectUI uses **React Context** for all state management, with contexts scoped to specific concerns: + +### Core Contexts (`packages/react/src/context/`) + +| Context | Purpose | +|---------|---------| +| `SchemaRendererContext` | Data scope + debug mode for the rendering tree | +| `ActionContext` | Action runner instance for handling user interactions | +| `ThemeContext` | Theme tokens and dark/light mode | +| `NotificationContext` | Toast and notification system | +| `DndContext` | Drag-and-drop state for sortable views | + +### Domain Contexts (separate packages) + +| Context | Package | Purpose | +|---------|---------|---------| +| `AuthContext` | `packages/auth/` | Authentication state | +| `PermissionContext` | `packages/permissions/` | RBAC/ABAC permissions | +| `I18nProvider` | `packages/i18n/` | Locale and translations | + +Contexts are composed at the application root and consumed by plugins and components via hooks (e.g., `useActionRunner`, `useExpression`, `useViewData`). + +## Expression Evaluation + +The expression engine lives in `packages/core/src/evaluator/` and powers dynamic schemas: + +### Components + +- **`ExpressionEvaluator.ts`** — Main engine: parses and evaluates `${...}` template expressions. +- **`ExpressionContext.ts`** — Variable scope: provides `data`, `user`, `params` to expressions. +- **`ExpressionCache.ts`** — Caches parsed expressions for repeated evaluations. +- **`FormulaFunctions.ts`** — Built-in functions available inside expressions. + +### Expression Types + +```json +{ + "visible": "${data.role === 'admin'}", + "label": "Hello, ${data.user.name}!", + "disabled": "${data.status !== 'draft'}", + "className": "${data.priority === 'high' ? 'text-red-500' : 'text-gray-500'}" +} +``` + +Expressions support: +- **String interpolation**: `"Welcome, ${data.name}"` +- **Conditionals**: `"${data.age > 18}"` +- **Ternary operators**: `"${data.active ? 'Yes' : 'No'}"` +- **Variable references**: `data.*`, `user.*`, `params.*` + +## Action System + +The action system (`packages/core/src/actions/`) handles user interactions defined in schemas. + +### ActionRunner (`ActionRunner.ts`) + +Executes action schemas and returns directives: + +``` + User Click → Schema Action + │ + ▼ + ActionRunner.execute(action, context) + │ ├─ Check confirmation dialog + │ ├─ Evaluate conditions + │ └─ Execute by action type + ▼ + ActionResult + ├─ reload: boolean + ├─ redirect: string + ├─ modal: SchemaObject + └─ toast: { message, type } +``` + +### Supported Action Types + +| Type | Description | +|------|-------------| +| `script` | Dispatch a named/registered script action — an `action.body` always executes **server-side** | +| `url` | Navigate to a URL | +| `api` | Make an API request (AJAX) | +| `modal` | Open a modal with a nested schema | +| `flow` | Start a server-side flow by name (`target`). In the console, a click on a declared `type: 'flow'` action goes through the action endpoint, `POST /api/v1/actions/{object}/{action}`, so every server-side gate the action declares applies; a flow start that names no declared action (an inline page button, a dashboard header action) goes through `POST /api/v1/automation/{flow}/trigger`. A run that pauses at a `screen` node opens the flow runner. Whether the run pauses or goes straight through, it closes on one success toast: the action's `successMessage`, else the flow's own `successMessage`, else a default sentence. A run that ends refused or fails shows no success toast | + +### Server Action Dispatch (`serverActionHandler.ts`) + +`ActionSchema.body` (the spec's preferred binding for script actions) executes +server-side via `POST /api/v1/actions/{object}/{action}` — the client +dispatches, it never interprets a body (a browser cannot enforce the L2 +sandbox's capabilities/timeout/memory contract, and L1 is formula-engine CEL, +a different dialect from the `${...}` evaluator). + +`createServerActionHandler` is the core factory every consumer uses to build +that dispatch. It owns the protocol (name-based action identity, record-id +resolution, re-entrancy guard, the `/actions` response-envelope rule) and +injects the three things core has no opinion about: + +```typescript +import { createServerActionHandler } from '@object-ui/core'; + +const script = createServerActionHandler({ + fetch: myAuthenticatedFetch, // auth is yours + baseUrl: 'https://api.example.com', // origin is yours ('' = same-origin) + resolveObject: () => currentObject, // fallback object scope is yours + onRefresh: () => notifyDataChanged(), // data invalidation is yours +}); + +// Registered handlers beat the built-in executors: + +``` + +The console builds on the same factory (`@object-ui/app-shell`'s +`createConsoleServerActionHandler` adds the popup pre-open dance and the +`redirectUrl` convention on top). + +### TransactionManager (`TransactionManager.ts`) + +Wraps multi-step actions in transactions for consistency, supporting rollback on failure. + +## Key Directories Reference + +``` +packages/ +├── types/src/ # Layer 2 — Pure type definitions +├── core/src/ +│ ├── evaluator/ # Expression engine +│ ├── registry/ # Component & plugin registries +│ ├── actions/ # ActionRunner, TransactionManager +│ ├── validation/ # Schema validation engine +│ ├── adapters/ # Data source adapters (API, Value) +│ ├── data-scope/ # ViewDataProvider, element data sources +│ ├── query/ # Query AST (filtering/sorting) +│ ├── theme/ # ThemeEngine +│ └── builder/ # Schema builder utilities +├── react/src/ +│ ├── SchemaRenderer.tsx +│ ├── LazyPluginLoader.tsx +│ ├── context/ # All React contexts +│ └── hooks/ # 20+ hooks (useExpression, useActionRunner, etc.) +├── components/ # 91+ Shadcn UI atoms +├── fields/ # 35+ field renderers +├── layout/ # AppShell, Header, Sidebar +├── i18n/ # Internationalization +├── auth/ # AuthContext + providers +├── permissions/ # RBAC/ABAC +└── plugin-*/ # 20 plugin packages + ├── plugin-grid/ + ├── plugin-kanban/ + ├── plugin-charts/ + ├── plugin-dashboard/ + ├── plugin-form/ + └── ... (15 more) +``` + +## Further Reading + +- [Schema Overview](/docs/sdk/guide/schema-overview) — JSON schema structure +- [Schema Rendering](/docs/sdk/guide/schema-rendering) — How schemas become UI +- [Component Registry](/docs/sdk/guide/component-registry) — Registering components +- [Plugin Development](/docs/sdk/guide/plugin-development) — Building plugins +- [Expressions](/docs/ui) — Expression syntax reference +- [Theming](/docs/sdk/guide/theming) — Theme configuration diff --git a/content/docs/sdk/guide/building-crud-app.mdx b/content/docs/sdk/guide/building-crud-app.mdx new file mode 100644 index 00000000000..9d07648b632 --- /dev/null +++ b/content/docs/sdk/guide/building-crud-app.mdx @@ -0,0 +1,429 @@ +--- +title: "Building a CRUD App" +description: "End-to-end tutorial for building a complete CRUD application with ObjectUI — from schema definition to deployment" +--- + +# Building a CRUD App + +This tutorial walks you through building a **Task Manager** CRUD application with ObjectUI — from schema definition to data source setup to deployment. + +## Prerequisites + +- **Node.js** and **pnpm** (or npm/yarn) — ObjectUI is tested on Node 22.x with pnpm 10.x. +- Basic knowledge of **React** and **TypeScript** + +## Step 1: Project Setup + +Create a new React project and install the required ObjectUI packages: + +```bash +pnpm create vite task-manager --template react-ts +cd task-manager +``` + +Install ObjectUI core packages and the plugins you need: + +```bash +pnpm add @object-ui/react @object-ui/core @object-ui/types @object-ui/components @object-ui/fields +pnpm add @object-ui/plugin-grid @object-ui/plugin-form @object-ui/plugin-detail +``` + +Install Tailwind CSS: + +```bash +pnpm add -D tailwindcss @tailwindcss/vite +``` + +Add Tailwind to your `vite.config.ts`: + +```ts +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [react(), tailwindcss()], +}); +``` + +Add to your `src/index.css`: + +```css +@import "tailwindcss"; +``` + +## Step 2: Register Components + +Create `src/setup.ts` to register the built-in component and field renderers: + +```ts +import { initializeComponents } from '@object-ui/components'; +// Side-effect imports: loading each package runs its own registration. +import '@object-ui/fields'; +import '@object-ui/plugin-grid'; +import '@object-ui/plugin-form'; +import '@object-ui/plugin-detail'; + +initializeComponents(); +``` + +Loading each package registers what it owns — the components, the field widgets +and each plugin's blocks all go into the one `ComponentRegistry` that +`@object-ui/core` exports. `initializeComponents()` takes no arguments; it exists +so a bundler cannot tree-shake the side-effect import away. + +**Every block this tutorial renders comes from a plugin**, so the three plugin +imports are not optional extras: `object-grid` lives in `@object-ui/plugin-grid`, +`object-form` in `@object-ui/plugin-form` and `detail-view` in +`@object-ui/plugin-detail`. Leave one out and `SchemaRenderer` has nothing to +resolve that `type` to — it renders the red **Unknown component type** panel +instead of the block. + +Import this file once at the top of your app entry point (`src/main.tsx` or `src/App.tsx`). + +## Step 3: Define the Object Schema + +Create `src/schemas/task.ts`. This is the metadata that drives the entire UI — grid columns, form fields, validation, and views are all derived from this schema: + +```ts +import type { FieldMetadata } from '@object-ui/types'; + +const fields: Record = { + title: { name: 'title', type: 'text', label: 'Title', required: true }, + status: { + name: 'status', + type: 'select', + label: 'Status', + defaultValue: 'Todo', + options: [ + { label: 'Backlog', value: 'Backlog' }, + { label: 'Todo', value: 'Todo' }, + { label: 'In Progress', value: 'In Progress' }, + { label: 'Review', value: 'Review' }, + { label: 'Done', value: 'Done' }, + ], + }, + priority: { + name: 'priority', + type: 'select', + label: 'Priority', + defaultValue: 'Medium', + options: [ + { label: 'Critical', value: 'Critical' }, + { label: 'High', value: 'High' }, + { label: 'Medium', value: 'Medium' }, + { label: 'Low', value: 'Low' }, + ], + }, + assignee: { name: 'assignee', type: 'text', label: 'Assignee' }, + due_date: { name: 'due_date', type: 'date', label: 'Due Date' }, + description: { name: 'description', type: 'textarea', label: 'Description' }, +}; + +export const TaskSchema = { + name: 'task', + label: 'Task', + icon: 'check-circle-2', + titleFormat: '{title}', + fields, + listViews: { + all: { + label: 'All Tasks', + columns: ['title', 'status', 'priority', 'assignee', 'due_date'], + }, + active: { + label: 'Active', + columns: ['title', 'status', 'priority', 'assignee', 'due_date'], + filter: [{ field: 'status', operator: 'not_equals', value: 'Done' }], + sort: [{ field: 'priority', order: 'asc' }], + }, + }, +}; +``` + +An object's metadata is a plain document — the same shape a backend serves from +`DataSource.getObjectSchema()` — so it is written as a literal rather than built +by a helper. `FieldMetadata` is the union every field entry belongs to (`text`, +`select`, `date`, `textarea`, …), and typing the `fields` record against it is +what makes a wrong `type` or a misspelled key fail at compile time. ObjectUI +reads those entries to choose the renderer, apply validation, and generate form +controls automatically. + +## Step 4: Create a Data Source + +ObjectUI never hardcodes `fetch` calls inside components. Instead, it communicates with your backend through the `DataSource` interface. Create `src/data/rest-data-source.ts`: + +```ts +import type { DataSource, QueryParams, QueryResult } from '@object-ui/types'; + +export class RestDataSource implements DataSource { + constructor(private baseUrl: string) {} + + async find(resource: string, params?: QueryParams): Promise { + const query = new URLSearchParams(); + if (params?.$top) query.set('$top', String(params.$top)); + if (params?.$skip) query.set('$skip', String(params.$skip)); + // `$orderby` is a union — an OData clause string, a map, or an array of + // fields. This backend speaks the string form, so narrow to it rather + // than stringifying a shape the server cannot parse. + if (typeof params?.$orderby === 'string') query.set('$orderby', params.$orderby); + if (params?.$search) query.set('$search', params.$search); + const res = await fetch(`${this.baseUrl}/${resource}?${query}`); + const data = await res.json(); + return { data: data.items, total: data.total }; + } + + async findOne(resource: string, id: string | number) { + return (await fetch(`${this.baseUrl}/${resource}/${id}`)).json(); + } + + async create(resource: string, data: Partial) { + const res = await fetch(`${this.baseUrl}/${resource}`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + return res.json(); + } + + async update(resource: string, id: string | number, data: Partial) { + const res = await fetch(`${this.baseUrl}/${resource}/${id}`, { + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + return res.json(); + } + + async delete(resource: string, id: string | number) { + return (await fetch(`${this.baseUrl}/${resource}/${id}`, { method: 'DELETE' })).ok; + } + + async getObjectSchema(objectName: string) { + return (await fetch(`${this.baseUrl}/schema/${objectName}`)).json(); + } +} +``` + +> **Tip:** For the ObjectStack backend, use the official `@object-ui/data-objectstack` adapter instead of writing your own. See the [Data Connectivity](/docs/sdk/guide/data-source) guide for details. + +## Step 5: Render a CRUD Grid View + +Wire everything together in `src/App.tsx`. `SchemaRendererProvider` injects the +data source once, and every `SchemaRenderer` beneath it renders its schema +against that one adapter: + +```tsx +import './setup'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +import { RestDataSource } from './data/rest-data-source'; + +const dataSource = new RestDataSource('https://api.example.com/v1'); + +function App() { + return ( + +
+ +
+
+ ); +} + +export default App; +``` + +This renders a fully interactive data grid with sortable columns, pagination, +and row actions. `object-grid` fetches the object's schema itself — through +your data source's `getObjectSchema` (Step 4) — to generate the columns and +field types, so nothing from `src/schemas/task.ts` needs to be imported here; +that file only has to describe what your backend actually serves. + +Two details in that snippet are load-bearing, and getting either wrong produces +a grid that draws its header and nothing else: + +- **The data source is injected by an ancestor, not passed to the block.** + `object-grid`, `object-form` and `detail-view` all read the adapter from + `SchemaRendererProvider`, so it has to be *above* them in the tree. A + `dataSource` written on the block itself reaches that one block and never + becomes context for anything under it. +- **The object is named by `objectName`.** That is the key each of these blocks + declares as required — inside the `properties` bag on `object-grid` and + `object-form`, as the spec's page component declares their props, and on the + node on `detail-view`; a differently-spelled key (`object`) is not read, so the + block has no object to query. + +Neither mistake used to say anything — the block simply rendered empty. Both now +report themselves: a block that resolves no adapter renders a **No data source +resolved** panel naming itself and the object it was about to read. + +## Step 6: Add Create and Edit Forms + +ObjectUI generates forms directly from your schema. Extend `App.tsx` with form state: + +```tsx +const [showForm, setShowForm] = useState(false); +const [editId, setEditId] = useState(null); +``` + +Add a "New Task" button and handle row clicks to open the edit form: + +Both of these render inside the `SchemaRendererProvider` from Step 5, so neither +carries a data source of its own: + +```tsx + { setEditId(row.id); setShowForm(true); }} +/> + +{showForm && ( + setShowForm(false)} + onCancel={() => setShowForm(false)} + /> +)} +``` + +`recordId` is the key `object-form` reads to load the record it is editing — it +is the form's own spelling and is not shared with `detail-view`, which uses +`resourceId` (Step 8). + +The form automatically renders the correct field widgets (text inputs, select dropdowns, date pickers) based on your `FieldMetadata` definitions. Validation rules like `required` are enforced out of the box. + +In **create** mode the form also opens with the `defaultValue`s your schema +declares — the `status` and `priority` fields above start on `Todo` and +`Medium`, already submittable, rather than empty next to a required marker. +Only static defaults are seeded: a `defaultValue` that is a runtime token +(`'NOW()'`, `'current_user'`) or a CEL expression is an instruction the server +resolves at insert time, so the form leaves that field empty and lets it, with +*Set automatically when saved.* under the field while it stays empty. In +**edit** mode nothing is seeded — the form shows the record as stored. Values +you pass as `initialData` / `initialValues` outrank a schema default. + +## Step 7: Add Filters and Search + +Leverage the `active` list view you defined in Step 3 by binding the grid to +it declaratively, with the spec's per-element `dataSource` binding +(`dataSource: { object, view }`). This is the same binding `list-view`, +`detail-view` and every other object-bound block in this repo read — +`ElementDataSourceGate` resolves `view` against the object's saved views +(fetched through your data source's `getObjectSchema` / `listViews`) and +composes that view's `filter` and `sort` onto the query for you: + +```tsx +const [activeView, setActiveView] = useState('all'); + +// View switcher buttons — the names match the `listViews` keys from Step 3. + + + +// The grid re-resolves `view` whenever `activeView` changes. + +``` + +Selecting **Active** re-queries with the `active` view's `filter` +(`status != Done`) and `sort` (`priority asc`) applied — you never assemble +`$filter` / `$orderby` by hand. A view name your backend does not publish is +reported, not silently ignored: swap `activeView` for a name outside +`listViews` and the grid renders a configuration-error panel in place of the +table, the same way an unresolved `objectName` does (Step 5). A page that +instead fell back to the object's full, unfiltered scope would look like it +worked while returning every record regardless of which button was pressed — +so this block does not offer that fallback. + +**Search needs no binding at all.** `object-grid` renders its own search box +in the toolbar — on by default — and typing there drives +`DataSource.find()`'s `$search` parameter directly; there is no separate +query-param key to author. Add `searchableFields: ['title', 'description']` +to the grid's `properties` bag to narrow which fields the server matches; leave +it out and the server decides. + +## Step 8: Add a Detail View + +Create a detail page that renders a single record with all its fields: + +```tsx +import { SchemaRenderer } from '@object-ui/react'; + +function TaskDetail({ taskId, onBack }: { taskId: string; onBack: () => void }) { + return ( +
+ + +
+ ); +} +``` + +Render `TaskDetail` inside the same `SchemaRendererProvider` as the grid — it +reads the injected data source from context, exactly as `object-grid` and +`object-form` do. + +Two keys differ from the form above, and both matter: + +- **`resourceId`, not `recordId`.** `detail-view` sources the record id from + `resourceId`; `recordId` is `object-form`'s spelling. The two blocks are not + interchangeable here. +- **No `data`.** On `detail-view`, `data` means *"here is the record already, + do not fetch"* — so handing it anything (including the object's metadata) + makes the block skip `findOne` entirely and render that value as if it were + the record. Omit it and the block loads the record for itself. + +Use this component in your main app with simple routing state, or integrate with a router like React Router or TanStack Router for URL-based navigation. + +## Deployment Considerations + +**Environment config** — Keep your API URL configurable: + +```ts +const dataSource = new RestDataSource( + import.meta.env.VITE_API_URL || 'http://localhost:3000/api' +); +``` + +**Performance** — Use server-side pagination via `$top`/`$skip` query params. ObjectUI plugins support lazy loading via `LazyPluginLoader` from `@object-ui/react`. Set `cache: { enabled: true, ttl: 300 }` on your schema for client-side caching. + +**Production build** — Run `pnpm build` and deploy the `dist/` folder to any static host (Vercel, Netlify, Cloudflare Pages). + +**Authentication** — Extend `RestDataSource` to inject auth headers: + +```ts +class AuthenticatedDataSource extends RestDataSource { + constructor(baseUrl: string, private getToken: () => string) { + super(baseUrl); + } + // Override fetch calls to include: Authorization: `Bearer ${this.getToken()}` +} +``` + +## Next Steps + +- Explore the [Schema Overview](/docs/sdk/guide/schema-overview) for advanced schema features +- Add a Kanban board view using `@object-ui/plugin-kanban` (see the [Kanban Plugin](/docs/sdk/plugins/plugin-kanban) reference for runnable board schemas) +- Connect to a production backend with the [Data Connectivity](/docs/sdk/guide/data-source) guide +- Build multi-object apps with relationships using `lookup` / `master_detail` fields (see the [Lookup Field](/docs/data-modeling/field-types) reference) diff --git a/content/docs/sdk/guide/component-registry.mdx b/content/docs/sdk/guide/component-registry.mdx new file mode 100644 index 00000000000..3bee9cbc847 --- /dev/null +++ b/content/docs/sdk/guide/component-registry.mdx @@ -0,0 +1,457 @@ +--- +title: "Component Registry" +--- + +The Component Registry is Object UI's system for mapping schema types to React components. Understanding the registry is key to extending Object UI with custom components. + +## Overview + +The registry acts as a lookup table that the `SchemaRenderer` uses to determine which React component to render for each schema type: + +``` +Schema Type → Component Registry → React Component +``` + +## Getting the Registry + +`ComponentRegistry` is a process-level singleton exported by `@object-ui/core`. +Import it directly — there is no accessor function and nothing to construct: + +```tsx +import { ComponentRegistry } from '@object-ui/core' +``` + +## Registering Components + +### Using Default Components + +The easiest way to get started is to register all default components: + +```tsx +import { initializeComponents } from '@object-ui/components' +// Side-effect import: loading the package runs its own field registration. +import '@object-ui/fields' + +// Call once at app initialization +initializeComponents() +``` + +Loading each package registers what it owns — the components and the field +widgets both land in the one `ComponentRegistry`; `initializeComponents()` exists +so a bundler cannot tree-shake the side-effect import away. The individual +renderers are not exported for hand-registration: registration is what loading +the package does. + +This registers all built-in components like: +- Forms: `input`, `textarea`, `select`, `checkbox`, etc. +- Data: `table`, `list`, `card`, `tree-view`, etc. +- Layout: `page`, `grid`, `flex`, `container`, etc. +- Feedback: `alert`, `dialog`, `toast`, etc. + +### Registering Custom Components + +Create and register your own components: + +```tsx +import { ComponentRegistry } from '@object-ui/core' +import type { BaseSchema } from '@object-ui/types' + +interface MyComponentSchema extends BaseSchema { + type: 'my-component' + title: string + content: string +} + +function MyComponent(props: MyComponentSchema) { + return ( +
+

{props.title}

+

{props.content}

+
+ ) +} + +ComponentRegistry.register('my-component', MyComponent) +``` + +Now you can use it in schemas: + +```json +{ + "type": "my-component", + "title": "Hello", + "content": "This is my custom component!" +} +``` + +## Component Interface + +All registered components receive the schema as props: + +```tsx +interface ComponentProps { + // The complete schema object + schema: T + + // Data context (optional) + data?: Record + + // Event handlers (optional) + onAction?: (action: any, context: any) => void + onChange?: (value: any) => void + onSubmit?: (data: any) => void +} + +function MyRenderer(props: ComponentProps) { + const { schema, data, onChange } = props + + return ( +
+ {/* Your component implementation */} +
+ ) +} +``` + +## Advanced Registration + +### With Metadata + +Register components with additional metadata: + +```tsx +ComponentRegistry.register('my-component', MyComponent, { + label: 'My Custom Component', + category: 'Custom', + icon: 'component-icon', + inputs: [ + { name: 'title', type: 'string' }, + { name: 'content', type: 'string' } + ] +}) +``` + +This metadata is used by the Visual Designer to provide better editing experience. + +`inputs` is also the authoring contract the SDUI parser validates against. A +component that renders `schema.children` declares the slot as +`{ name: 'children', type: 'slot' }`; a child list authored under a component +whose `inputs` carry no such entry draws the `not-a-container` diagnostic. +`isContainer` is a separate fact — *layout* containment, which keeps a block out +of a react page's JSX scope — and does not stand in for the slot. + +### Lazy Loading + +Register components that load on demand: + +```tsx +// The loader runs the first time a schema asks for `heavy-component`. +ComponentRegistry.registerLazy('heavy-component', () => import('./HeavyComponent')) +``` + +### Overriding Built-in Components + +Override default components with your own: + +```tsx +import { ComponentRegistry } from '@object-ui/core' +import { initializeComponents } from '@object-ui/components' +import '@object-ui/fields' + +// Register defaults first +initializeComponents() + +// Override specific component +ComponentRegistry.register('button', MyCustomButton) +``` + +## Component Categories + +Default components are organized by category: + +### Form Components + +- `input` +- `textarea` +- `select` +- `checkbox` +- `radio` +- `switch` +- `slider` +- `date-picker` +- `time-picker` +- `file-upload` +- `color-picker` + +### Data Display + +- `table` +- `list` +- `card` +- `tree-view` +- `timeline` +- `calendar` +- `kanban` + +### Layout + +- `page` +- `container` +- `grid` +- `flex` +- `tabs` +- `accordion` +- `divider` +- `spacer` + +### Feedback + +- `alert` +- `toast` +- `dialog` +- `drawer` +- `popover` +- `tooltip` +- `progress` +- `skeleton` +- `spinner` + +### Navigation + +- `menu` +- `breadcrumb` +- `pagination` +- `steps` + +### Other + +- `button` +- `link` +- `text` +- `icon` +- `image` +- `video` +- `badge` +- `avatar` + +## Checking Registered Components + +### Get All Registered Types + +```tsx +import { ComponentRegistry } from '@object-ui/core' + +const types = ComponentRegistry.getAllTypes() +console.log(types) // ['input', 'button', 'form', ...] +``` + +### Check if Type is Registered + +```tsx +import { ComponentRegistry } from '@object-ui/core' + +if (ComponentRegistry.has('my-component')) { + console.log('Component is registered') +} +``` + +### Get Component Metadata + +```tsx +import { ComponentRegistry } from '@object-ui/core' + +const metadata = ComponentRegistry.getMeta('input') +console.log(metadata) +// { +// label: 'Input', +// category: 'Form', +// icon: 'input-icon', +// ... +// } +``` + +## Best Practices + +### 1. Register Once at App Initialization + +```tsx +// main.tsx or App.tsx +import { initializeComponents } from '@object-ui/components' +import '@object-ui/fields' + +initializeComponents() + +function App() { + // Your app code +} +``` + +### 2. Use TypeScript for Custom Components + +```tsx +import type { BaseSchema } from '@object-ui/types' + +interface CustomSchema extends BaseSchema { + type: 'custom' + customProp: string +} + +function CustomComponent(props: { schema: CustomSchema }) { + // TypeScript ensures type safety +} +``` + +### 3. Follow Naming Conventions + +Use kebab-case for component types: +- ✅ `my-component`, `custom-button`, `data-table` +- ❌ `MyComponent`, `customButton`, `DataTable` + +### 4. Provide Meaningful Metadata + +```tsx +ComponentRegistry.register('rating', RatingComponent, { + label: 'Star Rating', + category: 'Form', + icon: 'star', + labelling: 'group' +}) +``` + +### 5. Handle Missing Props Gracefully + +```tsx +function MyComponent(props: ComponentProps) { + const { schema } = props + const title = schema.title || 'Default Title' + const content = schema.content || '' + + return ( +
+

{title}

+

{content}

+
+ ) +} +``` + +## Creating Plugin Packages + +Group related components into plugin packages: + +```tsx +// @my-org/objectui-plugin-charts +import { ComponentRegistry } from '@object-ui/core' +import { BarChart } from './BarChart' +import { LineChart } from './LineChart' +import { PieChart } from './PieChart' + +export function registerChartComponents() { + ComponentRegistry.register('bar-chart', BarChart) + ComponentRegistry.register('line-chart', LineChart) + ComponentRegistry.register('pie-chart', PieChart) +} +``` + +Usage: + +```tsx +import { initializeComponents } from '@object-ui/components' +import '@object-ui/fields' +import { registerChartComponents } from '@my-org/objectui-plugin-charts' + +initializeComponents() +registerChartComponents() +``` + +## Example: Custom Form Component + +Here's a complete example of a custom form component: + +```tsx +import { forwardRef, useState } from 'react' +import { ComponentRegistry } from '@object-ui/core' +import type { BaseSchema } from '@object-ui/types' +import { cn } from '@object-ui/components' + +interface RatingSchema extends BaseSchema { + type: 'rating' + name: string + label?: string + maxStars?: number + required?: boolean + disabled?: boolean + onChange?: (value: number) => void +} + +const RatingComponent = forwardRef( + ({ schema }, ref) => { + const [value, setValue] = useState(0) + const maxStars = schema.maxStars || 5 + + const handleClick = (rating: number) => { + if (schema.disabled) return + setValue(rating) + schema.onChange?.(rating) + } + + return ( +
+ {schema.label && ( + + )} +
+ {Array.from({ length: maxStars }).map((_, index) => ( + + ))} +
+
+ ) + } +) + +RatingComponent.displayName = 'Rating' + +// Register the component +ComponentRegistry.register('rating', RatingComponent, { + label: 'Star Rating', + category: 'Form', + labelling: 'group', + inputs: [ + { name: 'name', type: 'string', required: true }, + { name: 'label', type: 'string' }, + { name: 'maxStars', type: 'number', description: 'Defaults to 5 — the renderer\'s own fallback' }, + { name: 'required', type: 'boolean' }, + { name: 'disabled', type: 'boolean' } + ] +}) + +export { RatingComponent } +``` + +## Next Steps + +- [Expression System](/docs/ui) - Learn about dynamic expressions +- [Schema Rendering](/docs/sdk/guide/schema-rendering) - Understand the rendering engine +- [Custom Plugin Development](/docs/sdk/guide/plugin-development) - Deep dive into component creation + +## Related Documentation + +- [`@object-ui/core` README](https://github.com/objectstack-ai/objectui/tree/main/packages/core) - Component registry API +- [`@object-ui/react` README](https://github.com/objectstack-ai/objectui/tree/main/packages/react) - React integration +- [Schema Type Reference](/docs/sdk/api/schema-reference) - Component metadata reference diff --git a/content/docs/sdk/guide/data-source.mdx b/content/docs/sdk/guide/data-source.mdx new file mode 100644 index 00000000000..3204d4e6e6a --- /dev/null +++ b/content/docs/sdk/guide/data-source.mdx @@ -0,0 +1,622 @@ +--- +title: "Data Connectivity" +--- + +ObjectUI follows the **Universal Adapter Pattern**. UI components do not hardcode transport details. They receive a `DataSource` implementation from `SchemaRendererProvider` and call a stable CRUD/query contract. + +This keeps the renderer backend-agnostic: ObjectStack, REST, GraphQL, and proprietary backends can all be adapted behind the same interface. + +## The Interface + +The canonical interface lives in `@object-ui/types`: + +```typescript +import type { BatchTransactionOperation, QueryParams, QueryResult } from '@object-ui/types'; + +export interface DataSource { + find(resource: string, params?: QueryParams): Promise>; + findOne(resource: string, id: string, params?: QueryParams): Promise; + create(resource: string, data: Partial): Promise; + update( + resource: string, + id: string, + data: Partial, + opts?: { ifMatch?: string }, + ): Promise; + delete( + resource: string, + id: string, + opts?: { ifMatch?: string }, + ): Promise; + + // Optional: atomically persist an ordered set of cross-object operations + // (master-detail save). `{ $ref: }` links a child to a parent + // created earlier in the same batch. Adapters without server-side atomicity + // may emulate it — see below. + batchTransaction?( + operations: BatchTransactionOperation[], + ): Promise<{ results: any[] }>; + + getObjectSchema(objectName: string): Promise; +} +``` + +`find()` returns a `QueryResult` so components can receive both rows and pagination metadata: + +```typescript +interface QueryResult { + data: T[]; + total?: number; + page?: number; + pageSize?: number; + hasMore?: boolean; + cursor?: string; + metadata?: Record; +} +``` + +## Available Adapters + +### ObjectStack Adapter (Official) + +Use `@object-ui/data-objectstack` for ObjectStack-compatible backends. + +```bash +pnpm add @object-ui/data-objectstack +``` + +```typescript +import { createObjectStackAdapter } from '@object-ui/data-objectstack'; + +const dataSource = createObjectStackAdapter({ + baseUrl: 'https://api.your-instance.com' +}); +``` + +## Usage + +Inject the data source at the renderer boundary: + +```tsx +import '@object-ui/components'; +import '@object-ui/fields'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +import { createObjectStackAdapter } from '@object-ui/data-objectstack'; +import type { DeclaredNode } from '@object-ui/types'; + +const dataSource = createObjectStackAdapter({ + baseUrl: 'https://api.example.com' +}); + +// `object-grid` is a data-bound node: it reads `objectName` from its +// `properties` bag and queries the injected data source for the rows. +const mySchema: DeclaredNode = { type: 'object-grid', properties: { objectName: 'users' } }; + +function App() { + return ( + + + + ); +} +``` + +## Creating a Custom Adapter + +If you have a proprietary backend, wrap its SDK or client in a `DataSource` implementation. Keep transport details in the adapter, not in renderers. + +```typescript +import type { DataSource, QueryParams, QueryResult } from '@object-ui/types'; + +type User = { + id: string; + name: string; + email: string; +}; + +type BackendClient = { + listUsers(params?: QueryParams): Promise<{ rows: User[]; total?: number }>; + getUser(id: string | number): Promise; + createUser(data: Partial): Promise; + updateUser(id: string | number, data: Partial): Promise; + deleteUser(id: string | number): Promise; + describeObject(name: string): Promise; +}; + +class UserDataSource implements DataSource { + constructor(private readonly client: BackendClient) {} + + async find(resource: string, params?: QueryParams): Promise> { + if (resource !== 'users') { + return { data: [], total: 0 }; + } + + const result = await this.client.listUsers(params); + return { + data: result.rows, + total: result.total, + }; + } + + findOne(_resource: string, id: string | number): Promise { + return this.client.getUser(id); + } + + create(_resource: string, data: Partial): Promise { + return this.client.createUser(data); + } + + update(_resource: string, id: string | number, data: Partial): Promise { + return this.client.updateUser(id, data); + } + + delete(_resource: string, id: string | number): Promise { + return this.client.deleteUser(id); + } + + getObjectSchema(objectName: string): Promise { + return this.client.describeObject(objectName); + } +} +``` + +## Query Parameters + +ObjectUI uses OData-style query keys for broad compatibility: + +```typescript +await dataSource.find('users', { + $select: ['id', 'name', 'email'], + $filter: { status: 'active' }, + $orderby: { name: 'asc' }, + $skip: 0, + $top: 25, + $count: true, +}); +``` + +Data-aware plugins may also use optional methods such as `batchTransaction`, `bulkUpdate`, `bulkDelete`, `getView`, or `listViewOverrides` when an adapter supports them. Keep the required CRUD methods implemented first, then add optional capabilities as your UI needs them. + +### Bulk writes: per-record results and one refresh per run + +`bulkUpdate` and `bulkDelete` resolve to either a plain count or a `BulkWriteResult`: the count plus one `{ id, error }` entry per record the server refused, with the server's reason. An adapter whose server reports per-row outcomes should return the `BulkWriteResult` (the `@object-ui/data-objectstack` adapter does, from the `results[].errors[]` of `updateMany` / `deleteMany`). An adapter that has only a count keeps returning the number. A caller handles both: + +```typescript +import type { DataSource } from '@object-ui/types'; + +declare const dataSource: DataSource; + +const res = await dataSource.bulkUpdate?.('order', ['o1', 'o2', 'o3'], { status: 'closed' }); +if (typeof res === 'number') { + console.log(`${res} updated`); +} else if (res) { + console.log(`${res.succeeded} updated`); + for (const { id, error } of res.errors) console.warn(`${id}: ${error}`); +} +``` + +The grid's bulk actions read both shapes. A refused record shows up in the result dialog and the error CSV under its own id and reason, and **Retry** works on it, whatever the action's `batchSize`. + +Every `create` / `update` / `delete` normally fires one `onMutation` event, and every bound view refetches once per event. A loop that writes many rows (a bulk action on its per-row path, an undo) would refetch the list once per row. Wrap such a loop in the optional `batchMutationEvents(work)`: the events its writes fire are held and delivered when `work` settles, one per touched resource and mutation type, so bound views refresh once. The grid's bulk executor does this when the adapter has the method. + +```typescript +import type { DataSource } from '@object-ui/types'; + +declare const dataSource: DataSource; + +const ids = ['o1', 'o2', 'o3']; +const write = async () => { + for (const id of ids) await dataSource.update('order', id, { status: 'closed' }); +}; +await (dataSource.batchMutationEvents ? dataSource.batchMutationEvents(write) : write()); +``` + +`listMetadataItems(type)` lists one metadata registry type as `{ name, label }` headers, for the `position-ref` / `permission-ref` field widgets (see the [Fields guide](/docs/sdk/guide/fields)). An empty array means the registry has no item of that type, so a failed load must **reject**, never resolve `[]`. + +### Cross-object atomic writes (`batchTransaction`) + +Master-detail saves (a parent record plus its child line items) go through +`dataSource.batchTransaction(operations)` — one ordered list of cross-object +create/update/delete operations, where a child's foreign key can be +`{ $ref: }` to point at a parent created in the same batch. +The `@object-ui/data-objectstack` adapter maps this to the published +`@objectstack/client` `data.batchTransaction` SDK method, which drives the +server's atomic `POST /api/v1/batch` endpoint (commit-all-or-roll-back-all). +Adapters without a +transactional endpoint don't need to hand-write orchestration: call +`emulateBatchTransaction(dataSource, operations)` from `@object-ui/core`, which +executes the operations sequentially (resolving `$ref`s) with best-effort +compensation on failure. UI components never branch on atomicity — they call +`runBatchTransaction(dataSource, operations)` (also from `@object-ui/core`), +which uses the adapter's method when present and emulates otherwise. + +The `@object-ui/data-objectstack` adapter decides whether it can trust server +atomicity **declaratively**, at connect time: it reads the +`capabilities.transactionalBatch` flag from `GET /api/v1/discovery` +(framework #3298). When the backend advertises `true`, the adapter treats any +`/batch` failure as a real error — no non-atomic client-side compensation. When +the flag is `false` or absent (a backend predating #3298), it keeps the legacy +behaviour: probe `/batch` and fall back to the non-atomic emulation on +`404`/`405`/`501`. Atomic cross-object saves are therefore guaranteed only +against backends that advertise the capability; older ones still save, but +best-effort. See the +[adapter README](https://github.com/objectstack-ai/objectui/blob/main/packages/data-objectstack/README.md#cross-object-atomic-batch-batchtransaction) +for the full capability table and minimum-backend note. + +## Per-element data binding on a page (`dataSource`) + +A metadata page component carries its own data binding — +`PageComponentSchema.dataSource`, the spec's `ElementDataSourceSchema` — so one +page can show several objects without a page-level object context: + +```json +{ + "type": "list-view", + "dataSource": { "object": "account", "view": "hot", "limit": 10 } +} +``` + +This is metadata, **not** the data-source adapter. The two share a name and are +different things: the adapter is injected by the host (`SchemaRendererProvider`), +while `dataSource` on a schema node is JSON describing *what to query*. A +renderer therefore reads the binding off `schema.dataSource` and gets its adapter +from context — never from a prop the schema could occupy. `SchemaRenderer` strips +the binding from the props it spreads for exactly this reason. + +`view` names a **saved view** of that object; its columns, filter, sort and page +size are applied to the render, so a page never has to keep a second copy of a +view's configuration. `filter` is *additional* criteria — it AND-combines with the +view's filter rather than replacing it — while `sort` and `limit` override the +view's. Only a usable `limit` overrides: a binding cap the contract refuses (`0`, +a negative, a non-integer) is treated as not authored and yields to the view's +cap exactly as an absent one would, and the view's cap is held to the same rule. A `view` name that does not resolve is reported as a configuration error; +it never degrades into an unfiltered query for the object. + +`@object-ui/react` exposes `useElementDataSource(schema, dataSource?)` for +renderers that need the same resolution, and `@object-ui/core` exposes the pure +parts (`isElementDataSourceConfig`, `resolveSavedView`, +`composeElementDataSource`). + +### Which blocks consume it, and which keys each one honours + +The binding is declared on every page component, but a component can only honour +the keys it has a read site for — a calendar has no page to cap, a metric is one +aggregated number, a form edits one record. Each block therefore maps the keys it +reads and leaves the rest alone; a key written onto a schema slot the block +ignores would be accepted and dropped, which is the defect this binding removes. + +| block | `object` | `view` | `filter` | `sort` | `limit` | +|---|---|---|---|---|---| +| `list-view` | ✅ (`data`) | ✅ | ✅ | ✅ | ✅ | +| `object-grid` | ✅ | ✅ | ✅ | ✅ | ✅ | +| `element:record_picker` | ✅ | ✅ | ✅ | ✅ | ✅ | +| `element:number` | ✅ | filter | ✅ | — single value | — single value | +| `element:repeater` | ✅ | filter / sort / limit | ✅ | ✅ | ✅ | +| `record:related_list` | ✅ | columns / filter / sort / limit | ✅ | ✅ | ✅ | +| `object-calendar` | ✅ | filter / sort | ✅ | ✅ | — platform ceiling | +| `object-kanban` | ✅ | filter / limit | ✅ | — no ordering | ✅ (`limit`) | +| `object-chart` | ✅ | filter | ✅ | — engine orders | — no page | +| `object-metric` | ✅ | filter | ✅ | — single value | — single value | +| `object-gantt` | ✅ | filter / sort | ✅ | ✅ | — platform ceiling | +| `object-map` | ✅ | filter / sort | ✅ | ✅ | — platform ceiling | +| `object-pivot` | ✅ | filter | ✅ | — grouping orders | — totals need all rows | +| `object-timeline` | ✅ | filter / sort / limit | ✅ | ✅ | ✅ (`limit`) | +| `object-form` | ✅ | error-checked only | — no collection query | — | — | +| `embeddable-form` | ✅ | error-checked only | — no collection query | — | — | +| `object-master-detail-form` | ✅ | error-checked only | — no collection query | — | — | +| `record:line_items` | ✅ (`childObject`) | filter / sort / limit | ✅ (AND parent scope) | ✅ | ✅ (`limit`) | +| `record:details` | ✅ (its record) | filter / sort | ✅ | ✅ (picks the first row) | — one row is read | +| `record:highlights` | ✅ (its record) | filter / sort | ✅ | ✅ (picks the first row) | — one row is read | +| `record:quick_actions` | ✅ (its record) | filter / sort | ✅ | ✅ (picks the first row) | — one row is read | + +Reading the `view` column: it lists what a named saved view actually contributes +on that block. A view name that does not resolve is reported as a configuration +error on **every** block in the table, including the ones that take nothing else +from the view — so a typo never passes silently, whatever the block. + +Reading the `object` column: on a block with an object key it lands on that key, +which is `objectName` everywhere except `list-view` and `record:line_items`. A +`list-view` node names its object in `data` (`{ "provider": "object", "object": … }`), +the spec's list view spelling, and a bound `dataSource.object` outranks +`data.object`; the node's own `objectName` is not read. On `record:line_items` the collection the panel +lists, fetches and writes is `childObject`. Its `relationshipField` is *not* part +of the binding and stays the author's — it has to name a field on the bound child +object, so rebinding `object` without updating it is an authoring error the panel +cannot paper over. A `record:line_items` node bound this way needs no `childObject` +of its own, and the page compile accepts it; a node that names its child object in +neither place shows a configuration hint naming `childObject` and loads nothing. + +Two kinds of block have no object key for the binding to land on. The +`element:*` blocks read `dataSource.object` themselves (see below). The three +one-record blocks, whose rows read "its record", take the binding's `object` as +the object of the record they show, and the bound row becomes that record. + +`record:details`, `record:highlights` and `record:quick_actions` show the +record their record context carries. Bound, a block reads the FIRST row of +the bound set by the binding's `sort`, under the caller's own identity, and that +row becomes its record, so a product page shows a record without being a +`type: 'record'` page: + +```json +{ + "type": "record:details", + "properties": { "fields": ["name", "environment_type"] }, + "dataSource": { + "object": "sys_environment", + "filter": [{ "field": "environment_type", "operator": "equals", "value": "production" }], + "limit": 1 + } +} +``` + +When no row matches — or the server refuses the read — the block draws its +authored `emptyState` in its place, when its `properties` declare one. That is +the spec's empty-state shape, the one `object-grid` takes: `{ title, message, +icon, link }`, with `title` and `message` as plain strings or inline locale maps +and `link` as `{ label, url }`. A block that declares none draws what it draws +for a record that is not there: `record:details` its "record not found" state, +`record:highlights` nothing, `record:quick_actions` an empty bar. The empty state +is never drawn while the read loads, on a block with no `dataSource`, or for a +read that fails any other way, which is reported in the block's place. `record:quick_actions` +runs the object's declared actions against the bound row's id. The bound row is +re-read whenever a write to its object is announced on the page (the data +source's mutation events, or `notifyDataChanged`). `objectui validate` warns on a +record block whose binding has no `limit: 1`, so the binding says which record it +means. On a record page, a block with no `dataSource` still shows the page's +record; a bound one shows its bound record instead, read-only. + +Because the binding lands on `objectName` (on `list-view`, it stands in for +`data`), a node bound this way needs no `objectName` (or `data`) of its own. The schema validator (`StrictAnyComponentSchema`, which +`objectui validate` runs, and the tolerant `safeValidateSchema` alike) counts the binding as a record source of `list-view`, +`object-grid`, `object-kanban`, `object-calendar`, `object-gantt` and `object-map`, +and still refuses one of those nodes that declares no other record source and names +its object in neither place. A binding with an empty `object` names nothing. + +The page compile accepts such a node too, on every row above whose object lands on +`objectName`, and on `list-view`: those registrations do not declare `objectName` +(on `list-view`, `data`) required, because the binding can supply it. The exception is `record:related_list`, whose spec row +requires `objectName`, so the page compile still refuses one of those nodes without +it. A node that names its object in neither place renders a short hint, "No object +named: set objectName or dataSource.object.", in place of an empty list, board, +form, chart, metric or pivot. On `list-view` the hint names the key a page node +writes: "No object named: set data.object or dataSource.object.". `object-grid` +shows its own "Object name required for data fetching" error instead. A block with another record source (inline `data` +rows, a form's inline fields — `customFields`, or `sections` whose every field is +inline — a chart's `dataset`, a `bind` path, or a metric's `fallbackValue`) draws +from that source and shows no hint. + +`element:record_picker` and `element:number` keep their display configuration +in the node's `properties` bag and take their query from the binding **only**: +every query key either one reads comes from `dataSource` and from nowhere else. +The flat `properties.object`, `properties.filter`, `properties.sort` and +`properties.limit` on these two elements are not read, and `@objectstack/spec` +retires them (objectstack#11509). `element:number` no longer offers its flat +`object` or `filter` (objectui#12085), so the page validator reports either one +as a prop the metric does not have. The binding's filter is AND-combined with its +view's filter, as on every block above. On `element:number`, +`{ "dataSource": { "object": "contact" }, "properties": { "aggregate": "count" } }` +is a complete metric; its `sort` and `limit` are not read, because an aggregate +has no ordering and a capped count would be a wrong number, and a filter the +converter refuses shows the configuration-error panel instead of a count. +Either of the two whose node names no `dataSource.object` issues no query: an +`element:number` that sets `aggregate` shows a short "No object named: set +dataSource.object." notice instead of a count, and an `element:record_picker` +offers no records. The Studio page designer writes the metric's object into +`dataSource`. + +`element:repeater` reads its query from the binding alone. `@objectstack/spec` +18 retired the flat `properties.object`, `properties.filter`, `properties.sort` +and `properties.limit` (objectstack#11509), and the repeater reads none of them +(objectui#12345); the spec's conversion moves them into `dataSource`. A node +that still carries one is refused by the spec's row and by the +`@object-ui/types` mirror, and the page validator reports it as a prop the +repeater does not have. The repeater requires no prop (objectui#12085): a node +whose `dataSource` names no object issues no query and shows its empty state. +With a named saved view, the binding's own `sort` and `limit` win and the +view's are the baseline, and the view's filter is AND-combined with the +binding's. The Studio page designer writes the repeater's object and its row +limit into `dataSource`, as it does the metric's object. + +```json +{ + "type": "element:repeater", + "dataSource": { + "object": "task", + "filter": [{ "field": "status", "operator": "equals", "value": "open" }], + "sort": [{ "field": "due_date", "order": "asc" }], + "limit": 5 + }, + "properties": { "titleField": "subject", "fields": ["due_date"] } +} +``` + +#### A related list's parent record: `parentDataSource` + +`record:related_list` reads two bindings. Its `dataSource` is the CHILD rows the +list draws (its row in the table above). Its parent record — the record the +list hangs off — is the record page's record, or, when the block's +`properties` carry `parentDataSource`, the record that binding reads. The key +is a member of the spec's `record:related_list` row and takes the same object +binding `dataSource` takes. It is read the way the three one-record blocks +above read their record: the FIRST row of the binding by its `sort`, under the +caller's own identity, re-read whenever a write to its object is announced. +The list then draws that record's children through `relationshipField`, and +`relationshipValueField` is read on that row. So a product page lists a +record's children without being a `type: 'record'` page: + +```json +{ + "type": "record:related_list", + "properties": { + "objectName": "sys_member", + "relationshipField": "environment_id", + "parentDataSource": { + "object": "sys_environment", + "filter": [{ "field": "environment_type", "operator": "equals", "value": "production" }], + "limit": 1 + } + } +} +``` + +When no row matches — or the server refuses the read — the block says so in its +place, naming the object, and draws no list: an empty list would read as "this +record has no related records". A read that fails any other way is reported in +the block's place. `objectui validate` warns on a `parentDataSource` without +`limit: 1`, and refuses the key written on the node instead of in `properties`. +On a record page, a related list without `parentDataSource` lists the page +record's children, as before; with it, it lists the bound record's children. + +#### Scoping a filter to the record in view: `{record_id}` + +On a `type: 'record'` page, a filter value can name the record the page shows +with `{record_id}`, in a component's own filter and in its `dataSource.filter` +alike. On a person's record page this counts that person's open tasks, and the +next person's page counts theirs: + +```json +{ + "type": "element:number", + "dataSource": { + "object": "task", + "filter": [{ "field": "assignee", "operator": "equals", "value": "{record_id}" }] + }, + "properties": { "aggregate": "count" } +} +``` + +The id is the page's mounted record context, never a URL parameter or a page +variable. Anywhere with no record in context (a list view, a dashboard, a report, +a page that is not a record page) the token is refused by name: the renderer +warns, the value is left as written rather than dropped or blanked, and the +ObjectStack server refuses the query (`FILTER_TOKEN_UNRESOLVED`), so the number +never silently becomes a count over every record. `{record_id}` scopes what a +component *shows*; it is not access control, which stays with the server's +row-level security (objectui#7297). + +On `record:related_list` and `record:line_items` the composed filter is +AND-combined with the parent relationship condition, never substituted for it: a +child panel is always scoped to the record it appears on, and an *additional* +criterion can only narrow that set further. (Until objectstack#7118 +`record:related_list` declared `filter` without reading it, so a named view +contributed its columns / sort / limit while its filter was dropped — the list +could be wider than the view it named. That gap is closed; the `filter` cell above +is what closed it.) + +`object-timeline` and `record:line_items` were the two residual gaps in this table +until objectstack#7137. Neither had a `filter` / `sort` read site at all — the +timeline's whole fetch was `find(objectName, { options: { $top: 100 } })` and the +line-items panel's was the parent FK plus a fixed `$top: 500` — so a `view` named +on either resolved (a typo reported) and then contributed nothing: the rendered +rows could be **wider than the view they named**, with no error anywhere. Both now +read `filter`, `sort` and `limit`, so the cells above are ✅. Two notes on what +came with that: + +- The timeline's default window is `limit ?? 100` and it is now a real `$top`. + The old `{ options: { $top: 100 } }` nested the cap under a key that is not a + `QueryParams` field and that no adapter in this repo reads, so the intended cap + never reached the wire; a timeline over a large object fetched whatever the + server chose to return. Authoring `limit` (or a view's `pagination.pageSize`) + now sets it. +- `record:line_items` still does **not** take a view's `columns`: they are editable + `GridColumn` objects (`{ field, type, … }`) rather than a field-name projection, + so a view's column list would be the wrong *shape*, not merely a wider answer. + +`object-kanban` carried the same nesting, and until objectui#4025 this table read +`— fixed window` in its `limit` cell. There was no window: the board's cap was +written `{ options: { $top: 100 } }` — `$filter` at the top level where the +adapters read it, the cap one level down under a key that is not a `QueryParams` +field — so a board over a large object fetched every row the server would return +and grouped all of it into lanes, client-side. The cap is now a real `$top`, +defaulting to 100, and the `limit` cell is ✅ because the read site exists: a +`limit` authored on the block, the binding's `limit`, or the named view's +`pagination.pageSize` all set it. The board's `sort` cell still reads `— no +ordering` — lanes come from `groupBy` and the fetch declares no `$orderby`, so +mapping `sort` would be the accepted-and-dropped defect one block over. + +Note the two `limit`s on a board are different keys at different levels: the row +cap above is `limit` on the **board**, while `limit` on a **column** is that +lane's WIP limit (how many cards it may hold before it warns), which is display +behaviour and never touches the query. + +Reading `— platform ceiling` on `object-calendar` / `object-gantt` / `object-map` +(and on `object-tree`, which predates this table): those four are the **non-grid** +visualisations, and objectui#7210's maintainer ruling settled what their row +behaviour is. They fetch the whole **filtered** result set — a gantt cannot +compute a truthful `min(start) → max(end)` from one page, a map fits its camera to +every marker, and a tree assembled from a page loses every node whose parent fell +outside it — but the fetch is **bounded** by `NON_GRID_ROW_CEILING` +(`@object-ui/react`, currently 2,000). Past it the view draws the first N rows and +shows a footnote naming both N and the total. Silent truncation is the failure +that ruling exists to prevent: a cut-off schedule still looks like a schedule. + +The `limit` cell stays "not ✅" for all four because the ceiling is **not +authorable and must not become one** — a named constant in the renderer, by the +same ruling. An authored `limit`, a binding's `limit` and a named view's +`pagination.pageSize` all still fail to reach these queries, which is what the +cell has always meant. What changed is only that "no cap at all" is no longer +true. + +Remaining gap, recorded rather than papered over: + +- `object-form` / `embeddable-form` / `object-master-detail-form` resolve `view` + only to report an unresolvable name; a view that does resolve contributes + nothing, because a list view's columns are not a form layout. On the + master-detail form the bound object is the **parent**; child collections come + from `details[]`, by FK. + +Blocks not in the table (`dashboard`, the other `record:*` panels) do not consume +the binding yet. + +## Binding a page element to an API endpoint (`provider: 'api'`) + +`PageComponentSchema.dataSource` has a second shape beside the object binding: +the spec's `ElementApiDataSourceSchema`. It binds one same-origin endpoint +instead of the rows of an object: + +```json +{ + "type": "stat", + "dataSource": { "provider": "api", "read": { "url": "/api/v1/cloud/home" }, "path": "data" }, + "properties": { "label": "Members", "value": "${data.members.count}" } +} +``` + +`SchemaRenderer` honours it on every node: + +- It reads `read.url` with `GET`, through the host's authenticated fetch + (`SchemaRendererProvider`'s `apiFetch`; the console passes its signed-in + fetch), and selects `path` from the answer — dotted keys, a numeric segment + indexing a list. Omit `path` to bind the whole answer. +- The selected value is `data` in the node's `${…}` holes and in its + `visibleWhen`, and in the holes of everything the node renders. A hole that is + the whole string (`"${data.members.count}"`) evaluates to the value itself — a + number, a boolean, a list — rather than to text. +- While the read is in flight the node draws a loading line; a failed read, or + a `path` that selects nothing, draws an error panel naming why. The node is + never drawn without its data. +- The binding is judged by the spec's own schema before anything is read. An + absolute or protocol-relative `url`, a credential key, or a `method` other + than `GET` is refused with the spec's message, and no request is sent. + +Elements that bind the same URL at the same time share one request. A host can +also keep one open's answers: inside `ElementApiOpenScope` (from +`@object-ui/react`), a read that has already answered in the same open is +answered again with no request. The console mounts it and counts one open per +visit of a history entry, so a navigation entry's `visible` and a page element bound to +the same URL make one request, even when the entry's read settled first: + +- following a link, a navigation entry, or Back and Forward starts a new open, + and the page reads again, even on an entry visited before; +- a redirect that replaces the address (the app root's landing on its first + entry) stays in the open; +- a failed read is not kept, and any declared data change (`notifyDataChanged`, + which every save and action reports) drops what the open kept, so a read + after a write asks the server. + +Outside such a scope nothing is kept beyond the request in flight: a page that +mounts again reads again. + +The console's product-page blocks — `hero`, `stat`, `usage-bar`, `checklist` and +`tile-grid` — accept this binding in their arms; see the `@object-ui/app-shell` +README. diff --git a/content/docs/sdk/guide/fields.mdx b/content/docs/sdk/guide/fields.mdx new file mode 100644 index 00000000000..fec1b2d290f --- /dev/null +++ b/content/docs/sdk/guide/fields.mdx @@ -0,0 +1,237 @@ +--- +title: "Field Registry" +--- + +Object UI uses a **Field Registry** system to decouple the core engine from specific UI implementations of fields. This allows for rich extensibility and plugin support. + +## Concept + +The `@object-ui/fields` package serves as the "Universal Language" for rendering values. + +When a component like `` needs to render a `date` field, it doesn't import a DatePicker directly. Instead, it asks the registry: + +> *"Hey, give me the component responsible for rendering type 'date'."* + +This architecture allows you to: +1. **Override standard fields** (e.g. replace the native date picker with a fancy one). +2. **Add new field types** (e.g. add a `rating` or `signature` field). +3. **Keep bundles small** (heavy components like Code Editors are loaded only if their plugin is registered). + +## Usage + +### 1. Registering a Custom Field + +You can register a custom renderer globally, typically at your app's entry point. + +```tsx +// src/setup.tsx +import { registerFieldRenderer, type CellRendererProps } from '@object-ui/fields'; + +const MyRatingField = ({ value, onChange }: CellRendererProps) => { + return ( +
+ {[1, 2, 3, 4, 5].map(star => ( + onChange?.(star)} + style={{ color: star <= value ? 'gold' : 'grey' }} + > + ★ + + ))} +
+ ); +}; + +// Register it +registerFieldRenderer('rating', MyRatingField); +``` + +### 2. Using in Schema + +Once registered, you can simply use the new type in your JSON schema. + +```json +{ + "type": "form", + "fields": [ + { + "name": "customer_satisfaction", + "type": "rating", + "label": "Satisfaction" + } + ] +} +``` + +## Standard Fields + +Object UI comes with built-in support for the standard [ObjectStack Protocol](https://github.com/objectstack-ai/objectstack/tree/main/packages/spec) types: + +| Type | Description | +|---|---| +| `text` | Single line text | +| `textarea` | Multi-line text | +| `number` | Numeric input | +| `currency` | Currency formatting | +| `percent` | Percentage values | +| `date` | Date picker | +| `datetime` | Date & Time picker | +| `boolean` | Checkbox / Switch | +| `select` | Dropdown | +| `lookup` | Reference to another object | +| `master_detail` | Parent-child relationship | +| `user` | Person picker — searches the `sys_user` object (a lookup specialized to users) | +| `owner` | Record owner — a `user` field, typically read-only and stamped with the current user | + +## What a number field silently rewrites + +`number`, `currency`, `percent` and `geolocation` render a native +`type="number"` input. The browser — not ObjectUI — decides what that box will +accept, and it rewrites some entries **before any widget code runs**. Two +different things can happen, and only one of them is announced. + +### Announced: text the browser cannot read + +If the box is left holding something that is not a complete number, the browser +reports `validity.badInput` and these widgets now say so: the control is marked +`aria-invalid="true"` and a message is drawn under it — + +> Not saved: the text in this box is not a number. Enter a plain decimal (example: 1234.56). + +The sentence follows the reader's language (objectui#8148) — it resolves through +the `fields.number.badInput` locale key, so a console running in Chinese, +Japanese or Arabic refuses in that language. The example numeral is the +widget's own (`1234` for `number`, `1234.56` for `currency`, `12.5` for +`percent`, `30.2741` / `120.1551` for the two `geolocation` boxes) and stays +verbatim in every language. + +Measured in Chromium 141, typing any of `1e`, `1e-`, `1e+`, `5e`, `-`, `.`, +`+`, `-.` or `e` leaves the box **visibly displaying** what was typed while its +value reads empty. Before this was announced, the field simply stored nothing +and said nothing. + +### ⚠️ NOT announced: entries the browser silently truncates + +This is the important limitation, and it is deliberate rather than an oversight. + +| you paste / type | the field stores | +|---|---| +| `1.2.3` | `1.23` | +| `0x10` | `10` | +| `12abc` | `12` | + +**No warning is shown for these, and no widget-side check can add one.** The +browser filters the keystrokes or the pasted text as it arrives, so by the time +ObjectUI sees the field the discarded characters are already gone — there is +nothing left to detect. This is native `type="number"` behaviour; recovering it +would mean giving up the numeric keyboard on mobile and the `min`/`max`/`step` +spinner on every numeric field in the product. + +⛔ **So do not read "no warning" as "the value is correct."** A warning means the +browser could not read the box at all. Silence means the browser read +*something* — which may be less than you typed. When exact input matters +(reference codes, serial numbers, anything where `1.2.3` is meaningful), declare +a `text` field, not a numeric one. + +## Polymorphic pointer pairs (`referenceVia`) + +A `text` field that declares `referenceVia` holds a record id of the object its +sibling column names, row by row: `sys_approval_request.record_id` points at the +record `object_name` names, and `sys_activity.source_id` at the one +`source_object` names. The record details grid resolves the pair from the row +with `resolveRecordPointer(field, row)` and draws it with the renderer +registered under `RECORD_POINTER_CARD_TYPE`. The fields package's default draws +the record id as text; the console registers a record preview card there, so a +pointer field on a record page shows the record it points at. When the card +could read that record, its title links to the record's page in the current +app, built by the same record-link builder the page's lookup values use; a +record the reader cannot read is drawn with no link, and the card never says +why. Nothing is authored for this: the `referenceVia` declaration on the field +is the whole contract. List cells are not a call site yet. + +When the platform deletes the record a pointer names, it can say so on the row. +A pointer field that also declares `referenceDeletedWhen: { field, value }` +names a sibling `select` field and the option the platform writes there, as +`sys_approval_request.record_id` declares +`{ field: 'cancel_reason', value: 'record_deleted' }`. On a row holding that +option the details grid draws a tombstone in the option's own label (localized +when the console's bundle localizes it), reads nothing and links nothing. A +record the card cannot read on any other row still gets the cause-free text: +only the declaration means deleted. + +## Naming a registry item by name (`position-ref`, `permission-ref`) + +A `text` field that stores the **machine name** of one item of a metadata +registry — a position, a permission set — declares the matching widget, and the +record form lists that registry instead of asking for a typed name: + +```json +{ + "position": { + "type": "text", + "label": "Position", + "required": true, + "maxLength": 100, + "widget": "position-ref" + } +} +``` + +| Widget | Lists | Stores | +|---|---|---| +| `position-ref` | the `position` registry (`GET /api/v1/meta/position`) | the position's `name` | +| `permission-ref` | the `permission` registry (`GET /api/v1/meta/permission`) | the permission set's `name` | + +Like every `widget` value, the name is looked up as `field:`. Each item +reads as its label beside its name, and the value written is the name alone: no +label, no id. A stored name the registry does not list stays on screen, flagged +*not found*, so a renamed or removed item is visible rather than dropped. +`position-ref` leaves out the audience anchors, the spec's +`AUDIENCE_ANCHOR_POSITIONS` (`everyone`, `guest`), because the server refuses an +anchor as a grant target; a stored anchor still shows as its own option. The +list comes from the data source's `listMetadataItems(type)`. When the adapter +has none, the list is empty, or the load fails, the field is a text box that +takes the typed name; a failure is shown with its cause, and the box stays +editable. + +## A widget's face on the record heading + +A `text` field that declares `widget: 'recipient-picker'` or `widget: 'object-ref'` is drawn +in a list column through the read cell registered under that widget's key, which names what +the stored value references. When that field is also the object's `nameField`, the record +page heading draws the value through the same cell: a record share heads its page with the +recipient's name, not the user id it stores. Nothing is authored for this; the `widget` +declaration on the field is the whole contract. + +The heading lives in `@object-ui/components`, which this package depends on and so cannot be +imported from here the other way round. `@object-ui/components` therefore owns a slot, +`setFieldReadFaceResolver` / `resolveFieldReadFace`, and this package fills it when it loads with the +lookup the list cells make. The widget's key draws the stored text until a host registers a +face under it (the console registers its own), and while it does, the heading prints the +stored value as before. So does a host that does not load `@object-ui/fields`. + +## Using Renderers in Custom Components + +If you are building your own custom component (like a Kanban board card), you can leverage the registry to render fields without reinventing the wheel. + +```tsx +import { getCellRenderer } from '@object-ui/fields'; + +export const KanbanCard = ({ task }: { task: { name: string; assignee: string } }) => { + // Get the standard renderer for a 'user' type field + const UserRenderer = getCellRenderer('user'); + + return ( +
+

{task.name}

+
+ +
+
+ ); +}; +``` diff --git a/content/docs/sdk/guide/layout.mdx b/content/docs/sdk/guide/layout.mdx new file mode 100644 index 00000000000..e00c1e925db --- /dev/null +++ b/content/docs/sdk/guide/layout.mdx @@ -0,0 +1,917 @@ +--- +title: "Layout System" +description: "Understanding ObjectUI's layout components for building application shells and page structures" +--- + +# Layout System + +ObjectUI provides a comprehensive layout system through the `@object-ui/layout` package. This guide explains how to use layout components to build professional application structures. + +## Overview + +The layout system provides: + +- **AppShell** - Full application container with a top navbar, sidebar, and content areas +- **Page** - Individual page wrapper with header and body +- **PageHeader** - Consistent page headers with title and actions +- **SidebarNav** - Navigation sidebar with menu items + +## Installation + +The layout package is included in the core ObjectUI installation: + +```bash +npm install @object-ui/react +``` + +Layout components are automatically registered when you import ObjectUI. + +## AppShell Component + +The `AppShell` provides a complete application structure with a top navbar, a sidebar, and +a main content area. + +**`AppShell` is a React component, not an authorable JSON node.** Four of its seven props +are `React.ReactNode` slots — `sidebar`, `navbar`, `children` and `rightRail` — and a JSON +document has no way to put a node into any of them. Compose the shell in React, and render +your JSON pages *inside* it. `app-shell` is not a component key either — what a +`{ "type": "app-shell" }` node does now is measured under +[There is no `app-shell` node](#there-is-no-app-shell-node) below. + +### Basic Usage + +```tsx +import { AppShell, SidebarNav, type NavItem } from '@object-ui/layout'; +import { SchemaRenderer } from '@object-ui/react'; +import { LayoutDashboard, Settings, Users } from 'lucide-react'; + +const navItems: NavItem[] = [ + { title: 'Dashboard', href: '/dashboard', icon: LayoutDashboard }, + { title: 'Users', href: '/users', icon: Users }, + { title: 'Settings', href: '/settings', icon: Settings }, +]; + +My Application} + sidebar={} +> + + +``` + +Top-bar content goes in `navbar`. `AppShell` renders the sticky `
` element itself +and `{navbar}` is the only thing that fills it — there is no `header` prop. Main content is +the component's `children`; there is no `body` prop either. + +### Props + +`AppShellProps` (`packages/layout/src/AppShell.tsx`) declares exactly these seven. The +component destructures that fixed key list with **no rest element**, so anything else you +pass is built and then dropped on the floor. + +These are React props, passed in JSX. None of them is authorable in JSON — there is no +`app-shell` node to write them on. + +| Prop | Type | Required | Description | +| --- | --- | --- | --- | +| `sidebar` | `React.ReactNode` | no | Left sidebar node, a flex sibling of the content. Pass `SidebarNav`, or your own node. | +| `navbar` | `React.ReactNode` | no | Top-bar content. `AppShell` supplies the sticky `
` around it, so pass only what goes inside. | +| `children` | `React.ReactNode` | yes | Main content, rendered inside the content element, within the shell's one `
` landmark. | +| `className` | `string` | no | Tailwind overrides for the content element — **not** for the outer container. | +| `defaultOpen` | `boolean` | no | Initial open state of the underlying Shadcn `SidebarProvider`. Defaults to `true`. | +| `branding` | `AppShellBranding` | no | App branding, applied by `useAppShellBranding`: `primaryColor` / `accentColor` become CSS custom properties on the document root, `favicon` sets the icon link's `href`, and `title` sets `document.title`. | +| `rightRail` | `React.ReactNode` | no | Optional right-side rail. It reflows the content beside it rather than overlaying it; absent → unchanged single-pane layout. | + +### There is no `app-shell` node + +`app-shell` is not a component key. `registerLayout()` (`packages/layout/src/index.ts`) +does not register it, and nothing else in this repo does either, so a +`{ "type": "app-shell" }` node resolves to nothing and says so. Measured on this tree: + +- `SchemaRenderer` replaces the node with its error panel — + `Unknown component type: app-shell`, error code `OBJUI-001`. +- `sdui-parser` reports it before render, as an `error`-severity diagnostic with code + `unknown-component` and the message ` is not a known component`. + +It **was** registered until objectui#4841, and the registration could never produce a +shell. Four of the seven props are `React.ReactNode` slots that a JSON document cannot +fill, so a node had exactly two outcomes: `children` was stripped by `SchemaRenderer` +before a node's keys were spread as props — `AppShell` reads its `children` prop, never +`schema.children` — so the `
` element rendered **empty with nothing logged**; and a +schema written into `sidebar` / `navbar` / `rightRail` arrived as a plain object, which +React refuses to render, replacing the node with an error box. Only `className`, +`defaultOpen` and `branding` ever survived the JSON path, i.e. the best result JSON could +reach was a shell with no navigation, no top bar and an empty content area. The key was +retired under ADR-0049 (enforce-or-remove) so that this comes out as a named refusal +rather than a page that renders nothing. + +Two doors remain, one per capability: + +- **Compose in React** — `` as shown above, with your JSON pages rendered + *inside* it through `SchemaRenderer`. +- **The whole shell from metadata** — the `app-schema-renderer` node, which renders + `AppSchemaRenderer`. It builds the branding and the sidebar navigation from the app + document it carries under `schema`, below. It draws no page content: the node's + `children` are not rendered, and `objectui validate` refuses them on this node. Page + content reaches the shell only as React `children`, when a host renders + `AppSchemaRenderer` in JSX. + +The node takes three inputs: + +- `schema`: the app document, nested, with its own `"type": "app"`. It is validated as + the app document, so the document's own refusals apply inside it: `mobileNavMode` + there is refused, because the mode belongs on the node. +- `basePath`: the prefix of every href the shell generates. +- `mobileNavMode`: `"drawer"` (the default) or `"bottom_nav"`, which adds a fixed + bottom bar. + +```json +{ + "type": "app-schema-renderer", + "basePath": "/apps/crm", + "mobileNavMode": "bottom_nav", + "schema": { + "type": "app", + "name": "crm", + "title": "CRM", + "navigation": [ + { "id": "home", "type": "page", "pageName": "home", "label": "Home" } + ] + } +} +``` + +The document has one spelling. Its keys written flat on the node (`"navigation"`, +`"title"` beside `"type": "app-schema-renderer"`) are not drawn, and the strict authoring +face (`StrictAnyComponentSchema` in `@object-ui/types/zod`) refuses them as unrecognized +keys. A node without `schema` draws the shell with no branding and no navigation. + +### Features + +- Responsive layout that adapts to mobile/tablet/desktop +- Collapsible sidebar with state management +- Sticky header +- Scroll management for content area +- Consistent spacing and structure + +## Page Component + +The `Page` component provides a consistent wrapper for individual pages with optional headers. + +### Basic Usage + +```json +{ + "type": "page", + "pageType": "app", + "title": "User Management", + "description": "Manage users and permissions", + "children": { + "type": "container", + "children": [ + { "type": "text", "content": "User list goes here" } + ] + } +} +``` + +`pageType` decides whether the page draws its own `title` and `description`. `app`, `list`, +`utility` and `home` draw both, unless a titled `page:header` block in the page takes over +the heading. A page with no `pageType` is a `record` page, which draws neither: its heading +is a `page:header` block, as [Detail Page with Actions](#detail-page-with-actions) shows. +So without `"pageType": "app"`, the page above renders only its children. +[Content Width](#content-width) covers what else each type changes. + +### With Action Buttons + +A `page` node has no action row of its own. Buttons are NODES, and they go in `children`. +This is a list of products, so the page is a `list` page, which draws its `title` itself: + +```json +{ + "type": "page", + "pageType": "list", + "title": "Products", + "children": [ + { + "type": "flex", + "properties": { + "justify": "end", + "gap": 2, + "children": [ + { + "type": "button", + "label": "Add Product", + "variant": "default", + "icon": "plus" + }, + { + "type": "button", + "label": "Export", + "variant": "outline", + "icon": "download" + } + ] + } + }, + { + "type": "object-grid", + "properties": { "objectName": "products" } + } + ] +} +``` + +`label` is the button's text key — `text` is not a `ButtonSchema` key. `objectui validate` +refuses it by name, but nothing on the render path does: `BaseSchema` is `.passthrough()`, +so the rendering face keeps the unknown key and `button.tsx`, which reads `schema.label`, +renders a button with no text. + +> **⛔ `actions` on a `page` node is refused by name** (objectui#7926). This page used to +> teach `"actions": [ … ]` as a sibling of `title`, and it drew **nothing**: `PageRenderer` +> has never had a read point for the key, and `BaseSchema`'s `.passthrough()` kept the array +> rather than refusing it — so the author got a green validation and an empty page (before +> objectui#7933 it also reached the DOM as `actions="[object Object]"`). `PageNodeSchema` +> now declares the key as a refusal, so the same document fails with the remedy in the +> message instead of rendering silently short. Buttons in `children`, as above; on a record page, +> the `page:header` block's own `actions` — which are **action ids**, not nodes +> (see the [PageHeader reference](/docs/ui/pages)). + +> **⛔ `breadcrumbs` on a `page` node is refused by name** (objectui#8871). This page used +> to declare it in the Schema API block below and author it in two passages, and it drew +> **nothing**: no renderer has ever read the key, and `BaseSchema`'s `.passthrough()` kept +> the array rather than refusing it — the same silent-accept shape as `actions`, retired +> under the same ADR-0049 enforce-or-remove gate. The trail is a **node**, not a key: put +> a `breadcrumb` node in `children`, as [Breadcrumbs for Deep Navigation](#2-breadcrumbs-for-deep-navigation) +> shows. ⛔ Not the `page:header` block's `breadcrumb` either — that one is a singular +> **boolean** the renderer ignores, not a list of links. + +### Schema API + +```typescript +{ + type: 'page', + + // Header + title?: string, // Page heading; not drawn on a 'record' page, whose heading is a `page:header` block + description?: string, // Line under the heading; a 'record' page does not draw it + icon?: string, // Optional icon + // NO `actions` — refused by name (objectui#7926); put the buttons in `children` + // NO `breadcrumbs` — refused by name (objectui#8871); put a `breadcrumb` node in `children` + + // Content + children: SchemaNode, // Main page content + + // Layout + pageType?: 'record' | 'home' | 'app' | 'utility' | 'list', // default 'record'; sets the max width and who draws the heading + // NO `maxWidth` — refused by name (objectui#11318); use `pageType`, or a `container` in `children` + // NO `padding` — refused by name (objectui#11318); put a `container` with `padding` in `children` + + // Styling + className?: string, +} +``` + +### Content Width + +A `page` node has no `maxWidth` key. **`pageType`** sets how wide the page's content may +grow: `utility` is the narrowest and suits settings and forms, `record` (the default), +`list` and `app` share a wider cap, and `home` is the widest. `getPageMaxWidth` in +`packages/components/src/renderers/layout/page.tsx` holds the mapping. + +```json +{ + "type": "page", + "pageType": "utility", + "title": "Settings", + "children": { + "type": "form", + "fields": [] + } +} +``` + +A page laid out in `regions` can drop the cap altogether: a `main` region declared +`width: 'full'` makes the page full-bleed. That is the only `width` a `main` region takes. +The template decides how wide `main` is (the space `sidebar` and `aside` leave, or one grid +cell on `dashboard`), so `'small'`, `'medium'` and `'large'` on `main` are refused at that +region's `width`, as `@objectstack/spec` refuses them. Other regions keep all four values. + +`pageType` is more than a width. A `record` page leaves its heading to a `page:header` +block, so a `title` on a page with no `pageType` draws no heading. That is one more reason +the settings page above says `utility`. + +For a column narrower than the page's cap, wrap the content in a `container` and set +**its** `maxWidth`. That key is a `container` member, and the +[Container reference](/docs/ui) lists its values: + +```json +{ + "type": "page", + "pageType": "utility", + "title": "Profile", + "children": { + "type": "container", + "maxWidth": "2xl", + "children": { + "type": "form", + "fields": [] + } + } +} +``` + +> **⛔ `maxWidth` on a `page` node is refused by name** (objectui#11318). This page used to +> teach `"maxWidth": "lg"` beside `title`, with a list of sizes, and it changed **nothing**: +> `PageRenderer` has no read for the key and takes its width from `pageType`, while +> `BaseSchema`'s `.passthrough()` kept the value rather than refusing it. `PageNodeSchema` +> now declares the key as a refusal whose message names `pageType` and the `container`. + +## Page Header (`page:header`) + +`page:header` is a page's title bar: a title, an optional subtitle and, on a record page, the +record chip and the record's header actions. Its props contract is `PageHeaderProps`, the +`ComponentPropsMap['page:header']` row, and it is rendered by the record-aware +`PageHeaderRenderer` in `@object-ui/components`. The +[Page Header reference](/docs/ui/pages) documents every key, both layouts and the +styling. + +### Usage + +```json +{ + "type": "page:header", + "properties": { + "title": "{first_name} {last_name}", + "subtitle": "View and edit customer information", + "actions": ["edit_customer", "send_email"] + } +} +``` + +The props go in `properties`, which is where `PageHeaderProps` judges them: a misspelled or +removed key there is refused by name. `className` is the one key that belongs on the node +itself, beside `type`. + +`title` and `subtitle` both interpolate `{field.path}` tokens against the surrounding +record context, so the title above resolves on a record page. Unresolvable tokens collapse to +an empty string rather than leaking the raw template. + +`actions` holds action **ids**, resolved from the object's own `actions` metadata, which +keeps the definitions in one place. They are **not** `SchemaNode` nodes: the contract refuses +an entry that is not a string. Nor does the header draw `children`, so a button written there +does not render. + +### Schema API + +```typescript +{ + type: 'page:header', + className?: string, // on the node, beside `type` + properties?: { + title?: string | Record, // {field.path} tokens interpolated; derived from the record when omitted on a record page + subtitle?: string | Record, // secondary line; same interpolation + actions?: string[], // action ids, resolved against the object's own actions + recordChrome?: boolean, // default true — the record chip on a record page + showCopyId?: boolean, // default true + maxVisible?: number, // positive integer; inline action buttons (renderer default 3) + mobileMaxVisible?: number, // positive integer; the same on mobile (renderer default 1) + aria?: { ariaLabel?, ariaDescribedBy?, role? }, + }, +} +``` + +The favorites star beside the record title draws where the host wires a favorites toggle, and metadata does not control it. + +> **Write `subtitle`; there is no `description` and no `icon`.** The contract refuses +> `description` on this node and names `subtitle` in its place. It refuses `icon` by name as +> an ADR-0087 D2 tombstone — "`page:header` property `icon` was removed in +> @objectstack/spec 17.0.0 (ADR-0087 D2) — no renderer ever read it … Delete the key." The +> header's identity comes from the record chip (`recordChrome`), and each action carries its +> own `icon`. + +> **There is no `breadcrumbs` array, and `breadcrumb` draws nothing.** The contract still +> accepts a singular **boolean** `breadcrumb` until objectstack#20758 retires it; the renderer +> ignores it. A trail of links is a `breadcrumb` node — see +> [Breadcrumbs for Deep Navigation](#2-breadcrumbs-for-deep-navigation). + +> **`page-header` is retired; author `page:header`.** `page-header` (and its namespaced form +> `layout:page-header`) is no longer registered (objectui#10859): a node written that way +> renders the "Unknown component type" panel, and `objectui validate` refuses it at `type`. +> Write `{ "type": "page:header", "properties": { "title": "…", "subtitle": "…" } }`; the +> secondary line is `subtitle`. + +## SidebarNav Component + +The `SidebarNav` provides a collapsible navigation sidebar with menu items. + +**`SidebarNav` is a React component, and `sidebar-nav` is not a component key at all.** +`registerLayout()` (`packages/layout/src/index.ts`) registers two keys — `page:card` and +`app-schema-renderer` — and nothing in this repo registers `sidebar-nav`. What a +`{ "type": "sidebar-nav" }` node actually does is measured under +[There is no `sidebar-nav` node](#there-is-no-sidebar-nav-node) below. Compose the nav in +React, or declare it as the app's `navigation` metadata when the tree has to come from JSON. + +### Basic Usage + +`SidebarNav` renders a Shadcn `Sidebar`, so it must be inside a `SidebarProvider` — +`AppShell` supplies one. Its rows are `NavLink`s, so it also needs a router above it. + +```tsx +import { AppShell, SidebarNav, type NavItem } from '@object-ui/layout'; +import { SchemaRenderer } from '@object-ui/react'; +import { BarChart3, LayoutDashboard, Users } from 'lucide-react'; + +const navItems: NavItem[] = [ + { title: 'Dashboard', href: '/dashboard', icon: LayoutDashboard }, + { title: 'Users', href: '/users', icon: Users, badge: '12' }, + { + title: 'Reports', + href: '/reports', + icon: BarChart3, + children: [ + { title: 'Sales', href: '/reports/sales' }, + { title: 'Analytics', href: '/reports/analytics' }, + ], + }, +]; + +}> + +; +``` + +Three things that block a copy-paste: `icon` is a **component**, not an icon name — +`SidebarNav` renders it as ``. Nested rows go in `children`; `items` is a key +on `NavGroup`, never on a `NavItem`. And `href` is **required** on every item, including +one that only expands children — it is the row's React key as well as its link target. + +### Props + +`SidebarNavProps` (`packages/layout/src/SidebarNav.tsx`) declares exactly these six. + +| Prop | Type | Required | Description | +| --- | --- | --- | --- | +| `items` | `NavItem[] \| NavGroup[]` | yes | The rows. A flat `NavItem[]`, or `NavGroup[]` for labelled sections — the array's first element decides which. | +| `title` | `string` | no | Group label shown above a flat, ungrouped `items` list. Defaults to `"Application"`. | +| `className` | `string` | no | Tailwind overrides for the `Sidebar` root. | +| `collapsible` | `"offcanvas" \| "icon" \| "none"` | no | How the sidebar collapses at or above 768px. Defaults to `"icon"`. Not a boolean. | +| `searchEnabled` | `boolean` | no | Show a search input that filters rows by title, children included. Defaults to `false`. | +| `searchPlaceholder` | `string` | no | Placeholder for that input. Defaults to `"Search…"`. | + +#### `NavItem` + +| Key | Type | Required | Description | +| --- | --- | --- | --- | +| `title` | `string` | yes | The row's label. | +| `href` | `string` | yes | Link target, and the row's React key. | +| `icon` | `React.ComponentType<{ className?: string }>` | no | Rendered as `` — pass the Lucide component, not its name. | +| `badge` | `string \| number` | no | Trailing badge content. Rendered whenever it is not `null`/`undefined`, so `0` shows. | +| `badgeVariant` | `'default' \| 'destructive' \| 'outline'` | no | Badge styling. Defaults to `'default'`. | +| `children` | `NavItem[]` | no | One level of nested rows; the parent becomes a collapsible trigger. | + +#### `NavGroup` + +| Key | Type | Required | Description | +| --- | --- | --- | --- | +| `label` | `string` | yes | Section heading, shown in place of `title`. | +| `items` | `NavItem[]` | yes | The section's rows. | + +There is no `active` key — the active row is derived from the router +(`pathname === item.href`), never declared. There is no `disabled` key either, and no +`defaultOpen`: that one is `AppShell`'s prop, not this component's. + +### There is no `sidebar-nav` node + +`sidebar-nav` was never registered, so the node does not resolve to anything. Rendering +`{ "type": "sidebar-nav", "items": [...] }` produces the renderer's red error box instead +of a sidebar: + +``` +Unknown component type: sidebar-nav +💡 Ensure the component is registered via registry.register() before rendering. + Check for typos in the component type name. (OBJUI-001) +``` + +This is louder than the `app-shell` case above — nothing is silently dropped, because +nothing is parsed as props at all. The whole sidebar is replaced by the error panel. + +When the navigation tree genuinely has to come from JSON, it is application metadata: +the app document's `navigation` items (`{ "type": "app", "navigation": [...] }`, which +`objectui validate` accepts), drawn by the shell — the console's sidebar, or +`AppSchemaRenderer` (the whole shell from metadata, above). Both hand the items to the +`NavigationRenderer` component, and its items are JSON-shaped: `icon` really is a string +name there, resolved by `resolveIcon`. There is no page node for navigation: +`navigation-renderer` was retired (objectui#11441), so a node written with that type +renders the same "Unknown component type" panel. + +### Features + +- Nested menu items (2 levels) with collapsible expand/collapse +- Active state highlighting via React Router +- Icon support (Lucide icons) +- Badge/counter support with variant styling (`default`, `destructive`, `outline`) +- NavGroup support for grouped navigation sections +- Built-in search filtering (`searchEnabled`) across all items and children +- Collapse/expand animation + +## Common Layout Patterns + +### Full Application Layout + +The shell is React; the page inside it is your JSON. + +```tsx +import { AppShell, SidebarNav, type NavItem } from '@object-ui/layout'; +import { SchemaRenderer } from '@object-ui/react'; +import { SidebarTrigger } from '@object-ui/components'; +import { Home, Package, ShoppingCart } from 'lucide-react'; + +const navItems: NavItem[] = [ + { title: 'Home', href: '/', icon: Home }, + { title: 'Products', href: '/products', icon: Package }, + { title: 'Orders', href: '/orders', icon: ShoppingCart }, +]; + + + + My App +
+ } + sidebar={} + defaultOpen +> + + +``` + +`AppShell` adds no controls of its own to the top bar — the sidebar toggle above is one +you render yourself, in `navbar`. There is no `sidebarCollapsible` prop: the sidebar's +collapse behaviour belongs to the sidebar node you pass (`SidebarNav`'s `collapsible`), +and `defaultOpen` is the shell's own initial-state prop. + +### Landing Page (No Sidebar) + +Omit `sidebar` and the content fills the width under the top bar. + +```tsx +Welcome}> + + +``` + +### Settings Page with Tabs + +A settings page is a `utility` page, which gives it the narrowest content width (see +[Content Width](#content-width)): + +```json +{ + "type": "page", + "pageType": "utility", + "title": "Settings", + "children": { + "type": "tabs", + "items": [ + { + "label": "General", + "value": "general", + "content": { "type": "form", "fields": [] } + }, + { + "label": "Security", + "value": "security", + "content": { "type": "form", "fields": [] } + }, + { + "label": "Notifications", + "value": "notifications", + "content": { "type": "form", "fields": [] } + } + ] + } +} +``` + +### Detail Page with Actions + +Same rule as above, and it governs the trail too: the breadcrumb and the buttons are both +**nodes in `children`** — never a `breadcrumbs` or an `actions` key on the page. + +A detail page is a `record` page. That is the default, and it is named below so the snippet +says so. A record page draws no `title` and no `description` of its own: its heading is +the [`page:header`](#page-header-pageheader) block, so the record's name goes in that +block's `properties.title`, not on the page. + +```json +{ + "type": "page", + "pageType": "record", + "children": [ + { + "type": "breadcrumb", + "items": [ + { "label": "Home", "href": "/" }, + { "label": "Customers", "href": "/customers" }, + { "label": "Acme Corporation" } + ] + }, + { + "type": "page:header", + "properties": { "title": "Acme Corporation" } + }, + { + "type": "flex", + "properties": { + "justify": "end", + "gap": 2, + "children": [ + { + "type": "action:button", + "properties": { + "name": "edit_record", + "label": "Edit", + "variant": "default", + "icon": "pencil", + "actionType": "editRecord" + } + }, + { + "type": "action:button", + "properties": { + "name": "delete_record", + "label": "Delete", + "variant": "destructive", + "icon": "trash", + "actionType": "deleteRecord" + } + } + ] + } + }, + { + "type": "card", + "children": [ + { "type": "text", "content": "Record details..." } + ] + } + ] +} +``` + +A button that RUNS something is an `action:button` node, not a `button` carrying an +`onClick`. Its props — `actionType` and the rest of the block's row — go in the node's +`properties` bag, as `@objectstack/spec` declares them; the spec's strict page component +refuses them written flat on the node, and `SchemaRenderer` hoists the bag onto the node +before the renderer reads it. `ButtonSchema.onClick` is declared as a runtime slot for a host-supplied +function and the zod mirror refuses it BY NAME — JSON has no function value, and no +handler key consumes a declarative action object. The refusal is not the whole cost: +`onClick` is on `SDUI_DOM_PASS_THROUGH_KEYS`, so an authored string or object is +forwarded to the real DOM listener slot, and React throws the moment anyone clicks. +Measured, React's own error: "Expected `onClick` listener to be a function, instead got +a value of `object` type." + +The handler name goes in `actionType`, which `action:button` forwards to the action +runner as the action's type; the runner dispatches to the handler registered under it. +Same spelling as [Record Edit Modes](/docs/ui). + +The rest of the action is authored on the same node. `action:button` and `action:icon` +evaluate `visible` and `disabled` themselves (a boolean, a CEL string, or a +`{ dialect, source }` envelope), and forward the action-definition keys to the runner: +`params`, `confirmText`, `successMessage`, `errorMessage`, `toast`, `bodyExtra`, +`bodyShape`, `operation`, `onSuccess`, `objectName` and the others each +registration's `inputs` lists, with a `description` per key. For an `api` action, write +the request URL in `target`: `endpoint` is not published, because the console's `api` +handler never reads it. + +An `operation: update` node runs the declared action its `name` names: the platform +action route writes that declaration's `patch` under the collected params. A `patch` +written on the node itself is not forwarded and writes nothing. + +`undoable: true` on an `operation: update` makes the success toast offer Undo, which +writes back the prior values of the fields the update wrote: the named declaration's +`patch` fields and the collected params. `action:button` reads those values off the +record in scope: the record page's record, or the row the host binds through `data`. A +button with no record in scope offers no Undo, because there is no row to restore; +neither does one whose `recordId` names a record other than the one in scope. + +## Responsive Behavior + +The shell has exactly **one** layout breakpoint, at **768px** — Tailwind's `md`, and +`MOBILE_BREAKPOINT` in `packages/components/src/hooks/use-mobile.tsx`. There is no +separate tablet tier: nothing in `AppShell`, `SidebarNav` or the Shadcn sidebar underneath +them reads `lg` (1024px), so 800px and 1400px get the same layout. + +### Sidebar, at or above 768px + +- Rendered inline, as a flex sibling of the content — not an overlay. +- How it collapses is the sidebar node's own `collapsible` prop: `"icon"` (the `SidebarNav` + default) leaves an icon rail, `"offcanvas"` slides it fully out, `"none"` pins it open. +- `AppShell`'s `defaultOpen` picks the initial state, and defaults to `true`. + +### Sidebar, below 768px + +- It leaves the layout entirely and becomes a `Sheet` overlay (18rem) above the content, + which is why the content is full-width there. +- **Nothing opens it for you.** `AppShell`'s header renders `{navbar}` and nothing else — + it adds no controls of its own, in particular **no sidebar toggle**. Render a + `SidebarTrigger` inside `navbar` yourself; otherwise the only way in is the + `SidebarProvider` keyboard shortcut, `Cmd/Ctrl + B`, which no touch device has. + +### Header and content + +- The header is `h-12` (3rem / 48px) at **every** breakpoint — there is no compact + variant — and spans the full viewport width at every size. The only thing about it that + responds is horizontal padding, and it turns at `sm` (640px), not 768: `px-2 sm:px-4`. +- Content padding steps three ways — `p-3`, `sm:p-4` (640px), `md:p-6` (768px) — with a + taller `pb-20` below `sm` only. + +## Styling and Customization + +### Custom Classes + +Add Tailwind classes to layout components: + +```tsx + + My App + + } + sidebar={} +> + + +``` + +`className` is the only class hook `AppShell` itself takes, and it lands on the content +element (a `
` inside the shell's one `
` landmark) — not on the outer container. There is **no** per-slot className: +`headerClassName`, `sidebarClassName` and `contentClassName` do not exist. The top bar and +the sidebar are nodes you build, so style them where you build them, as above. + +### Page Padding + +A `page` node has no padding switch. Its wrapper always insets the content: `p-3`, then +`md:p-4`, then `lg:p-6`. The padding you control is a `container`'s. Its `padding` is a +step on the container's spacing scale: one of 0 to 8, 10, 12 or 16, and `0` means none. +Any other number is refused: + +```json +{ + "type": "page", + "children": { + "type": "container", + "maxWidth": false, + "padding": 8, + "children": [] + } +} +``` + +`"maxWidth": false` keeps the container as wide as the page. Leave it out and the container +also caps its own width at its default, `xl`. The +[Container reference](/docs/ui) lists both scales. + +> **⛔ `padding` on a `page` node is refused by name** (objectui#11318). This page used to +> teach `"padding": false` here, and it removed **nothing**: the page wrapper kept its inset, +> because `PageRenderer` has no read for the key and `BaseSchema`'s `.passthrough()` kept the +> value rather than refusing it. The refusal's message names the `container` and its `padding`. + +## Best Practices + +### 1. Consistent Structure + +Compose the shell once and let the page JSON change per route: + +```tsx +// One shell for the whole app; `pageSchema` is whatever the route resolves to. +}> + + +``` + +### 2. Breadcrumbs for Deep Navigation + +Add a breadcrumb trail to help users navigate. It is a **node in `children`**, not a key on the +page — `breadcrumb`, singular, is the registered renderer: + +```json +{ + "type": "breadcrumb", + "items": [ + { "label": "Home", "href": "/" }, + { "label": "Products", "href": "/products" }, + { "label": "Electronics", "href": "/products/electronics" }, + { "label": "Laptops" } + ], + "separator": "/", + "maxItems": 3 +} +``` + +`separator` defaults to `/`, and `maxItems` collapses the middle of a long trail behind an +ellipsis while keeping the first crumb and the current page. See the +[Breadcrumb reference](/docs/sdk/components/data-display/breadcrumb) for the per-key face. + +⛔ Not `"breadcrumbs"` on the `page` node — that key has no reader and is refused by name +(objectui#8871), the same way `actions` is. ⛔ Nor the `page:header` block's `breadcrumb`, +a **boolean** the renderer ignores rather than a list of links. + +### 3. Action Buttons at the Top of the Body + +Place primary actions in the first `children` node, so they sit above the content. A list +of orders is a `list` page, which draws its `title` as the heading above them: + +```json +{ + "type": "page", + "pageType": "list", + "title": "Orders", + "children": [ + { + "type": "flex", + "properties": { + "justify": "end", + "gap": 2, + "children": [ + { "type": "button", "label": "New Order", "variant": "default" } + ] + } + } + ] +} +``` + +⛔ Not `"actions"` on the `page` node — that key has no reader and is refused by name +(objectui#7926). A record page has a second door: the `page:header` block, whose `actions` +are **action ids** resolved from the object's own actions metadata, not nodes +(see the [PageHeader reference](/docs/ui/pages)). + +### 4. Constrained Width for Forms + +Give forms and reading content a narrow column: a `container` with its own `maxWidth`, +inside the page. ⛔ Not `maxWidth` on the `page` node, which is refused by name +(objectui#11318); the page's own cap comes from `pageType` (see [Content Width](#content-width)). + +```json +{ + "type": "page", + "pageType": "utility", + "children": { + "type": "container", + "maxWidth": "2xl", + "children": { + "type": "form", + "fields": [] + } + } +} +``` + +### 5. Sidebar Organization + +Group related items with `NavGroup`. Pass groups instead of a flat list and `SidebarNav` +labels each section and draws the separator between them itself — there is no `divider` +item, and a row is either a link or a group, never both: + +```tsx +import { SidebarNav, type NavGroup } from '@object-ui/layout'; +import { DollarSign, Home, Settings } from 'lucide-react'; + +const navGroups: NavGroup[] = [ + { + label: 'Overview', + items: [{ title: 'Dashboard', href: '/', icon: Home }], + }, + { + label: 'Sales', + items: [ + { + title: 'Sales', + href: '/sales', + icon: DollarSign, + children: [ + { title: 'Orders', href: '/orders' }, + { title: 'Invoices', href: '/invoices' }, + ], + }, + ], + }, + { + label: 'System', + items: [{ title: 'Settings', href: '/settings', icon: Settings }], + }, +]; + +; +``` + +## Related Documentation + +- [Components Overview](/docs/sdk/components) - All available components +- [Schema Rendering](/docs/sdk/guide/schema-rendering) - How schemas work +- [Architecture Overview](/docs/sdk/guide/architecture-overview) - System architecture diff --git a/content/docs/sdk/guide/meta.json b/content/docs/sdk/guide/meta.json new file mode 100644 index 00000000000..5937540872f --- /dev/null +++ b/content/docs/sdk/guide/meta.json @@ -0,0 +1,18 @@ +{ + "title": "Guide", + "pages": [ + "quick-start", + "architecture-overview", + "schema-rendering", + "schema-overview", + "layout", + "data-source", + "fields", + "component-registry", + "theming", + "plugins", + "plugin-development", + "building-crud-app", + "troubleshooting" + ] +} diff --git a/content/docs/sdk/guide/plugin-development.mdx b/content/docs/sdk/guide/plugin-development.mdx new file mode 100644 index 00000000000..91308fd6054 --- /dev/null +++ b/content/docs/sdk/guide/plugin-development.mdx @@ -0,0 +1,552 @@ +--- +title: "Custom Plugin Development" +--- + +This guide walks you through creating custom ObjectUI plugins — from scaffolding to publishing. Plugins extend ObjectUI with new view types, field widgets, or complex interactive components while keeping your application bundle lean through lazy loading. + +## What Is an ObjectUI Plugin? + +A plugin is a self-contained package that registers one or more components into the [Component Registry](/docs/sdk/guide/component-registry). When a JSON schema references a plugin's component type, the renderer resolves and renders it automatically. + +Plugins differ from regular components in two ways: + +- **Lazy-loaded** — heavy dependencies are code-split and fetched on demand. +- **Self-registering** — importing the package is enough; no manual wiring required. + +Official plugins (`@object-ui/plugin-grid`, `@object-ui/plugin-kanban`, `@object-ui/plugin-charts`, etc.) all follow this pattern, and your custom plugins should too. + +## Plugin Anatomy + +Every plugin has three key parts: + +``` +packages/plugin-board/ +├── src/ +│ ├── index.tsx # Entry point: lazy wrapper + ComponentRegistry.register() +│ ├── BoardImpl.tsx # Heavy implementation (imported lazily) +│ ├── BoardImpl.test.tsx # Tests +│ └── types.ts # TypeScript interfaces & schema types +├── package.json +├── vite.config.ts +├── tsconfig.json +└── README.md +``` + +| File | Role | +|------|------| +| `index.tsx` | Lightweight entry — sets up `React.lazy()`, `Suspense` fallback, and calls `ComponentRegistry.register()`. | +| `BoardImpl.tsx` | The actual renderer. All heavy dependencies live here so they are tree-shaken from the initial bundle. | +| `types.ts` | Schema interfaces extending `BaseSchema` from `@object-ui/types`. | + +## Scaffolding With the CLI + +The fastest way to start is the `create-plugin` generator: + +```bash +npx @object-ui/create-plugin board --description "Kanban-style board view" + +# Or with pnpm / npm create aliases: +pnpm create @object-ui/plugin board +npm create @object-ui/plugin board +``` + +This produces a ready-to-build plugin under `packages/plugin-board/` with the correct `package.json`, Vite config, test file, and registry call already in place. That directory — and the anatomy shown above — is what the generator writes into **your** workspace (`/packages/plugin-`, see `packages/create-plugin/src/index.ts`); it is not a package that ships in this repository, so do not expect to find it in a fresh ObjectUI checkout. + +After scaffolding, install dependencies: + +```bash +pnpm install +``` + +## Implementing a Custom View Plugin + +Let's build a **board** view plugin that renders items in columns (similar to a Kanban but simplified). + +### 1. Define the Schema Types + +```typescript +// src/types.ts +import type { BaseSchema } from '@object-ui/types'; + +export interface BoardColumn { + id: string; + title: string; +} + +export interface BoardItem { + id: string; + columnId: string; + title: string; + description?: string; +} + +export interface BoardSchema extends BaseSchema { + type: 'board'; + columns: BoardColumn[]; + items: BoardItem[]; + onItemMove?: (itemId: string, toColumnId: string) => void; +} + +export interface BoardProps { + schema: BoardSchema; + className?: string; +} +``` + +### 2. Build the Implementation + +```tsx +// src/BoardImpl.tsx +import React from 'react'; +import { Card, CardHeader, CardTitle, CardContent } from '@object-ui/components'; +import { cn } from '@object-ui/components'; +import type { BoardProps } from './types'; + +export default function BoardImpl({ schema, className }: BoardProps) { + const { columns, items } = schema; + + return ( +
+ {columns.map((col) => ( +
+

{col.title}

+ {items + .filter((item) => item.columnId === col.id) + .map((item) => ( + + + {item.title} + + {item.description && ( + + {item.description} + + )} + + ))} +
+ ))} +
+ ); +} +``` + +### 3. Create the Entry Point + +```tsx +// src/index.tsx +import React, { Suspense } from 'react'; +import { ComponentRegistry } from '@object-ui/core'; +import { Skeleton } from '@object-ui/components'; + +const LazyBoard = React.lazy(() => import('./BoardImpl')); + +export const BoardRenderer: React.FC<{ schema: any; [key: string]: any }> = ({ + schema, + ...props +}) => ( + }> + + +); + +// Auto-register on import +ComponentRegistry.register('board', BoardRenderer, { + namespace: 'plugin-board', + label: 'Board View', + category: 'plugin', + inputs: [ + { name: 'columns', type: 'array', required: true }, + { name: 'items', type: 'array', required: true }, + ], + defaultProps: { + columns: [ + { id: 'todo', title: 'To Do' }, + { id: 'done', title: 'Done' }, + ], + items: [], + }, +}); + +export { default as BoardImpl } from './BoardImpl'; +export type { BoardSchema, BoardProps, BoardColumn, BoardItem } from './types'; +``` + +Now any schema with `"type": "board"` will resolve to your component. + +## What Reaches the DOM + +`SchemaRenderer` hands a registered component far more than the keys it +declares: the node itself as `schema`, every other key the author wrote on the +node, the contents of its `props` bag, the runtime props it injects (`bind`, +`events`, the camelCase `ariaLabel` the author wrote, the data-source adapter) +and whatever its own host passed. React writes unknown lowercase props straight +onto an element and stringifies objects, so a component that ends in a bare +`{...props}` spread onto an element puts all of it on the page — +`schema="[object Object]"`, `datasource="[object Object]"`, a `name` on a `div`, +and every authored key the widget never declared. Nothing warns: React stays +silent for lowercase names. + +**The contract for a registered widget's host element** (objectui#4425, +phase-2 ruling, comment 5270759246): it receives only what `toDomProps` from `@object-ui/core` +passes — the SDUI contract's declared DOM keys (`id`, `className`, `role`, +`tabIndex` and the rest of `SDUI_DOM_PASS_THROUGH_KEYS`) and the open `aria-*` +and `data-*` families. Everything else the renderer hands a widget is either +**consumed** — read by name and turned into behaviour — or dropped. It is a +whitelist rather than a list of keys to strip, because the set of keys an author +may write is unbounded and a strip list can only name the ones that exist today. + +```tsx +import type { HTMLAttributes, ReactNode } from 'react'; +import { toDomProps, type SduiDomPassThroughKey } from '@object-ui/core'; + +// The DOM pass-through this widget declares is exactly what the whitelist +// forwards — named from the whitelist's own key type, so the two cannot drift. +interface BoardFrameProps extends Pick, SduiDomPassThroughKey> { + children?: ReactNode; +} + +export function BoardFrame({ className, children, ...props }: BoardFrameProps) { + return ( + // Spread FIRST, so the attributes the widget computes stay authoritative. +
+ {children} +
+ ); +} +``` + +A widget that really means to forward something beyond that set **declares** +it on its props type and forwards it **by name** — the way `style` reaches the +layout containers. Never reopen the spread to get it. A component that is both +registered and exported for direct React use may keep a wider declared +`HTMLAttributes` pass-through on its direct door, as `@object-ui/plugin-dashboard`'s +metric cards do; what the renderer hands it still goes through the whitelist. + +Field widgets follow the same rule with their own declared set: `@object-ui/fields` +exports a `toDomProps` that adds the form-control keys `name` and `disabled` +(see [Field Widget Props](/docs/data-modeling/field-types)). + +**The gate.** `packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx` +renders every registered widget of the packages it covers through the real +`SchemaRenderer`, with planted canary keys from every family above, and fails +on any attribute HTML does not define. A known leak is a ledger row in that +file naming the issue that owns its fix, and the ledger is a two-way ratchet: a +new leak fails the gate, and so does a fixed one until its row is deleted. + +## Implementing a Custom Field Widget + +Field widgets follow the `FieldWidgetComponentProps` interface from `@object-ui/fields`. + +```typescript +// FieldWidgetComponentProps shape (from packages/fields/src/widgets/types.ts) +import type { FieldMetadata } from '@object-ui/types'; + +type FieldWidgetComponentProps = { + value: T; + onChange: (val: T) => void; + field: FieldMetadata; + readonly?: boolean; + disabled?: boolean; + className?: string; + error?: string; +}; +``` + +The validation slot is named `error`, matching `FieldWidgetPropsSchema` in +`@objectstack/spec/ui` — the published contract a widget is written against. +The form renderer supplies it from the active validation message. + +### `field` is the only metadata carrier (v17, breaking) + +Before v17 a widget could receive its metadata under **either** `field` or +`schema`, depending on which host rendered it, so widgets written in that era +resolve their config as `field || schema`. `schema` has been removed from +`FieldWidgetComponentProps` in v17: **read `props.field`, full stop.** Reading +`props.schema` now yields `undefined`. + +`schema` itself is not going anywhere — it is the universal SDUI node +`SchemaRenderer` hands to *every* registered component (`element:*`, `page:*`, +grids, reports). That is precisely why a **field widget** needs an adapter when +it is rendered from a schema node instead of from a form. Wrap it once, at +registration, and the widget only ever implements one contract: + +```tsx +import { ComponentRegistry } from '@object-ui/core'; +import { withFieldCarrier } from '@object-ui/fields'; + +ComponentRegistry.register('color', withFieldCarrier(ColorPickerField), { + namespace: 'field', +}); +``` + +`withFieldCarrier` forwards the node by reference (nothing is copied or +dropped) and consumes `schema` so it never reaches the DOM. Every built-in +field widget is registered through it. + +### Who renders what + +The widget and the form renderer split validation display, and the split is +not optional: + +| Concern | Owner | +|---|---| +| `aria-invalid` on the input | **the widget** — only it renders the input element | +| the required marker (`*`) | **the form renderer** (``) | +| the message TEXT | **the form renderer** (``) | + +So consume `error` as a **boolean signal** — `aria-invalid={!!error}` — and do +not render the message yourself. The form already prints it below the control; +a widget that prints it too shows the user the same sentence twice. For the +same reason `required` is not in the props: the marker has one author. + +### Example: Color Picker Field + +```tsx +// src/ColorPickerField.tsx +import React from 'react'; +import { Input } from '@object-ui/components'; +import type { FieldWidgetComponentProps } from '@object-ui/fields'; + +export function ColorPickerField({ + value, + onChange, + field, + readonly, + disabled, + error, +}: FieldWidgetComponentProps) { + if (readonly) { + return ( +
+ + {value || '—'} +
+ ); + } + + return ( +
+ onChange(e.target.value)} + disabled={disabled} + className="h-8 w-8 cursor-pointer rounded border-0 p-0" + /> + onChange(e.target.value)} + placeholder={field?.placeholder || '#000000'} + disabled={disabled} + className="font-mono text-sm" + // The whole job of `error` here: tell assistive tech the field failed. + // The message text is rendered by the form, not by this widget. + aria-invalid={!!error} + /> +
+ ); +} +``` + +Register it as a field widget: + +```tsx +// src/index.tsx +import { ComponentRegistry } from '@object-ui/core'; +import { withFieldCarrier } from '@object-ui/fields'; +import { ColorPickerField } from './ColorPickerField'; + +ComponentRegistry.register('field-color', withFieldCarrier(ColorPickerField), { + namespace: 'plugin-board', + label: 'Color Picker', + category: 'field', + inputs: [ + { name: 'value', type: 'string', label: 'Value' }, + { name: 'placeholder', type: 'string', label: 'Placeholder' }, + ], +}); + +export { ColorPickerField }; +``` + +## Using the ComponentRegistry + +### Namespaced Registration + +Namespaces prevent type collisions between plugins: + +```tsx +import { ComponentRegistry } from '@object-ui/core'; + +// Register with a namespace — accessible as 'plugin-board:board' AND 'board' +ComponentRegistry.register('board', BoardRenderer, { + namespace: 'plugin-board', +}); + +// Explicit lookup by namespace +ComponentRegistry.get('board', 'plugin-board'); + +// Fallback lookup (works when the type is unambiguous) +ComponentRegistry.get('board'); +``` + +Use `skipFallback: true` in the metadata if you do **not** want the component to be available without a namespace prefix. + +### Querying Registered Components + +```tsx +import { ComponentRegistry } from '@object-ui/core'; + +ComponentRegistry.has('board'); // boolean +ComponentRegistry.getAllTypes(); // string[] +ComponentRegistry.getNamespaceComponents('plugin-board'); // RegistryComponentConfig[] +``` + +## Plugin Configuration & Schema Types + +Define your schema interface in `types.ts` and extend `BaseSchema`: + +```typescript +import type { BaseSchema } from '@object-ui/types'; + +export interface BoardSchema extends BaseSchema { + type: 'board'; + columns: BoardColumn[]; + items: BoardItem[]; +} +``` + +Declare `ComponentInput` entries when registering: they are what the published manifest (`sdui.manifest.json`) and the JSX-page compiler's diagnostics read. Each entry carries the seven keys the manifest forwards — `name`, `type`, `of`, `required`, `enum`, `binding`, `description`; a default belongs in the renderer's own fallback read and, for the author, in `description` (`label`, `defaultValue` and `advanced` are retired keys — nothing ever read them). At the component level the manifest carries one marker of its own: `tier: 'html'` on the intrinsic HTML elements a `kind:'html'` page may author (`HTML_TIER_INTRINSICS` in `@object-ui/core`). An entry without it is a curated block; a reader that means the curated vocabulary filters on it, a reader that whitelists tags takes every key. + +`of` is the coarse kind of an `array`'s elements, or of the values of an `object` used as a map. Declare it whenever the contract admits exactly one kind there: `type: 'array'` alone says a value is a list and stops, so a member of the wrong shape reaches the renderer with nothing anywhere reporting it. With `of` declared, the parser reports a member no declared kind accepts, and the generated `sdui-intrinsics.d.ts` types the elements (`string[]` rather than `unknown[]`). Like `type`, it names a KIND and never a domain: `of: 'object'` says the elements are objects, not which keys they carry — spell that out in `description`, and let `os validate` judge the values. A member contract that genuinely accepts several kinds takes the array form (`of: ['string', 'object']`), or no `of` at all: + +```tsx +ComponentRegistry.register('board', BoardRenderer, { + inputs: [ + { name: 'columns', type: 'array', of: 'object', required: true }, + { name: 'items', type: 'array', of: 'object', required: true }, + { + name: 'layout', + type: 'enum', + enum: ['horizontal', 'vertical'], + description: 'Defaults to "horizontal" — the renderer\'s own fallback', + }, + ], +}); +``` + +## Testing Plugins + +ObjectUI uses **Vitest + React Testing Library**. Place tests next to the implementation. + +```tsx +// src/BoardImpl.test.tsx +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +// `toBeInTheDocument` is a jest-dom matcher, not a Vitest one — without this +// import the assertions below do not type-check and do not run. +import '@testing-library/jest-dom'; +import BoardImpl from './BoardImpl'; + +const schema = { + type: 'board' as const, + columns: [ + { id: 'todo', title: 'To Do' }, + { id: 'done', title: 'Done' }, + ], + items: [ + { id: '1', columnId: 'todo', title: 'Write tests' }, + { id: '2', columnId: 'done', title: 'Ship plugin' }, + ], +}; + +describe('BoardImpl', () => { + it('renders all columns', () => { + render(); + expect(screen.getByText('To Do')).toBeInTheDocument(); + expect(screen.getByText('Done')).toBeInTheDocument(); + }); + + it('renders items in correct columns', () => { + render(); + expect(screen.getByText('Write tests')).toBeInTheDocument(); + expect(screen.getByText('Ship plugin')).toBeInTheDocument(); + }); + + it('handles empty items gracefully', () => { + render(); + expect(screen.getByText('To Do')).toBeInTheDocument(); + }); +}); +``` + +Run tests: + +```bash +pnpm vitest run packages/plugin-board +``` + +## Publishing Guidelines + +### Package Checklist + +Before publishing, verify: + +- [ ] `package.json` has correct `name`, `version`, `exports`, and `peerDependencies`. +- [ ] `react` and `react-dom` are **peer** dependencies, not direct dependencies. +- [ ] `@object-ui/core` and `@object-ui/components` are in `devDependencies` (or `peerDependencies`). +- [ ] `vite.config.ts` marks React and ObjectUI packages as **external**. +- [ ] Types are exported via `"types"` field in `package.json`. +- [ ] All tests pass (`pnpm vitest run`). +- [ ] The entry point is lightweight — heavy code lives in `*Impl.tsx` files. + +### Build & Verify + +```bash +pnpm build --filter @object-ui/plugin-board +ls -lh packages/plugin-board/dist/ +``` + +The entry chunk should be under 1 KB; the lazy chunk carries the bulk. + +### Publish + +```bash +cd packages/plugin-board +npm publish --access public +``` + +### Consumers Install & Use + +```bash +pnpm add @object-ui/plugin-board +``` + +```tsx +// app/main.tsx — import once, auto-registers +import '@object-ui/plugin-board'; +``` + +```json +{ + "type": "board", + "columns": [ + { "id": "todo", "title": "To Do" }, + { "id": "done", "title": "Done" } + ], + "items": [ + { "id": "1", "columnId": "todo", "title": "Write docs" } + ] +} +``` + +## Related Documentation + +- [Component Registry](/docs/sdk/guide/component-registry) — registry internals and advanced usage +- [Plugins Overview](/docs/sdk/guide/plugins) — official plugin catalog +- [Schema Rendering](/docs/sdk/guide/schema-rendering) — how schemas become UI +- [Fields Guide](/docs/sdk/guide/fields) — built-in field widgets and `FieldWidgetComponentProps` diff --git a/content/docs/sdk/guide/plugins.mdx b/content/docs/sdk/guide/plugins.mdx new file mode 100644 index 00000000000..be2031a16d0 --- /dev/null +++ b/content/docs/sdk/guide/plugins.mdx @@ -0,0 +1,543 @@ +--- +title: "Plugins" +--- + +Object UI supports a powerful plugin system that allows you to extend the framework with additional components. Plugins are separate packages that load on-demand, keeping your main application bundle small while providing rich functionality. + +## Overview + +Plugins are lazy-loaded component packages that: + +- **Auto-register** components when imported +- **Lazy-load** heavy dependencies on-demand +- **Keep bundles small** - only load when needed +- **Are type-safe** with full TypeScript support +- **Follow best practices** with built-in loading states + +## Official Plugins + +Object UI provides 14+ official plugins for common use cases: + +### Data Visualization & Dashboards + +#### [@object-ui/plugin-charts](/docs/sdk/plugins/plugin-charts) + +Data visualization components powered by Recharts. + +- Bar, line, area, and pie charts +- Responsive design +- Customizable colors +- Lazy-loaded (~80 KB) + +[Read full documentation →](/docs/sdk/plugins/plugin-charts) + +--- + +#### [@object-ui/plugin-dashboard](/docs/sdk/plugins/plugin-dashboard) + +Dashboard layouts with metric cards and widgets. + +- Dashboard grid layouts +- Metric/KPI cards with trends +- Widget system +- Lazy-loaded (~22 KB) + +[Read full documentation →](/docs/sdk/plugins/plugin-dashboard) + +--- + +#### [@object-ui/plugin-timeline](/docs/sdk/plugins/plugin-timeline) + +Timeline component with multiple layout variants. + +- Vertical, horizontal layouts +- Customizable markers +- Date formatting +- Lazy-loaded (~20 KB) + +[Read full documentation →](/docs/sdk/plugins/plugin-timeline) + +--- + +#### [@object-ui/plugin-gantt](/docs/sdk/plugins/plugin-gantt) + +Gantt chart for project visualization. + +- Task dependencies +- Progress tracking +- ObjectQL integration +- Lazy-loaded (~40 KB) + +[Read full documentation →](/docs/sdk/plugins/plugin-gantt) + +--- + +#### [@object-ui/plugin-calendar](/docs/sdk/plugins/plugin-calendar) + +Calendar visualization for events. + +- Month/week/day views +- Event management +- ObjectQL integration +- Lazy-loaded (~25 KB) + +[Read full documentation →](/docs/sdk/plugins/plugin-calendar) + +--- + +#### [@object-ui/plugin-map](/docs/sdk/plugins/plugin-map) + +Map visualization with markers. + +- Interactive maps +- Location markers +- ObjectQL integration +- Lazy-loaded (~60 KB) + +[Read full documentation →](/docs/sdk/plugins/plugin-map) + +--- + +### Data Management + +#### [@object-ui/plugin-grid](/docs/sdk/plugins/plugin-grid) + +Advanced data grid with sorting, filtering, and pagination. + +- Column sorting and filtering +- Pagination controls +- Row selection +- Lazy-loaded (~45 KB) + +[Read full documentation →](/docs/sdk/plugins/plugin-grid) + +--- + +#### [@object-ui/plugin-form](/docs/sdk/plugins/plugin-form) + +Advanced form builder with validation. + +- Multi-step forms +- Field validation +- Custom field types +- Lazy-loaded (~28 KB) + +[Read full documentation →](/docs/sdk/plugins/plugin-form) + +--- + +#### [@object-ui/plugin-view](/docs/sdk/plugins/plugin-view) + +ObjectQL-integrated views for automatic CRUD. + +- Auto-generated forms and grids +- CRUD operations +- Field mapping +- Lazy-loaded (~35 KB) + +[Read full documentation →](/docs/sdk/plugins/plugin-view) + +--- + +### Content & Editing + +#### [@object-ui/plugin-editor](/docs/sdk/plugins/plugin-editor) + +Code editor component powered by Monaco Editor. + +- Syntax highlighting for 100+ languages +- IntelliSense and code completion +- Multiple themes +- Lazy-loaded (~120 KB) + +[Read full documentation →](/docs/sdk/plugins/plugin-editor) + +--- + +#### [@object-ui/plugin-markdown](/docs/sdk/plugins/plugin-markdown) + +Markdown renderer with GitHub Flavored Markdown support. + +- GitHub Flavored Markdown +- XSS protection +- Code syntax highlighting +- Lazy-loaded (~30 KB) + +[Read full documentation →](/docs/sdk/plugins/plugin-markdown) + +--- + +#### [@object-ui/plugin-chatbot](/docs/sdk/plugins/plugin-chatbot) + +Chat interface component. + +- Message history +- User and assistant roles +- Timestamps and avatars +- Responsive floating panel for console assistants +- Inline responding, stop, and retry states +- Lazy-loaded (~35 KB) + +[Read full documentation →](/docs/sdk/plugins/plugin-chatbot) + +--- + +### Workflows & Tasks + +#### [@object-ui/plugin-kanban](/docs/sdk/plugins/plugin-kanban) + +Kanban board component with drag-and-drop powered by @dnd-kit. + +- Drag and drop cards between columns +- Column limits (WIP limits) +- Card badges for status/priority +- Lazy-loaded (~100 KB) + +[Read full documentation →](/docs/sdk/plugins/plugin-kanban) + +--- + +## How Plugins Work + +### Stylesheets + +A plugin's JavaScript is only half of what it renders with. `@object-ui/plugin-grid` and `@object-ui/plugin-kanban` publish a `style.css` of their own, and an app that installs one must import it after the base sheets: + +```css +/* src/index.css */ +@import "tailwindcss"; +@import "@object-ui/components/style.css"; +@import "@object-ui/fields/style.css"; +@import "@object-ui/plugin-grid/style.css"; +@import "@object-ui/plugin-kanban/style.css"; +``` + +Each plugin sheet is compiled against the components theme and then has every rule that sheet already ships subtracted from it, so it carries only what the plugin adds. That includes the themed utilities (`bg-muted/10`, `bg-card/60`, `ring-primary/40`) which **no consumer-side configuration can produce** — the `@theme` block declaring their tokens lives in package source that is not published, so scanning `node_modules` cannot reach it ([#4929](https://github.com/objectstack-ai/objectui/issues/4929)). Skip the import and the view renders unstyled. + +Add a line only for the plugins you install. The other `@object-ui/plugin-*` packages do not publish a stylesheet yet; the build step above is the pattern each of them will adopt when it needs one. + +### Lazy Loading Architecture + +Plugins use React's `lazy()` and `Suspense` to load heavy dependencies on-demand: + +```typescript +// The plugin structure +import React, { Suspense } from 'react' +import { Skeleton } from '@object-ui/components' + +// Lazy load the heavy implementation +const LazyEditor = React.lazy(() => import('./MonacoImpl')) + +export const CodeEditorRenderer = (props) => ( + }> + + +) +``` + +**Benefits:** +- **Smaller initial bundle**: Main app loads faster +- **Progressive loading**: Components load when needed +- **Better UX**: Loading skeletons while chunks download +- **Automatic code splitting**: Vite handles chunking + +### Bundle Impact + +| Plugin | Initial Load | Lazy Load | Description | +|--------|-------------|-----------|-------------| +| plugin-editor | ~0.2 KB | ~120 KB | Monaco editor | +| plugin-charts | ~0.2 KB | ~80 KB | Recharts visualization | +| plugin-kanban | ~0.2 KB | ~100 KB | Drag-and-drop board | +| plugin-markdown | ~0.2 KB | ~30 KB | Markdown rendering | +| plugin-dashboard | ~0.2 KB | ~22 KB | Dashboard layouts | +| plugin-form | ~0.2 KB | ~28 KB | Form builder | +| plugin-grid | ~0.2 KB | ~45 KB | Data grid | +| plugin-view | ~0.2 KB | ~35 KB | ObjectQL views | +| plugin-timeline | ~0.2 KB | ~20 KB | Timeline layouts | +| plugin-chatbot | ~0.2 KB | ~35 KB | Chat interface | +| plugin-calendar | ~0.2 KB | ~25 KB | Calendar views | +| plugin-gantt | ~0.2 KB | ~40 KB | Gantt charts | +| plugin-map | ~0.2 KB | ~60 KB | Map visualization | + +Without lazy loading, all this code would be in your main bundle! + +### Auto-Registration + +Plugins automatically register their components when imported: + +```typescript +// In the plugin's index.tsx +import { ComponentRegistry } from '@object-ui/core' + +ComponentRegistry.register('code-editor', CodeEditorRenderer) +``` + +You just need to import the plugin once: + +```typescript +// In your App.tsx or main.tsx +import '@object-ui/plugin-editor' +import '@object-ui/plugin-charts' +import '@object-ui/plugin-kanban' +import '@object-ui/plugin-markdown' +import '@object-ui/plugin-dashboard' +import '@object-ui/plugin-form' +import '@object-ui/plugin-grid' +// ... import other plugins as needed +``` + +Now all plugin components are available in your schemas! + +## Creating Custom Plugins + +You can create your own plugins following the same pattern: + +### 1. Create Package Structure + +```bash +mkdir -p packages/plugin-myfeature/src +cd packages/plugin-myfeature +``` + +### 2. Create Heavy Implementation + +```typescript +// src/MyFeatureImpl.tsx +import HeavyLibrary from 'heavy-library' + +export default function MyFeatureImpl(props) { + return +} +``` + +### 3. Create Lazy Wrapper + +```typescript +// src/index.tsx +import React, { Suspense } from 'react' +import { ComponentRegistry } from '@object-ui/core' +import { Skeleton } from '@object-ui/components' + +// Lazy load implementation +const LazyFeature = React.lazy(() => import('./MyFeatureImpl')) + +// Create renderer with Suspense +export const MyFeatureRenderer = (props) => ( + }> + + +) + +// Auto-register +ComponentRegistry.register('my-feature', MyFeatureRenderer) + +// Export for manual use +export const myFeatureComponents = { + 'my-feature': MyFeatureRenderer +} +``` + +### 4. Add TypeScript Types + +```typescript +// src/types.ts +import type { BaseSchema } from '@object-ui/types' + +export interface MyFeatureSchema extends BaseSchema { + type: 'my-feature' + customProp?: string +} +``` + +### 5. Configure Build + +```typescript +// vite.config.ts +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import { resolve } from 'path' + +export default defineConfig({ + plugins: [react()], + build: { + lib: { + entry: resolve(__dirname, 'src/index.tsx'), + name: 'ObjectUIPluginMyFeature', + fileName: (format) => `index.${format}.js` + }, + rollupOptions: { + external: [ + 'react', + 'react-dom', + '@object-ui/components', + '@object-ui/core' + ], + output: { + globals: { + react: 'React', + 'react-dom': 'ReactDOM' + } + } + } + } +}) +``` + +### 6. Add Package.json + +```json +{ + "name": "@object-ui/plugin-myfeature", + "version": "1.0.0", + "type": "module", + "main": "./dist/index.umd.js", + "module": "./dist/index.es.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.es.js", + "require": "./dist/index.umd.js", + "types": "./dist/index.d.ts" + } + }, + "files": ["dist"], + "scripts": { + "build": "vite build && tsc --emitDeclarationOnly" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "dependencies": { + "heavy-library": "^1.0.0" + }, + "devDependencies": { + "@object-ui/components": "workspace:*", + "@object-ui/core": "workspace:*", + "@object-ui/types": "workspace:*", + "@vitejs/plugin-react": "^6.0.5", + "typescript": "^6.0.3", + "vite": "^8.2.1" + } +} +``` + +## Best Practices + +### 1. Keep Entry Point Light + +The main index file should only contain: +- Lazy loading wrapper +- Component registration +- Type exports + +Heavy imports go in the `*Impl.tsx` file. + +### 2. Provide Good Loading States + +Always show a meaningful skeleton while loading: + +```typescript + +}> + + +``` + +### 3. Export Types + +Make your plugin type-safe: + +```typescript +export type { MyFeatureSchema } from './types' +``` + +### 4. Document Your Plugin + +Include a README with: +- Installation instructions +- Usage examples +- Schema API reference +- Bundle size information + +### 5. Test Lazy Loading + +Verify that: +- The main bundle is small (~200 bytes) +- The lazy chunk is separate +- Components load correctly when rendered + +```bash +pnpm build +ls -lh dist/ +``` + +## Plugin vs Component Package + +**Use a Plugin when:** +- The component depends on large libraries (>50 KB) +- Not all apps will use this component +- You want on-demand loading + +**Use regular Components when:** +- The component is lightweight +- Most apps will use it +- It's part of core functionality + +## Troubleshooting + +### Plugin not loading + +Check that you imported it in your app: + +```typescript +import '@object-ui/plugin-myfeature' +``` + +### TypeScript errors + +Make sure types are exported: + +```typescript +export type { MyFeatureSchema } from '@object-ui/plugin-myfeature' +``` + +### Bundle size too large + +Check that the implementation is in a separate file: + +``` +✅ src/index.tsx (light, uses React.lazy) +✅ src/MyFeatureImpl.tsx (heavy, imported lazily) +``` + +### Component not registering + +Check that ComponentRegistry.register() is called at the module level: + +```typescript +// ✅ Good - runs on import +ComponentRegistry.register('my-feature', MyFeatureRenderer) + +// ❌ Bad - never runs +export function registerComponents() { + ComponentRegistry.register('my-feature', MyFeatureRenderer) +} +``` + +## Related Documentation + +- [Component Registry](/docs/sdk/guide/component-registry) - Understanding the registry +- [Schema Rendering](/docs/sdk/guide/schema-rendering) - How schemas become UI +- [Custom Plugin Development](/docs/sdk/guide/plugin-development) - Component development +- **[Create Plugin Utility](/docs/sdk/utilities/create-plugin)** - Scaffold new plugins quickly +- **[CLI Tool](/docs/sdk/utilities/cli)** - Test plugins with the CLI +- **[All Utilities](/docs/sdk/utilities/cli)** - Complete toolkit for development + +## Next Steps + +1. Install official plugins you need +2. Try creating a custom plugin +3. Share your plugins with the community +4. Contribute new plugins to Object UI diff --git a/content/docs/sdk/guide/quick-start.mdx b/content/docs/sdk/guide/quick-start.mdx new file mode 100644 index 00000000000..eeef2398042 --- /dev/null +++ b/content/docs/sdk/guide/quick-start.mdx @@ -0,0 +1,332 @@ +--- +title: "Quick Start" +description: "Get up and running with ObjectUI in 5 minutes - install, configure, and render your first server-driven UI" +--- + +# Quick Start + +Get up and running with ObjectUI in a small Vite app. This guide installs the core renderer, registers the built-in component packages, and renders a first JSON schema. + +## Prerequisites + +- **Node.js** and **pnpm** (or npm/yarn) — ObjectUI is tested on Node 22.x with pnpm 10.x. +- Basic knowledge of **React** and **TypeScript** + +## Step 1: Create a React Project + +If you don't have an existing React project, create one with Vite: + +```bash +pnpm create vite my-app --template react-ts +cd my-app +``` + +## Step 2: Install ObjectUI + +Install the core ObjectUI packages: + +```bash +pnpm add @object-ui/react @object-ui/core @object-ui/types @object-ui/components @object-ui/fields +``` + +Install Tailwind CSS for styling: + +```bash +pnpm add -D tailwindcss @tailwindcss/vite +``` + +## Step 3: Configure Tailwind CSS + +Add Tailwind to your `vite.config.ts`: + +```ts +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [react(), tailwindcss()], +}); +``` + +Add to your `src/index.css`: + +```css +@import "tailwindcss"; +@import "@object-ui/components/style.css"; +@import "@object-ui/fields/style.css"; +``` + +Each `style.css` is a stylesheet the package compiles from its own sources at build time, and between them they carry every utility ObjectUI renders with — the themed ones (`bg-primary`, `border-input`) included. + +**Import them in that order.** `@object-ui/components/style.css` is the complete sheet: Tailwind's base layer, the `@theme` tokens and the utilities its components use. `@object-ui/fields/style.css` is a small supplement on top of it — only the ~155 utilities the field widgets add and the components sheet does not already carry, which is why it is a few kB rather than another 170. It is not a standalone stylesheet, and on its own it will not style anything. + +**Plugin packages that publish a stylesheet need one line each.** `@object-ui/plugin-grid` and `@object-ui/plugin-kanban` ship the same kind of supplement, built the same way, so add whichever of them you install: + +```css +@import "@object-ui/plugin-grid/style.css"; +@import "@object-ui/plugin-kanban/style.css"; +``` + +Without that line the plugin renders with no themed styling at all — its `bg-muted/10`, `bg-card/60` and `text-muted-foreground/60` have no other source in a published app, because the `@theme` block they resolve lives in package source that is never published ([#4929](https://github.com/objectstack-ai/objectui/issues/4929)). The remaining `@object-ui/plugin-*` packages ship no stylesheet yet; importing one that does not exist breaks the build, so add only the lines above. + +That is the whole styling setup: you do not add `@source` lines for the ObjectUI packages, and pointing Tailwind at them inside `node_modules` only regenerates utilities these imports already gave you. + +## Step 4: Render Your First Schema + +Replace `src/App.tsx` with: + +```tsx +import '@object-ui/components'; +import '@object-ui/fields'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +import type { CardSchema } from '@object-ui/types'; + +const schema: CardSchema = { + type: 'card', + title: 'Team Directory', + description: 'Rendered from JSON metadata', + className: 'mx-auto max-w-3xl', + children: { + type: 'data-table', + caption: 'Users', + columns: [ + { header: 'Name', accessorKey: 'name', sortable: true }, + { header: 'Email', accessorKey: 'email' }, + { header: 'Role', accessorKey: 'role' }, + ], + data: [ + { name: 'Ada Lovelace', email: 'ada@example.com', role: 'Admin' }, + { name: 'Grace Hopper', email: 'grace@example.com', role: 'Editor' }, + { name: 'Katherine Johnson', email: 'katherine@example.com', role: 'Viewer' }, + ], + pagination: false, + searchable: false, + }, +} as const; + +function App() { + return ( +
+ + + +
+ ); +} + +export default App; +``` + +Importing `@object-ui/components` and `@object-ui/fields` registers their renderers with the shared `ComponentRegistry`. `SchemaRendererProvider` injects the host's data adapter, and everything below it — expressions, smart fields, data-aware plugins — reads it back from there. + +This app renders inline `data`, so it has no adapter to inject and says so with `undefined`. That is a real state of the contract, not a placeholder: `dataSource` is typed `DataSource | null | undefined` (`@object-ui/types`), so a host either hands over an adapter or states that it has none. It used to be typed `any` and this example passed an empty object, which no renderer can do anything with (objectui#7912). + +## Step 5: Run the App + +```bash +pnpm dev +``` + +Open [http://localhost:5173](http://localhost:5173). You should see a card and data table rendered from JSON. + +## What Just Happened? + +1. **Schema** - the UI was described as JSON with `type`, visual props, and nested `children`. +2. **Registry** - importing the component packages registered renderers for `card` and `data-table`. +3. **Renderer** - `SchemaRenderer` resolved each `type` and rendered React components. +4. **Provider** - `SchemaRendererProvider` is where a host injects its `DataSource`; this app has none, so it passes `undefined`. + +## Expression Scope + +A `${…}` expression in a schema reads a root the host publishes. Publish those roots with +`PredicateScopeProvider`: + +### Basic Usage + +```tsx +import React from 'react' +import { PredicateScopeProvider, SchemaRenderer } from '@object-ui/react' +import type { DeclaredNode } from '@object-ui/types' +// Importing the package registers every default renderer as a side effect — +// there is no separate registration call. +import '@object-ui/components' + +const schema: DeclaredNode = { + type: "page", + // An app page draws `title` as its heading; a page with no `pageType` is a + // record page, which leaves the heading to a `page:header` block. + pageType: "app", + title: "Dashboard", + children: { + type: "grid", + columns: 3, + children: [ + { type: "statistic", label: "Total Users", value: "${stats.users}" }, + { type: "statistic", label: "Revenue", value: "${stats.revenue}" }, + { type: "statistic", label: "Orders", value: "${stats.orders}" } + ] + } +} + +function App() { + // Every key of this object becomes a root the schema's expressions can read — + // `stats` here is what answers `${stats.users}`. + const scope = { + stats: { users: 1234, revenue: "$56,789", orders: 432 } + } + + return ( + + + + ) +} + +export default App +``` + +Expression scope reaches the renderer through the provider, never through a prop on the +element. `SchemaRenderer` declares exactly one prop, `schema`, and forwards every other prop +it is handed to the component the schema names — so a value passed as `data={…}` is neither +read nor refused, and the expression that wanted it is returned as its own source text, with +nothing thrown and one line on the console. + +⛔ `dataSource` is the **adapter** — the object data renderers call `find()` on — and not an +expression root. It is a different channel from the expression scope: publish the values your +`${…}` expressions read with `PredicateScopeProvider`, and inject the adapter your data +components query with `SchemaRendererProvider`. + +## Copy-Paste Schemas + +A `${name.…}` in any of these reads the root `name` off the scope the host published — see +[Expression Scope](#expression-scope) for the provider that publishes one. A head name nothing +published is not an error: the expression is returned as its own source text. + +### Contact Form + +```json +{ + "type": "form", + "fields": [ + { "name": "name", "type": "text", "label": "Full Name", "required": true }, + { "name": "email", "type": "email", "label": "Email", "required": true }, + { "name": "subject", "type": "select", "label": "Subject", "options": [ + { "label": "General Inquiry", "value": "general" }, + { "label": "Bug Report", "value": "bug" }, + { "label": "Feature Request", "value": "feature" } + ]}, + { "name": "message", "type": "textarea", "label": "Message", "required": true } + ], + "submitLabel": "Send Message" +} +``` + +### Data Grid + +```json +{ + "type": "object-grid", + "properties": { + "objectName": "user", + "title": "Users", + "columns": [ + { "field": "name", "label": "Name", "sortable": true }, + { "field": "email", "label": "Email" }, + { "field": "role", "label": "Role" }, + { "field": "status", "label": "Status" }, + { "field": "created_at", "label": "Joined" } + ], + "showSearch": true, + "operations": { "create": true, "update": true, "delete": true, "export": true } + } +} +``` + +### Dashboard + +A row of live KPI tiles. A `dashboard` node's `widgets[]` takes `metric-card` entries and +dataset-bound charts instead; see [Dashboard Plugin](/docs/sdk/plugins/plugin-dashboard). + +```json +{ + "type": "grid", + "columns": 4, + "children": [ + { "type": "statistic", "label": "Revenue", "value": "${stats.revenue}", "trend": "up", "description": "+12%" }, + { "type": "statistic", "label": "Orders", "value": "${stats.orders}", "trend": "up", "description": "+8%" }, + { "type": "statistic", "label": "Customers", "value": "${stats.customers}", "trend": "up", "description": "+5%" }, + { "type": "statistic", "label": "Conversion", "value": "${stats.conversion}", "trend": "down", "description": "-2%" } + ] +} +``` + +### Kanban Board + +```json +{ + "type": "object-kanban", + "objectName": "tasks", + "groupBy": "status", + "cardTitle": "title", + "cardFields": ["assignee", "priority", "due_date"], + "columns": [ + { "id": "todo", "title": "To Do", "className": "border-t-2 border-indigo-500" }, + { "id": "in_progress", "title": "In Progress", "className": "border-t-2 border-amber-500" }, + { "id": "review", "title": "In Review", "className": "border-t-2 border-blue-500" }, + { "id": "done", "title": "Done", "className": "border-t-2 border-green-500" } + ] +} +``` + +More schemas: [`ui/examples/schema-catalog`](https://github.com/objectstack-ai/objectstack/tree/main/ui/examples/schema-catalog) +is the canonical catalog — the JSON schemas the docs render, a smoke test mounts, and AI agents +use as a few-shot corpus. + +## Next Steps + +### Add Actions + +Actions are data, not inline functions. Declare one as an `action:button` +node whose `properties` bag carries the block's props, as `@objectstack/spec` +declares them: `actionType` names the built-in executor the action runner +dispatches to, and the bag's other keys carry that executor's arguments — +`target` is the location a `url` action navigates to. Written flat on the node, +`actionType` and `target` are refused by the spec's strict page component: + +```json +{ + "type": "action:button", + "properties": { + "label": "Open details", + "actionType": "url", + "target": "/users/ada" + } +} +``` + +Learn the full action model in [Enhanced Actions](/docs/ui/actions). + +### Connect a Data Source + +```bash +pnpm add @object-ui/data-objectstack +``` + +```tsx +import { createObjectStackAdapter } from '@object-ui/data-objectstack'; + +const dataSource = createObjectStackAdapter({ + baseUrl: 'https://api.example.com' +}); +``` + +Pass the adapter to `SchemaRendererProvider` and let data-aware renderers call the `DataSource` interface. See [Data Connectivity](/docs/sdk/guide/data-source). + +### Learn More + +- [Architecture Overview](/docs/sdk/guide/architecture-overview) — Understand how ObjectUI works +- [Schema Rendering](/docs/sdk/guide/schema-rendering) — Deep dive into schema rendering +- [Component Registry](/docs/sdk/guide/component-registry) — Customize and extend components +- [Plugins](/docs/sdk/guide/plugins) — Add views like Grid, Kanban, Charts +- [Fields Guide](/docs/sdk/guide/fields) — Field widgets and cell renderers diff --git a/content/docs/sdk/guide/schema-overview.mdx b/content/docs/sdk/guide/schema-overview.mdx new file mode 100644 index 00000000000..c5d8b7abeb0 --- /dev/null +++ b/content/docs/sdk/guide/schema-overview.mdx @@ -0,0 +1,313 @@ +--- +title: "Schema Overview" +description: "Comprehensive overview of ObjectUI schemas for building enterprise applications" +--- + +# Schema Overview + +ObjectUI provides powerful schemas that enable you to build sophisticated enterprise applications with advanced features like theming, reporting, and complex workflows. This guide provides an overview of all available schemas and helps you get started quickly. + +## Key Capabilities + +ObjectUI includes enterprise-grade capabilities to build production-ready applications: + +- **Application Structure** - Define complete multi-page applications with navigation +- **Dynamic Theming** - Brand your applications with custom themes and light/dark modes +- **Advanced Actions** - Build complex workflows with API calls, chaining, and conditions +- **Enterprise Reporting** - Generate, schedule, and export comprehensive reports + +## Core Schemas + +### Application Configuration + +#### [App Schema](/docs/ui/apps) +Define your entire application structure with navigation, branding, and global settings. + +```typescript +const app: AppComponentSchema = { + type: 'app', + title: 'My Application', + layout: 'sidebar', + menu: [...], + navigation: [...] +}; +``` + +**Use Cases:** +- Multi-page applications +- Admin dashboards +- CRM systems +- Internal tools + +--- + +### Theming & Branding + +#### [Theme Schema](/docs/ui) +Dynamic theming with light/dark modes, color palettes, and typography. + +Theming is **not** a component you declare in a page. There is no `type: 'theme'` +node: the `ThemeComponentSchema` wrapper documented here until objectui#5489 was +retired because no renderer ever implemented it, so a page declaring one got the +registry's "Unknown component type" panel rather than a theme manager. + +A theme is a **document**, not a node. Author it as the `Theme` shape +`@object-ui/types` re-exports from `@objectstack/spec/ui`, hand it to +`ThemeProvider` (`@object-ui/react`), and `ThemeEngine` (`@object-ui/core`) +turns it into the CSS variables your components already read. + +**What the theme document carries:** +- Light/dark mode switching +- 20+ semantic colors +- Typography system +- CSS variables +- Tailwind integration + +--- + +### Advanced Actions + +#### [Enhanced Actions](/docs/ui/actions) +Powerful action system with AJAX calls, chaining, conditions, and tracking. + +```typescript +const action: ActionSchema = { + type: 'action', + actionType: 'ajax', + api: '/api/submit', + chain: [...], + condition: '${...}', + tracking: {...} +}; +``` + +**New Action Types:** +- **`ajax`** - API calls with full request configuration +- **`confirm`** - Confirmation dialogs +- **`dialog`** - Modal/dialog actions + +**Key Features:** +- Action chaining (sequential/parallel) +- Conditional execution (a `condition` predicate gates whether an action runs) +- Success / failure notices (`successMessage` / `errorMessage`) +- Event tracking +- Retry logic + +--- + +### Reporting + +#### [Report Schema](/docs/ui/reports) +Enterprise reports with aggregation, export, and scheduling. + +```typescript +import type { ReportComponentSchema } from '@object-ui/types'; + +const report: ReportComponentSchema = { + type: 'report', + title: 'Sales Report', + fields: [ + { name: 'revenue', aggregation: 'sum' }, + { name: 'orders', aggregation: 'count' } + ], + schedule: { + frequency: 'monthly', + recipients: ['team@company.com'] + } +}; +``` + +**Features:** +- Field aggregation (sum, avg, count, min, max) +- Multiple export formats (PDF, Excel, CSV) +- Scheduled reports +- Email distribution +- Interactive builder + +--- + +## Quick Comparison + +| Schema | Purpose | Best For | +|--------|---------|----------| +| **AppComponentSchema** | Application structure | Multi-page apps, dashboards | +| **Enhanced Actions** | Complex workflows | API integration, multi-step processes | +| **ReportComponentSchema** | Data reporting | Analytics, business intelligence | + +## View Components + +ObjectUI also includes enhanced view components: + +### [Detail View](/docs/sdk/plugins/plugin-detail) +Rich detail pages with sections, tabs, and related records. + +### [View Switcher](/docs/sdk/components/complex/view-switcher) +Toggle between list, grid, kanban, calendar, timeline, and map views. + +### [Filter UI](/docs/sdk/components/complex/filter-ui) +Advanced filtering interface with multiple field types. + +### [Sort UI](/docs/sdk/components/complex/sort-ui) +Sort configuration with multiple fields. + +## Installation & Setup + +### Package Installation + +All schemas are included in `@object-ui/types`. Install it in your project: + +```bash +npm install @object-ui/types +# or +pnpm add @object-ui/types +# or +yarn add @object-ui/types +``` + +### TypeScript Usage + +Import the type definitions you need: + +```typescript +import type { + AppComponentSchema, + ActionSchema, + ReportComponentSchema +} from '@object-ui/types'; +``` + +### Runtime Validation + +For runtime validation, use the included Zod schemas: + +```typescript +import { + AppComponentSchema, + CRUDActionSchema, + ReportComponentSchema +} from '@object-ui/types/zod'; + +const myConfig = { type: 'app', title: 'My Application', layout: 'sidebar' }; + +const result = AppComponentSchema.safeParse(myConfig); +if (result.success) { + // Valid configuration + const app = result.data; +} else { + // Handle validation errors + console.error(result.error); +} +``` + +## Quick Start Example + +Here's a complete example showing how to build a simple CRM application using ObjectUI schemas: + +```typescript +import type { AppComponentSchema } from '@object-ui/types'; + +// Define your application structure +const app: AppComponentSchema = { + type: 'app', + name: 'enterprise-crm', + title: 'Enterprise CRM', + layout: 'sidebar', + + menu: [ + { + type: 'item', + label: 'Dashboard', + icon: 'LayoutDashboard', + path: '/dashboard' + }, + { + type: 'group', + label: 'Sales', + children: [ + { type: 'item', label: 'Leads', path: '/leads' }, + { type: 'item', label: 'Deals', path: '/deals' } + ] + } + ] +}; +``` + +This creates a professional-looking CRM application with: +- A sidebar layout with navigation menu +- Sales section with leads and deals + +Theming is configured separately, as a theme document handed to `ThemeProvider` — +see [Theme Schema](/docs/ui). + +## Advanced Features + +ObjectUI provides advanced schemas and capabilities for enterprise applications: + +### Core Schemas + +ObjectUI includes these top-level schemas: + +- **`AppComponentSchema`** - Define your entire application structure +- **`ReportComponentSchema`** - Create data reports with aggregation + +### Enhanced ActionSchema + +The `ActionSchema` provides comprehensive action handling: + +- ✅ Action types: `ajax`, `confirm`, `dialog` +- ✅ Action chaining via the `chain` array (sequential or parallel) +- ✅ Conditional execution with the `condition` property +- ❌ Success/failure callbacks: `onSuccess` / `onFailure` were RETIRED (objectui#7068) — both faces refuse them; write `successMessage` / `errorMessage` for notices, and the spec's `onSuccess` block `{ navigate, openIn }` on `UIActionSchema` for post-success navigation (objectui#5934) +- ✅ Event tracking with the `tracking` configuration +- ✅ Automatic retry logic + +### View Components + +ObjectUI includes enhanced view components: + +- **`DetailViewSchema`** - Rich detail pages with sections and tabs +- **`ViewSwitcherSchema`** - Toggle between list, grid, kanban, calendar views +- **`FilterUISchema`** - Advanced filtering interface +- **`SortUISchema`** - Multi-field sort configuration + +## Getting Started + +### Installation Steps + +1. **Install package** - Add `@object-ui/types` to your project + ```bash + npm install @object-ui/types@latest + ``` + +2. **Configure application** - Define your app structure with AppComponentSchema (optional) + +3. **Set up theming** - Hand a `Theme` document to `ThemeProvider` for consistent styling (optional) + +4. **Implement actions** - Use advanced action features like `confirm` and chaining + +5. **Test your application** - Verify all functionality works as expected + +## Learning Resources + +- **[Schema Type Reference](/docs/sdk/api/schema-reference)** - Complete schema reference with JSON examples. +- **[Quick Start](/docs/sdk/guide/quick-start)** - Render your first ObjectUI schema. +- **[Schema Rendering](/docs/sdk/guide/schema-rendering)** - Understand the renderer pipeline. +- **[Component Registry](/docs/sdk/guide/component-registry)** - Learn how schema `type` values resolve to components. + +## Getting Help + +### Community Support +- **[GitHub Issues](https://github.com/objectstack-ai/objectui/issues)** - Report bugs and request features + +### Official Documentation +- **[Documentation Site](https://www.objectui.org/docs)** - Full documentation and guides +- **[Schema Reference](/docs/sdk/api/schema-reference)** - Detailed schema documentation + +## Next Steps + +Ready to build with ObjectUI? Here's what to do next: + +1. **[Review schema documentation](/docs/sdk/api/schema-reference)** - Learn about each schema in detail +2. **[Try the Quick Start](/docs/sdk/guide/quick-start)** - Build your first ObjectUI application +3. **[Explore components](/docs/sdk/components)** - See the core renderer catalog +4. **[Explore plugins](/docs/sdk/plugins)** - Add heavier widgets such as grids, kanban, charts, maps, and reports diff --git a/content/docs/sdk/guide/schema-rendering.mdx b/content/docs/sdk/guide/schema-rendering.mdx new file mode 100644 index 00000000000..dc3d30364f5 --- /dev/null +++ b/content/docs/sdk/guide/schema-rendering.mdx @@ -0,0 +1,643 @@ +--- +title: "Schema Rendering" +--- + +Object UI's schema rendering system is the core mechanism that transforms JSON configurations into live React components. This guide explains how it works and how to use it effectively. + +## Overview + +The schema rendering engine follows a simple principle: + +``` +JSON Schema → SchemaRenderer → React Components → Beautiful UI +``` + +Every visual element in Object UI starts as a JSON object that describes what should be rendered, not how it should be rendered. + +## The SchemaRenderer Component + +The `SchemaRenderer` is the primary component that interprets your JSON schemas: + +```tsx +import { SchemaRenderer } from '@object-ui/react' +import { initializeComponents } from '@object-ui/components' +import type { PageNodeSchema } from '@object-ui/types' +// Side-effect import: loading the package runs its own field registration. +import '@object-ui/fields' + +// Register components once at app initialization +initializeComponents() + +function App() { + const schema: PageNodeSchema = { + type: "page", + pageType: "app", + title: "My Dashboard", + children: [{ type: "text", content: "Hello" }] + } + + return +} +``` + +## Schema Structure + +Every schema object must have at minimum a `type` field: + +```typescript +import type { CSSProperties } from 'react' + +interface BaseSchema { + type: string // Component type identifier + id?: string // Optional unique identifier + className?: string // Tailwind CSS classes + style?: CSSProperties // Inline styles (use sparingly) + visibleWhen?: string // Expression for conditional visibility + hidden?: boolean | string // Expression for conditional hiding + disabled?: boolean | string // Expression for conditional disabling +} +``` + +### Example Schema + +```json +{ + "type": "card", + "id": "stats-card", + "className": "p-6 shadow-lg", + "title": "User Statistics", + "visibleWhen": "${user.role === 'admin'}", + "children": { + "type": "text", + "content": "Total Users: ${stats.totalUsers}" + } +} +``` + +## Data Context + +Expression scope does **not** arrive as a prop. `SchemaRenderer` declares exactly one prop, +`schema`, and forwards every other prop it is handed straight through to the component the +schema names — so a `data`, `dataSource` or `debug` written on the element is neither read nor +refused. Nothing throws, and there is one line on the console; the expression simply never +resolves, and an unresolvable template is returned as its own source text, so the characters +you typed are what the reader sees. The scope comes from `PredicateScopeProvider`, which +publishes each name you give it as an expression root: + +```tsx +import { SchemaRenderer, PredicateScopeProvider } from '@object-ui/react' +import type { DeclaredNode } from '@object-ui/types' + +// The page schema from the first example on this page. +declare const schema: DeclaredNode + +// Every name here becomes a root the schema's expressions can read. +const scope = { + user: { name: 'John', role: 'admin' }, + stats: { totalUsers: 1234 }, +} + +function App() { + return ( + + + + ) +} +``` + +An app built on `@object-ui/app-shell` does not mount this provider itself: the shell's +`ExpressionProvider` already feeds the same channel with `user` (the signed-in user, also +readable as `current_user`) and `features`. + +The scope the evaluator builds is what you published, plus three names the renderer supplies: + +| name | what it holds | +|---|---| +| every key of `scope` | exactly what you put there — `user`, `stats`, whatever the page needs | +| `page` | page-local variables, for predicates that gate on another component's state | +| `record` | the row a record surface is bound to, when there is one | +| `current_user` | an alias of whatever you published as `user`; the host's `ExpressionProvider` publishes the signed-in user there | + +A name outside that set resolves to nothing, and an unresolvable template is not an error: +the evaluator hands back its own source text and writes one line to the console, so the +characters you typed are what the reader sees. + +> **`dataSource` is not an expression root.** `SchemaRendererProvider`'s `dataSource` carries +> the host's `DataSource` *adapter* — the object renderers call `find()` on. The renderer used +> to publish that adapter under the name `data`; an adapter answers no `data.*` path, so the +> root was constant for every conformant host, and objectui#9308 removed it. A `${data.…}` +> expression now reads whatever *you* published under `data`, and nothing if you published +> none. At the runtime layer the row is `record` (ADR-0089). + +### Accessing Data in Schemas + +Use expression syntax `${}` to reference the scope, by the name you published it under: + +```json +{ + "type": "text", + "content": "Welcome, ${user.name}!" +} +``` + +## Component Registry + +The schema renderer uses a component registry to map schema types to React components: + +```tsx +import { ComponentRegistry } from '@object-ui/core' + +// `ComponentRegistry` is a process-level singleton — import it, do not construct one. +// Register a custom component +ComponentRegistry.register('my-component', MyComponent) + +// Now you can use it in schemas +const schema = { + type: "my-component", + // ... component props +} +``` + +## Nested Schemas + +Schemas can be nested to create complex UIs: + +```json +{ + "type": "page", + "pageType": "app", + "title": "Dashboard", + "children": { + "type": "grid", + "columns": 2, + "children": [ + { + "type": "card", + "title": "Card 1", + "children": { + "type": "text", + "content": "Nested content" + } + }, + { + "type": "card", + "title": "Card 2", + "children": { + "type": "chart", + "chartType": "bar", + "xAxisKey": "month", + "series": [{ "name": "revenue" }], + "data": [ + { "month": "Jan", "revenue": 120 }, + { "month": "Feb", "revenue": 80 } + ] + } + } + ] + } +} +``` + +The chart's rows are written out on the node, and that is the spelling that plots. A +`${…}` on node-level `data` is **not** one of the rows `SchemaRenderer` evaluates: the raw +string reaches `ChartRenderer`, whose `Array.isArray(schema.data) ? schema.data : []` drops +it, and the chart frame draws with nothing in it — no error, no warning. Since objectui#7113 +that node is also refused by name, `ChartSchema.data` being `z.array(z.record(…))`. `series` +is required alongside it: `series[].name` picks the column to plot within each row, and +`xAxisKey` names the category column. `chart` reads no `bind` either, so resolve the array in +your own code and hand `SchemaRenderer` a schema whose rows are already on the node. + +## Array Rendering + +Use arrays for multiple items: + +```json +{ + "type": "container", + "children": [ + { "type": "text", "content": "First item" }, + { "type": "text", "content": "Second item" }, + { "type": "text", "content": "Third item" } + ] +} +``` + +## Expression System + +Object UI includes a powerful expression system for dynamic behavior: + +### Simple Expressions + +```json +{ + "type": "text", + "content": "${user.firstName} ${user.lastName}" +} +``` + +### Conditional Expressions + +```json +{ + "type": "card", + "title": "${record.status === 'active' ? 'Active' : 'Inactive'}", + "description": "${record.status === 'active' ? 'This record is in use.' : 'This record is archived.'}" +} +``` + +`record.status` rather than a bare `status`, because this example is about a row: `record` is +the row a record surface is bound to, and it is the only spelling a row field has — the bare +shorthand and the wrong-layer `data.status` were both retired on runtime record surfaces +(objectui#5330 phase 2). A head name the host publishes itself stays bare; this one is not +one of those. + +`card` here rather than `badge`, because an expression is evaluated only on a key the +node's own type carries. `expressionBindableTextKeysFor` — the lookup `SchemaRenderer` +consumes out of `@objectstack/spec` — gives `card` the rows `title` and `description`, +and gives `badge` no rows at all, so a `${…}` written on a badge reaches the DOM as the +characters you typed. Resolve a badge's text before you hand the schema over, and author +it on `label`: `text` is not a `BadgeSchema` key. + +### Visibility Control + +```json +{ + "type": "button", + "label": "Delete", + "visibleWhen": "${user.role === 'admin'}" +} +``` + +### Complex Logic + +```json +{ + "type": "alert", + "variant": "default", + "title": "Welcome!", + "children": { + "type": "text", + "content": "${user.isNew ? 'Start with the quick tour.' : user.tasks.length === 0 ? 'You are all caught up.' : 'You have tasks waiting.'}" + } +} +``` + +The branch sits on the nested `text` node's `content`, which `SchemaRenderer` evaluates +on every node type — the escape hatch for a component that carries no expression rows of +its own, and `alert` is one of those. Its severity could not be chosen by expression in +any case: `AlertSchema.variant` is the closed set `default` | `destructive`, so `info`, +`warning` and `success` are not values it accepts. Pick the variant in the host and +author it as a literal. + +## Event Handling + +Components can emit events that you handle in React: + +```tsx + { + console.log('Action:', action) + console.log('Context:', context) + }} + onSubmit={(data) => { + console.log('Form submitted:', data) + }} +/> +``` + +Reference actions in schemas — the block's props go in its `properties` bag, as +`@objectstack/spec` declares them; the spec's strict page component refuses them +written flat on the node: + +```json +{ + "type": "action:button", + "properties": { + "name": "call_api", + "label": "Click Me", + "actionType": "api", + "target": "/api/action", + "method": "POST" + } +} +``` + +Three things about the shape this replaces. A declarative action is its own NODE TYPE, +`action:button` — a plain `button` has no authorable handler: `ButtonSchema.onClick` is a +runtime slot for a host-supplied function, refused by name by the zod mirror, and (being +on `SDUI_DOM_PASS_THROUGH_KEYS`) forwarded straight to the DOM listener slot, where React +throws on the first click: "Expected `onClick` listener to be a function, instead got a +value of `object` type." The execution type is `actionType`, and the built-in vocabulary +is `script` | `url` | `modal` | `flow` | `api` | `form` (plus objectui's `navigation` +alias) — anything else must be a handler your host registered on `ActionProvider`. `ajax` +is neither. And an `api` action's endpoint is its `target`, with the verb in `method`: the +spec's `action:button` row refuses `endpoint` and names `target` in its place, and `api` is +not a key any action renderer forwards. + +## Performance Optimization + +### Lazy loading: there is no such knob + +There is no authorable lazy-loading key — not on `tabs`, not on any other node. +This section used to show a `tabs` node carrying `"lazyLoad": true` beneath the +sentence "Large schemas are automatically optimized". Nothing in this repository +performs that optimization and nothing reads that key, so the promise is removed +here rather than respelled. + +Two things mislead, so both are worth naming: + +- **`lazyLoad` is a real name in this repository — on a different surface.** It is + a member of `PerformanceConfig`, the argument type of the `usePerformance` + hook: a configuration object you pass in TypeScript, never a key you author on + a node. Finding it in a search does not make it authorable. It gates nothing + even there — `usePerformance` resolves it into the config it hands back and + never branches on it, unlike its sibling `debounceMs`, which the hook lifts + into a local and passes to `setTimeout`. +- **The `tabs` renderer builds every panel on the same render pass.** It maps + `items` twice, unconditionally — once for the triggers and once for the + panels — so no tab's `content` waits for a click. It uses neither `lazy` nor + `Suspense`, and it never calls `usePerformance`. + +To defer real work behind a tab, defer the *component* rather than the node: see +[Code Splitting](#code-splitting) below. + +For reference, a `tabs` node written with the keys `TabsSchema` actually declares: + +```json +{ + "type": "tabs", + "defaultValue": "tab1", + "items": [ + { "value": "tab1", "label": "Tab 1", "content": { "type": "text", "content": "First panel" } }, + { "value": "tab2", "label": "Tab 2", "content": { "type": "text", "content": "Second panel" } } + ] +} +``` + +`items` is the node's tab list — there is no `tabs` key — and every item takes +`value`, `label` and `content`: the identifier, the visible title, and the panel. +All three are required, and the two ways of getting an item wrong fail +differently. An item missing one of them is **refused**. An item that also +carries the older `title` spelling is **accepted with that key dropped**, while +an item-level `body` is **refused by name**, pointed at `content`. On +the node itself, `body` and `children` are refused by name: `tabs` reads neither +content channel. The four keys it does render are `defaultValue`, `items`, +`orientation` and `value`. + +### Memoization + +The renderer automatically memoizes components to prevent unnecessary re-renders. + +### Code Splitting + +Use dynamic imports for heavy components: + +```tsx +import { lazy } from 'react' + +const HeavyChart = lazy(() => import('./HeavyChart')) + +registry.register('heavy-chart', HeavyChart) +``` + +## Error Handling + +The renderer includes built-in error boundaries: + +```tsx + { + console.error('Rendering error:', error) + // Log to error tracking service + }} +/> +``` + +## Render is not a validation door + +`SchemaRenderer` does **not** parse your document against the published Zod schema before it +draws. The one document check on the render path is `validateSchema` from `@object-ui/core` — +a hand-written structural walker — and it runs **in development only**: the call sits behind a +`process.env.NODE_ENV !== 'production'` guard, warns to the console, and marks the offending +host element with `data-obj-schema-invalid` so an app can hang a visual cue off it. In a +production build that pass is skipped entirely — the document is not checked at all before it +is drawn, and `data-obj-schema-invalid` is never emitted. + +The walker and the schema do not share an accept set, so neither one's verdict tells you +anything about the other's. The walker judges node *structure* — is the root an object, does +every node carry a `type`, recursing through `children` — plus a few rules of its own, such as +a short hand-written table of retired node-type spellings. The rest of the published contract +is outside it. Take a `chatbot` node carrying a key the schema has retired: the walker is +silent, whether that key holds a node, an array of nodes, a string, a number or a record, while +`safeValidateSchema` refuses the same document with the retirement's own message. So a document +that renders without a warning has **not** thereby passed the contract, and a warning that does +appear is a report about node structure, not about the schema. + +Validation happens at three doors, all of them outside the render path: + +1. **Load time — the framework's Zod parse.** Every metadata item a package ships is validated + against its Zod schema when the framework loads it; the result travels with the item as a + `_diagnostics` envelope, which Studio surfaces. See + [Metadata Diagnostics](/docs/ui). +2. **Authoring time — `objectui validate`.** It parses one document, JSON or YAML, against the + published schema's strict authoring face — which refuses, by name, any key no schema declares, + on every node — prints the schema's own errors when it fails, and exits non-zero. That exit + code is the CLI's validation verdict. `objectui check` does not give it. `check` sweeps a + project's JSON files and checks the `type` of each file it recognises, and a file whose root + carries an ObjectUI structural key (`children`, `className`, `body`, …) is recognised by that + key alone, without being parsed against the schema — so an invalid document of that shape is + not reported at all. Only a file with none of those keys is parsed; it is listed by name when + its root `type` names a registered component but the document does not validate, and that + list is advisory: being listed does not fail the run. `check` exits non-zero on unreadable + JSON, and on a `${…}` expression on a text key its node never evaluates, in a listed file as + in a recognised one. See [`objectui check`](/docs/sdk/utilities/cli#objectui-check). +3. **Wherever else you need it — `StrictAnyComponentSchema.safeParse`.** Exported from + `@object-ui/types/zod`, it is the parse `objectui validate` runs, so a build step, a CI job or + a save handler can apply the identical contract. `safeValidateSchema`, from the same module, + is the tolerant parse: it keeps a key no schema declares, unjudged, the way the renderer does. + +Put the check where documents are authored, saved or loaded — not in the paint. A document that +reaches the browser without passing one of those doors is drawn as best the renderer can, in +development and in production alike. + +## TypeScript Support + +Full type safety for your schemas: + +```tsx +import type { PageNodeSchema, FormSchema } from '@object-ui/types' + +const form: FormSchema = { + type: "form", + // TypeScript will validate this entire structure + fields: [] +} + +const schema: PageNodeSchema = { + type: "page", + pageType: "app", + title: "Typed Page", + children: [form] +} +``` + +The spec's page blocks take their props in a `properties` bag. `SchemaRenderer` +accepts them through `AuthoringNode` from `@object-ui/types`, so the bag is +typed by the spec's `ComponentPropsMap` row and a misspelled key in it does not +compile: + +```tsx +import { SchemaRenderer } from '@object-ui/react' +import type { PublicBlockNodeOf } from '@object-ui/types' + +const heading: PublicBlockNodeOf<'element:text'> = { + type: "element:text", + properties: { content: "Quarterly revenue", variant: "h2" } +} + +function Heading() { + return +} +``` + +## Best Practices + +### 1. Keep Schemas Simple + +Break complex UIs into smaller, reusable schemas: + +```tsx +// ❌ Bad: One massive schema +const massiveSchema = { /* 500 lines of JSON */ } + +// ✅ Good: Composed schemas +const headerSchema = { /* ... */ } +const contentSchema = { /* ... */ } +const footerSchema = { /* ... */ } + +const pageSchema = { + type: "page", + children: [headerSchema, contentSchema, footerSchema] +} +``` + +### 2. Use Data Context Effectively + +Put everything the schema's expressions need on one scope, mounted above the tree — not on +the renderer, which does not read it: + +```tsx +import { SchemaRenderer, PredicateScopeProvider } from '@object-ui/react' +import type { DeclaredNode } from '@object-ui/types' + +// The reader's own values. +declare const schema: DeclaredNode +declare const userData: { name: string } +declare const userSettings: { theme: string } +declare const dashboardStats: { totalUsers: number } + +// ✅ Good — one provider, and every expression reads a name published on it +const scope = { + user: userData, + settings: userSettings, + stats: dashboardStats, +} + +function Dashboard() { + return ( + + + + ) +} +``` + +### 3. Leverage Expressions + +Move logic to expressions instead of creating conditional schemas: + +```tsx +// ❌ Bad +const schema = user.isAdmin ? adminSchema : userSchema + +// ✅ Good +const schema = { + type: "page", + children: [ + { + type: "admin-panel", + visibleWhen: "${user.isAdmin}" + }, + { + type: "user-panel", + visibleWhen: "${!user.isAdmin}" + } + ] +} +``` + +### 4. Use TypeScript + +Always type your schemas for better IDE support and fewer runtime errors. + +## Common Patterns + +### Loading States + +```json +{ + "type": "container", + "children": { + "type": "spinner", + "visibleWhen": "${loading}" + } +} +``` + +### Empty States + +```json +{ + "type": "empty", + "visibleWhen": "${items.length === 0}", + "title": "No items found", + "action": { + "type": "button", + "label": "Create New" + } +} +``` + +### Error States + +```json +{ + "type": "alert", + "variant": "destructive", + "visibleWhen": "${error}", + "title": "Something went wrong", + "children": { "type": "text", "content": "${error.message}" } +} +``` + +`visibleWhen` is a condition key and is evaluated on every node type. The message text is a +nested `text` node because `alert` carries no expression rows — and `message` is not an +`AlertSchema` key at all: the alert's own text keys are `title` and `description`, and the +renderer falls back from `description` to `children`. `destructive` is the variant this state +wants; `error` is not in the closed set. + +## Next Steps + +- [Component Registry](/docs/sdk/guide/component-registry) - Learn about component registration +- [Expression System](/docs/ui) - Master expressions +- [Schema Overview](/docs/sdk/guide/schema-overview) - Explore all available schemas + +## Related Documentation + +- [SchemaRenderer](/docs/sdk/core/schema-renderer) - Technical reference for the renderer +- [Architecture Overview](/docs/sdk/guide/architecture-overview) - System architecture +- [`@object-ui/core` README](https://github.com/objectstack-ai/objectui/tree/main/packages/core) - Core package API reference +- [`@object-ui/react` README](https://github.com/objectstack-ai/objectui/tree/main/packages/react) - React package API reference diff --git a/content/docs/sdk/guide/theming.mdx b/content/docs/sdk/guide/theming.mdx new file mode 100644 index 00000000000..bfc636a8c07 --- /dev/null +++ b/content/docs/sdk/guide/theming.mdx @@ -0,0 +1,355 @@ +--- +title: "Theming & Customization" +description: "Customize the look-and-feel of ObjectUI components with CSS custom properties, Tailwind CSS, cva variants, and runtime theme switching" +--- + +# Theming & Customization + +ObjectUI's theming system is built on three layers: **CSS custom properties** (design tokens), **Tailwind CSS** (utility classes), and **class-variance-authority** (component variants). Together they give you full control over appearance — from global palettes down to individual component states. + +## How Theming Works + +``` +Theme JSON → ThemeProvider → CSS Custom Properties → Tailwind utilities → Components +``` + +1. You define a **Theme** object (colors, fonts, radii, spacing). +2. `ThemeProvider` from `@object-ui/react` resolves inheritance and converts the theme to CSS custom properties injected on `document.documentElement`. +3. Tailwind classes reference those properties (e.g. `bg-primary` maps to `hsl(var(--primary))`). +4. Components use `cva()` for variant logic and `cn()` for class merging. + +## CSS Custom Properties + +ObjectUI follows the Shadcn convention. Design tokens are defined as HSL channel values on `:root` and `.dark`: + +```css +/* globals.css */ +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --border: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + /* ... remaining dark tokens */ + } +} +``` + +Components reference these tokens through Tailwind: + +```tsx +
+ +``` + +## Dark Mode + +ObjectUI supports three modes: `light`, `dark`, and `auto` (follows system preference). The `ThemeProvider` applies a `light` or `dark` class to the root element and listens for `prefers-color-scheme` changes. + +```tsx +import { ThemeProvider } from "@object-ui/react"; + +function App() { + return ( + + + + ); +} +``` + +Toggle mode at runtime with the `useTheme` hook: + +```tsx +import { useTheme } from "@object-ui/react"; + +function ModeToggle() { + const { resolvedMode, setMode } = useTheme(); + + return ( + + ); +} +``` + +There is no `darkMode` option to set on your side. ObjectUI declares the class-based dark variant in its own CSS — `@custom-variant dark (&:where(.dark, .dark *))` — so `.dark`-scoped utilities are already compiled into the stylesheet you import. What you do need is for the `dark` class to sit on an ancestor of your components; `ThemeProvider` puts it on `document.documentElement` for you. + +## Custom Component Themes + +Register one or more theme definitions with `ThemeProvider`. Each theme is a plain JSON object: + +```tsx +import type { Theme } from "@object-ui/types"; + +const corporateTheme: Theme = { + name: "corporate", + label: "Corporate", + // `colors` keys are ColorPalette keys, NOT Shadcn variable names. The engine + // maps them: text → --foreground, surface → --card, error → --destructive, + // textSecondary → --muted-foreground. A key that is not on ColorPalette is + // dropped, so the *-foreground pairs go through `customVars` below. + colors: { + primary: "220 70% 50%", + background: "0 0% 100%", + text: "220 20% 10%", + accent: "200 80% 55%", + }, + // Emitted verbatim as `--: ` — the declared door for any custom + // property that has no ColorPalette key. + customVars: { + "primary-foreground": "0 0% 100%", + "accent-foreground": "0 0% 100%", + }, + typography: { + fontFamily: { base: "IBM Plex Sans, system-ui" }, + }, + borderRadius: { base: "0.375rem" }, +}; + + + + +``` + +Themes support **inheritance** via the `extends` field. A child theme only needs to declare its overrides: + +```tsx +const darkCorporate: Theme = { + name: "corporate-dark", + label: "Corporate Dark", + extends: "corporate", + colors: { + primary: "220 70% 50%", + background: "220 20% 8%", + text: "0 0% 95%", + }, +}; + + + + +``` + +## Creating a Custom Color Palette + +Start from HSL values and define both light and dark variants: + +```tsx +import type { Theme } from "@object-ui/types"; + +export const brand: Theme = { + name: "brand", + label: "Brand", + colors: { + // Every key here is a ColorPalette key; the comment is the CSS variable + // the engine emits it as. + primary: "262 83% 58%", // --primary (Purple) + secondary: "262 30% 94%", // --secondary + accent: "160 84% 39%", // --accent (Teal accent) + background: "0 0% 100%", // --background + surface: "0 0% 100%", // --card + text: "262 20% 10%", // --foreground + textSecondary: "262 10% 45%", // --muted-foreground + disabled: "262 20% 95%", // --muted + border: "262 20% 90%", // --border + error: "0 84% 60%", // --destructive + }, + // Shadcn pairs the palette with foreground/ring variables that have no + // ColorPalette key of their own. Author those here — they are emitted + // verbatim as `--: `. + customVars: { + "primary-foreground": "0 0% 100%", + "secondary-foreground": "262 83% 30%", + "accent-foreground": "0 0% 100%", + "destructive-foreground": "0 0% 100%", + ring: "262 83% 58%", + }, + borderRadius: { base: "0.5rem" }, // --radius +}; +``` + +> **Tip:** Use the [Shadcn Themes tool](https://ui.shadcn.com/themes) to generate a full palette, then paste the HSL values into your Theme object. + +## Runtime Theme Switching + +`ThemeProvider` accepts multiple themes. Switch between them at runtime with `setTheme`: + +```tsx +import { useTheme } from "@object-ui/react"; + +function ThemeSwitcher() { + const { activeTheme, themes, setTheme } = useTheme(); + + return ( + + ); +} +``` + +Enable **persistence** so the user's choice survives page reloads: + +```tsx + + + +``` + +The provider stores the active theme name and mode in `localStorage` under the keys `-name` and `-mode`. + +## RTL Support + +ObjectUI components use **logical CSS properties** (`ms-`, `me-`, `ps-`, `pe-`) where available via Tailwind's logical utilities. For full RTL support: + +1. Set the `dir` attribute on your root element: + +```tsx + +``` + +2. Use Tailwind's RTL modifier for directional overrides: + +```tsx +
+ Directional padding +
+``` + +3. Avoid `left` / `right` utilities — prefer `start` / `end` equivalents (`text-start`, `ms-4`, `rounded-s-lg`). + +> **Note:** RTL support requires Tailwind CSS v3.3+ or v4 with logical property utilities enabled. diff --git a/content/docs/sdk/guide/troubleshooting.mdx b/content/docs/sdk/guide/troubleshooting.mdx new file mode 100644 index 00000000000..691cce6a55d --- /dev/null +++ b/content/docs/sdk/guide/troubleshooting.mdx @@ -0,0 +1,425 @@ +--- +title: Troubleshooting +description: Solutions to common issues when building with ObjectUI. +--- + +# Troubleshooting + +This guide covers the most common issues you may encounter when working with ObjectUI, along with their solutions. + +## 1. "Component type X not found" + +**Symptom:** The `SchemaRenderer` renders nothing or shows a fallback, and the console logs `component type "object-kanban" not found in registry`. + +**Cause:** The plugin that provides the component type has not been imported, so it never registered itself with the `ComponentRegistry`. + +**Fix:** + +```typescript +// Import the plugin package — registration happens on import +import '@object-ui/plugin-kanban'; +import '@object-ui/plugin-grid'; +``` + +If you are using a custom component, register it explicitly: + +```typescript +import { ComponentRegistry } from '@object-ui/core'; +import { MyCustomWidget } from './MyCustomWidget'; + +ComponentRegistry.register('my-widget', MyCustomWidget, { + namespace: 'custom', + label: 'My Widget', + category: 'plugin', +}); +``` + +Verify what is registered by running: + +```bash +npx objectui doctor +``` + +## 2. Build Errors with Tailwind CSS + +**Symptom:** Tailwind utility classes are not applied. Components render without styling. + +**Cause:** You are not importing the stylesheets the ObjectUI packages publish. Their utilities — including every themed one, such as `bg-primary` and `border-input` — are compiled at build time into each package's `style.css`, and nothing in your own build can reproduce the themed ones. + +**Fix:** Import them in your main CSS file, after your own Tailwind entry, in this order: + +```css +/* src/index.css */ +@import 'tailwindcss'; +@import '@object-ui/components/style.css'; +@import '@object-ui/fields/style.css'; +``` + +`@object-ui/components` publishes the base sheet — theme tokens, base layer, its own utilities — and every other sheet is a supplement built by subtracting everything the base already ships, so each must come **after** it and none styles much on its own. `@object-ui/fields` carries what the field widgets add. `@object-ui/plugin-grid` and `@object-ui/plugin-kanban` carry what those two plugins add; add a line for each plugin package you install: + +```css +@import '@object-ui/plugin-grid/style.css'; +@import '@object-ui/plugin-kanban/style.css'; +``` + +If a grid or a kanban board specifically looks wrong — column headers and card surfaces flat, drag feedback and selection rings missing — that plugin's sheet is the missing import. It did not exist before either: those packages emitted no CSS at all until [#4929](https://github.com/objectstack-ai/objectui/issues/4929), so on earlier versions the subpath does not resolve and upgrading is the fix, not a scanning path. The remaining `@object-ui/plugin-*` packages still publish no stylesheet. + +If field widgets specifically look wrong — tag and badge colours flat, the rating stars not reacting to hover, the signature pad showing the wrong cursor — the fields import is the one that is missing. Note that it genuinely did not exist before: every release up to and including 17.3.0 declared the `@object-ui/fields/style.css` subpath while shipping no stylesheet at all ([#4059](https://github.com/objectstack-ai/objectui/issues/4059)), so on those versions the import fails to resolve and breaks the build. Upgrade rather than adding scanning paths. + +Then check that the Tailwind 4 build plugin is actually installed and wired up — `@tailwindcss/postcss` in `postcss.config.mjs`, or `@tailwindcss/vite` in `vite.config.ts`. Without it, `@import 'tailwindcss'` is passed through as a plain CSS import and no utilities are generated at all. + +> **Do not** try to fix this by adding `node_modules` paths to a `content` array or an `@source` line. ObjectUI is Tailwind 4 and has no `tailwind.config.js`; Tailwind 4 does not load one unless you opt in with `@config`, so on most projects those paths do nothing whatsoever. Even when they are read, scanning the published files only regenerates the shape-only utilities (`inline-flex`, `rounded-md`, `h-9`) the two sheets already contain — it can never produce the themed ones, because the `@theme` block declaring their tokens lives in unpublished package source. Missing theme colours are always a missing `style.css` import, never a missing path. + +## 3. Missing Peer Dependencies + +**Symptom:** Build or runtime errors about missing modules like `react`, `react-dom`, or `react-hook-form`. + +**Cause:** ObjectUI packages declare React 19 as a **peer dependency**. Your host project must provide them. + +**Fix:** + +```bash +# Run the built-in doctor command to detect missing dependencies +npx objectui doctor + +# Or install manually +pnpm add react react-dom react-hook-form +``` + +The `objectui doctor` command checks: +- Peer dependency versions +- Package compatibility +- Registry health +- Missing plugins referenced in schemas + +## 4. Expression Evaluation Errors + +**Symptom:** Expressions like `${data.user.name}` render as literal strings or throw runtime errors. + +**Cause:** Malformed expression syntax, missing context variables, or unclosed brackets. + +**Fix:** + +| Problem | Bad | Good | +|---------|-----|------| +| Missing `$` prefix | `{data.name}` | `${data.name}` | +| Unclosed bracket | `${data.name` | `${data.name}` | +| Wrong context var | `${row.name}` | `${data.name}` | +| Nested quotes | `${"hello"}` | `${'hello'}` or `${data.greeting}` | + +Available context variables: +- `data.*` — Current data scope +- `user.*` — Authenticated user +- `params.*` — URL/query parameters + +Debug expressions by enabling debug mode in `SchemaRendererContext`: + +```tsx + +``` + +This logs each expression evaluation to the browser console. + +## 5. Schema Validation Errors + +**Symptom:** Schema renders incorrectly, the development console shows `[ObjectUI] Invalid schema detected:`, or `assertValidSchema` throws `Schema validation failed:`. + +**Fix:** Validate your schema before passing it to the renderer: + +```bash +# CLI validation (checks against @objectstack/spec) +npx objectui validate ./path/to/schema.json +``` + +Common schema issues: +- Missing required `type` field on components +- Invalid action type (must be one of: `script`, `url`, `api`, `modal`, `flow`) +- Referencing a field type that doesn't exist (e.g., `rich-text` without importing `plugin-editor`) + +Schema-structure validation is `validateSchema` / `assertValidSchema` from `@object-ui/core` (`packages/core/src/validation/schema-validator.ts`); `SchemaRenderer` runs `validateSchema` once per schema object in development. + +## 6. TypeScript Errors with Schema Types + +**Symptom:** TypeScript cannot find types like `BaseSchema`, `ActionSchema`, or `FieldWidgetProps`. + +**Cause:** `@object-ui/types` is not installed, or the version is mismatched. + +**Fix:** + +```bash +pnpm add @object-ui/types +``` + +Then import types from the correct entry points: + +```typescript +// Base types +import type { BaseSchema } from '@object-ui/types'; + +// Category-specific types +import type { FormSchema } from '@object-ui/types/form'; +import type { LayoutSchema } from '@object-ui/types/layout'; +import type { DataDisplaySchema } from '@object-ui/types/data-display'; + +// Zod validation schemas — same PascalCase names as the types, so alias one of +// the two when a module imports both. +import { BaseSchema as BaseSchemaValidator } from '@object-ui/types/zod'; +``` + +The `@object-ui/types` package exports multiple entry points (`base`, `layout`, `form`, `data-display`, `feedback`, `overlay`, `navigation`, `complex`, `data`, `zod`). Check `packages/types/package.json` for the full list. + +## 7. Dark Mode Flickering + +**Symptom:** Page flashes white before switching to dark mode on initial load. + +**Cause:** The `ThemeProvider` (`packages/react/src/context/ThemeContext.tsx`) is mounted too late, or the theme preference is read asynchronously after first paint. + +**Fix:** + +1. Place the `ThemeProvider` as high as possible in your component tree — ideally wrapping your entire app: + +```tsx +import { ThemeProvider } from '@object-ui/react'; +import type { FC } from 'react'; + +declare const YourApp: FC; + +function App() { + return ( + + + + ); +} +``` + +2. Add a blocking script in your HTML `` to set the `class` attribute before React hydrates: + +```html + +``` + +## 8. i18n Missing Translations + +**Symptom:** UI shows raw translation keys like `common.save` instead of localized text. + +**Cause:** Locale files are missing or the `I18nProvider` is not configured. + +**Fix:** + +1. Ensure the `I18nProvider` from `packages/i18n/` wraps your app: + +```tsx +import { I18nProvider } from '@object-ui/i18n'; +import type { FC } from 'react'; + +declare const App: FC; + +// The app's own packs are merged with the built-in locales. +const resources = { en: { greeting: 'Hello' }, fr: { greeting: 'Bonjour' } }; + + + +; +``` + +2. Check that locale files follow the expected structure. Each locale file should export a flat or nested object of key-value pairs. + +3. Verify the locale code matches exactly (e.g., `en`, `fr`, `de` — not `en-US` unless your locale files use that format). The built-in catalogues are looked up exact tag first, then base language, so a region-tagged code such as `zh-CN` is served by the built-in `zh` catalogue. + +## 8b. The UI paints in English first, then switches to the user's language + +**Symptom:** a `zh` / `ja` / `de` user sees one frame of English before the real +strings arrive. The strings are correct in both frames — this is not the missing +-translation symptom above, which shows raw keys like `common.save`. + +**Cause:** this is the documented behaviour, not a defect. Only the `en` +catalogue ships resident; the other nine are fetched per locale, so a provider +that boots into `zh` renders through `fallbackLng: 'en'` until the `zh` +catalogue lands. + +**Fix:** resolve the catalogue before you mount. If your app already awaits +anything before `createRoot().render()`, spend that await here: + +```tsx +import { I18nProvider, preloadBootstrapLocale } from '@object-ui/i18n'; +import { createRoot } from 'react-dom/client'; +import type { FC } from 'react'; + +declare const App: FC; +declare const container: HTMLElement; + +// Pass the SAME options you pass to — otherwise the two can +// disagree about which language this boot is in, and the preload fetches the +// wrong catalogue. Never rejects: a catalogue that will not download must not +// take the boot down, and the provider retries on mount. +await preloadBootstrapLocale(); +createRoot(container).render( + + + , +); +``` + +An app that has no such seam keeps the fallback render, which is correct in both +frames. + +## 9. Performance Issues with Large Datasets + +**Symptom:** Grid, table, or list views become slow with 1,000+ rows. + +**Cause:** All rows are being rendered in the DOM at once. + +**Fix:** + +- **Grid Plugin** (`packages/plugin-grid/`): Built-in grid supports virtualization out of the box for large datasets. +- **Paginate server-side**: Configure your data source adapter (`packages/core/src/adapters/`) to fetch data in pages rather than loading everything at once. + +For custom views, use the `usePerformance` hook from `@object-ui/react` to monitor render times: + +```typescript +import { usePerformance } from '@object-ui/react'; + +// Inside your component: +const { metrics, markRenderStart } = usePerformance({ + virtualScroll: { enabled: true, itemHeight: 40 }, +}); + +const stopMeasure = markRenderStart(); // call stopMeasure() once the render settles +console.log(metrics.lastRenderDuration); +``` + +## 10. Plugin Conflicts + +**Symptom:** Two plugins register the same component type and one silently overrides the other. + +**Cause:** Plugins registered without namespaces collide on the same type key. + +**Fix:** Always use namespaced registrations: + +```typescript +import { ComponentRegistry } from '@object-ui/core'; +import type { FC } from 'react'; + +declare const MyGridComponent: FC; + +ComponentRegistry.register('grid', MyGridComponent, { + namespace: 'my-plugin', // ← prevents collision + label: 'Custom Grid', + category: 'plugin', +}); +``` + +The `PluginSystem` (`packages/core/src/registry/PluginSystem.ts`) uses `PluginScopeImpl` to auto-prefix registrations with the plugin name. If you register manually, always provide a `namespace`. + +To debug conflicts: + +```bash +npx objectui doctor +``` + +This reports duplicate registrations and namespace collisions. + +## 10. `react-day-picker` v9 prop / classNames errors + +**Symptom:** After upgrading `react-day-picker` (a transitive dependency of `@object-ui/components`) you see TypeScript errors such as: + +- `Property 'initialFocus' does not exist on type 'DayPickerProps'` +- `Property 'fromYear' does not exist on type 'DayPickerProps'` +- `'table' does not exist in type 'Partial'` + +**Cause:** v9 removed several legacy props/classnames slots inherited from the v7 API. + +**Fix:** Apply the v8 → v9 mapping below in any file that consumes `` / `` directly: + +| v8 / earlier | v9 replacement | +| -------------------- | ------------------------------------------------------------- | +| `initialFocus` | `autoFocus` | +| `fromYear={2000}` | `startMonth={new Date(2000, 0)}` | +| `toYear={2050}` | `endMonth={new Date(2050, 11)}` | +| `classNames.table` | removed — drop the entry (the table wrapper now styles itself) | + +The components shipped in `@object-ui/components` and `@object-ui/plugin-calendar` already use the v9 API; this note exists so downstream apps with their own `` wrappers can apply the same migration. + +## 11. A list column is empty, or a relation column shows a raw id + +**Symptom:** A column appears in a list/grid but every cell is blank, or a +`lookup` / `master_detail` / `user` column shows a record id (`8UY9zHWBfjYjYor4`) +instead of the related record's name. Sorting by that column does nothing, and +exports come out missing it. + +**Cause:** The column object names its field with more than one key. The +canonical key is `field` — the only identity key `@objectstack/spec`'s +`ListColumnSchema` declares — but stored objectui metadata also carries the +legacy `name` (and, in older imports, `fieldName`). When a column carries two +of them with different values, the renderer and the data request can resolve +two different fields: the row fetch asks the server for one field while the +grid renders another, so the cell has nothing behind it and the relation is +never expanded. + +✗ two identities on one column + +```json +{ "field": "account", "name": "account_name" } +``` + +✗ legacy-only: the renderer shows it, the request used to drop it + +```json +{ "name": "account" } +``` + +✓ canonical + +```json +{ "field": "account" } +``` + +**Fix:** Author columns with `field`. Two mechanisms keep legacy metadata +working, and both resolve the canonical key first: + +- Metadata reaching a `list-view` is canonicalized at the component boundary by + `normalizeListViewSchema`, which stamps `field` from whichever spelling is + present and makes any legacy key it already carries agree. +- Every renderer that resolves a column — list, grid, tree, related lists, the + `$expand` / `$select` builders — reads that identity through one shared + function, so a surface rendered outside the fold (a standalone `object-grid` + node, for instance) still resolves the same field the request asked for. + +Legacy keys are still accepted, but they are a migration bridge, not a second +contract: fix the producer. A column that carries two identity keys that +*disagree* logs a one-time dev-mode warning naming which key won and what to +change — the renderer recovering is not the same as the metadata being right: + +``` +[ObjectUI] Column carries two identities: `field: 'account'` and +`name: 'account_name'`. `field` wins — it is the only key `ListColumnSchema` +declares — and `name` has been rewritten to match, so the rendered column and +the requested field agree. Fix the producer: drop `name` and author `field` +only. (objectui#3104) +``` + +If you read column identity in your own code, use the one reader rather than +spelling out a fallback chain — it resolves canonical-first, so it agrees with +what the data layer requested: + +```typescript +import { columnIdentity } from '@object-ui/core'; + +declare const column: unknown; + +const fieldName = columnIdentity(column); // string | undefined +``` + +## Getting Help + +If none of the above resolves your issue: + +1. Search [existing issues](https://github.com/objectstack-ai/objectui/issues) +2. Run `npx objectui doctor` and include the output in your report +3. Open an issue with your schema JSON, error message, and ObjectUI package versions diff --git a/content/docs/sdk/index.mdx b/content/docs/sdk/index.mdx new file mode 100644 index 00000000000..07dab703079 --- /dev/null +++ b/content/docs/sdk/index.mdx @@ -0,0 +1,103 @@ +--- +title: "Object UI SDK" +description: "Render ObjectStack JSON schemas inside your own React app with the MIT-licensed @object-ui packages" +--- + +The Object UI SDK is the set of MIT-licensed `@object-ui/*` packages that render ObjectStack +JSON schemas inside your own React app. It is a schema-driven UI engine: JSON metadata goes in, +and accessible, themeable components built with Tailwind CSS, Shadcn UI and Radix primitives come +out. + +Use it when your application needs server-driven pages, metadata-defined forms, reusable +enterprise views, or an embeddable renderer that stays independent from any single backend. + +```bash +npm install @object-ui/react @object-ui/components +``` + +## The SDK and the Console + +The ObjectStack Console, the admin UI that ships with every ObjectStack server, is built from +these same packages. The Console is one application of the SDK: it reads your stack's metadata +from the server and renders apps, views, forms and dashboards through the same renderer and +component registry described in this section. + +- Building on ObjectStack and happy with the Console? You author metadata and never touch the + SDK. Start with [Views & Apps](/docs/ui). +- Embedding ObjectStack data in your own React app, or rendering JSON schemas with no + ObjectStack backend at all? That is the SDK, and this section is its guide. + +## Start Here + +1. [Quick Start](/docs/sdk/guide/quick-start) - install ObjectUI and render a first schema. +2. [Schema Rendering](/docs/sdk/guide/schema-rendering) - learn how JSON becomes React UI. +3. [Data Connectivity](/docs/sdk/guide/data-source) - connect a backend through the `DataSource` contract. +4. [Schema Reference](/docs/sdk/api/schema-reference) - inspect the supported schema shapes. + +## A Small Schema + +```json +{ + "type": "data-table", + "caption": "Users", + "className": "rounded-lg border", + "columns": [ + { + "header": "Name", + "accessorKey": "name", + "sortable": true + }, + { + "header": "Email", + "accessorKey": "email" + } + ], + "data": [ + { + "name": "Ada Lovelace", + "email": "ada@example.com" + }, + { + "name": "Grace Hopper", + "email": "grace@example.com" + } + ] +} +``` + +That schema renders through `SchemaRenderer` after the component package is imported once for registration side effects. + +## Build Paths + +### Render Schemas + +- [Schema Renderer](/docs/sdk/core/schema-renderer) explains the runtime component. +- [Component Registry](/docs/sdk/guide/component-registry) explains how `type` maps to React components. +- [Expressions](/docs/ui) covers visibility, disabled state, and dynamic values. + +### Build Applications + +- [App Schema](/docs/ui/apps) defines app navigation, branding, and layout. +- [Layout Guide](/docs/sdk/guide/layout) covers page structure and layout primitives. +- [Building a CRUD App](/docs/sdk/guide/building-crud-app) walks through a full task manager. + +### Connect Data + +- [Data Connectivity](/docs/sdk/guide/data-source) covers the backend adapter contract. +- [ObjectStack Adapter](/docs/sdk/utilities/data-objectstack) connects ObjectUI to ObjectStack backends. +- [User State Persistence](/docs/ui) covers favorites and recent items. + +### Extend ObjectUI + +- [Plugin Guide](/docs/sdk/guide/plugins) explains lazy-loaded feature packages. +- [Plugin Development](/docs/sdk/guide/plugin-development) walks through a custom plugin. +- [Theming](/docs/sdk/guide/theming) covers design tokens, Tailwind, and runtime themes. + +## Reference + +- [Components](/docs/sdk/components) - core renderers grouped by category. +- [Fields](/docs/sdk/guide/fields) - field widgets and cell renderers. +- [Plugins](/docs/sdk/plugins) - heavier views such as grids, kanban, charts, maps, and reports. +- [Utilities](/docs/sdk/utilities/cli) - the CLI, plugin scaffolding, and the ObjectStack data adapter. + +For how the Console itself behaves, see [Views & Apps](/docs/ui). diff --git a/content/docs/sdk/layout/app-shell.mdx b/content/docs/sdk/layout/app-shell.mdx new file mode 100644 index 00000000000..adddc68f054 --- /dev/null +++ b/content/docs/sdk/layout/app-shell.mdx @@ -0,0 +1,306 @@ +--- +title: "AppShell" +description: "Main application shell with sidebar and header layout" +--- + +The AppShell component provides the main application layout structure with an integrated sidebar, header, and content area. It's the foundation for building enterprise applications with ObjectUI. + +## Basic Usage + +```tsx +import { AppShell, SidebarNav, type NavItem } from '@object-ui/layout'; +import { Home, Settings, Users } from 'lucide-react'; + +const navItems: NavItem[] = [ + { title: 'Dashboard', href: '/', icon: Home }, + { title: 'Users', href: '/users', icon: Users }, + { title: 'Settings', href: '/settings', icon: Settings }, +]; + +} + navbar={ +
+ My App +
+ } +> + {/* Your page content */} +
Main content area
+
+``` + +## Component Props + +```ts +import type { AppShellBranding } from '@object-ui/layout'; + +interface AppShellProps { + sidebar?: React.ReactNode; // Sidebar content (usually SidebarNav) + navbar?: React.ReactNode; // Top navbar content (logo, search, user menu) + children: React.ReactNode; // Main content area + className?: string; // Additional CSS classes for content area + defaultOpen?: boolean; // Sidebar default state (default: true) + branding?: AppShellBranding; // App branding — four theme fields, see Branding + rightRail?: React.ReactNode; // Optional right rail beside the content, not + // over it (ADR-0057 P3a) +} +``` + +## Features + +### Responsive Sidebar + +The AppShell does not build a sidebar of its own: it renders the node you pass in +`sidebar`, wrapped in a `SidebarProvider`. Pass a `Sidebar`-based node (`SidebarNav`, or +your own) and you get a sidebar that: +- Collapses on mobile devices +- Can be toggled with the `Cmd/Ctrl + B` keyboard shortcut, or by a toggle button you + render yourself — see Collapsible Sidebar below +- Persists state across page navigations +- Supports smooth animations + +### Header Bar + +The header renders your `navbar` content and nothing else — the AppShell adds no controls +of its own to it, in particular **no sidebar toggle**. If you want one, render it inside +`navbar` (see Collapsible Sidebar below). The header itself provides: +- Custom navbar content area +- Fixed height: `h-12` (3rem / 48px) at every breakpoint — there is no responsive height variant +- Border bottom separator +- Background matching app theme + +### Content Area + +The main content area features: +- Responsive padding in three steps: `p-3` on mobile, `sm:p-4` from the `sm` breakpoint, `md:p-6` from `md` up +- A taller bottom padding on mobile only: `pb-20` (5rem), which keeps content clear of the mobile bottom bar (that bar is itself `sm:hidden`); from `sm` up the bottom edge follows `sm:pb-4` / `md:pb-6` like the other sides +- Automatic scrolling +- Flexible height (fills viewport) +- Custom className support + +### Branding + +`branding` is theming, not chrome — the shell hands it to `useAppShellBranding`, and all +four of the fields it declares are read: + +- `primaryColor` / `accentColor` — a six-digit hex (`#3B82F6`; the leading `#` is + optional, three-digit shorthand is not accepted), translated into the Shadcn theme + tokens listed under Styling below. A value that does not parse that way is ignored + silently. +- `favicon` — overwrites the `href` of the icon link the page already has (`#favicon`, + otherwise `link[rel="icon"]`). It never creates one, so a document with no icon link is + left unchanged. +- `title` — assigned to `document.title` as given. It is the whole title, not a suffix: + the caller composes the string it wants (the console passes + `"App label — Product name"`). + +All of it acts on the document rather than on the shell's subtree, and the colour +properties are removed again when the shell unmounts. + +### Right Rail + +`rightRail` renders your node as a flex sibling of the content area, so the rail sits +**beside** the content and reflows it rather than overlaying it (ADR-0057 P3a). The shell +adds no wrapper and no width around it — the node you pass owns its width, border and +scrolling; omit it and the layout is the unchanged single pane. + +## Layout Structure + +``` +┌─────────────────────────────────────┐ +│ Header (your navbar content) │ +├──────────┬──────────────────────────┤ +│ │ │ +│ Sidebar │ Main Content Area │ +│ │ │ +│ │ │ +│ │ │ +└──────────┴──────────────────────────┘ +``` + +## Sidebar Configuration + +### Default Open State + +Control whether sidebar is open by default: + +```plaintext + + {/* Sidebar starts collapsed */} + +``` + +### Collapsible Sidebar + +The AppShell renders no toggle button of its own. What it does give you is the +`SidebarProvider` wrapped around both slots, so the sidebar you supply can be toggled via: +- The `Cmd/Ctrl + B` keyboard shortcut, provided by `SidebarProvider` +- Programmatic control — `useSidebar().toggleSidebar()` from any component inside the shell +- A toggle button you render yourself; it belongs in the `navbar` slot: + +```tsx +import { SidebarTrigger } from '@object-ui/components'; +import { AppShell, SidebarNav, type NavItem } from '@object-ui/layout'; +import { Home, Settings } from 'lucide-react'; + +const navItems: NavItem[] = [ + { title: 'Dashboard', href: '/', icon: Home }, + { title: 'Settings', href: '/settings', icon: Settings }, +]; + +} + sidebar={} +> +
Your page content
+
+``` + +## Navbar Content + +The navbar area is flexible and can contain: + +### Logo and Title + +```plaintext +navbar={ +
+ Logo + My Application +
+} +``` + +### Search Bar + +```plaintext +navbar={ +
+ +
+} +``` + +### User Menu + +```plaintext +navbar={ +
+ + +
+} +``` + +## Complete Example + +```tsx +import { AppShell, SidebarNav, type NavItem } from '@object-ui/layout'; +import { Avatar, AvatarFallback, Button, Input } from '@object-ui/components'; +import { Bell, Folder, LayoutDashboard, Settings, Users } from 'lucide-react'; + +const navItems: NavItem[] = [ + { title: 'Dashboard', href: '/', icon: LayoutDashboard, badge: '12' }, + { + title: 'Projects', + href: '/projects', + icon: Folder, + children: [ + { title: 'Active', href: '/projects/active' }, + { title: 'Archived', href: '/projects/archived' }, + ], + }, + { title: 'Team', href: '/team', icon: Users }, + { title: 'Settings', href: '/settings', icon: Settings }, +]; + +function App() { + return ( + } + navbar={ +
+ {/* Search */} +
+ +
+ + {/* Right side items */} +
+ + + JD + +
+
+ } + className="bg-gray-50" + > + {/* Main content */} +
+

Dashboard

+ {/* Page content */} +
+
+ ); +} +``` + +`SidebarNav` has no logo slot and no footer slot — `title` is its only branding prop, the +section label printed above a flat `NavItem[]` (it is ignored when `items` is a +`NavGroup[]`, since each group prints its own `label`). Put a logo in the `navbar` slot +instead, as the Logo and Title example under Navbar Content above does. + +## With Page Component + +Combine AppShell with the Page component for complete layouts: + +```plaintext +}> + + +``` + +## Styling + +The AppShell uses Tailwind CSS and Shadcn UI components: + +- `SidebarProvider`: Manages sidebar state +- `SidebarInset`: Content area wrapper + +Brand colours arrive as Shadcn theme tokens rather than as per-component styles: each hex +from `branding` is converted to the `H S% L%` triple those tokens carry and set as a CSS +custom property on `document.documentElement`. `primaryColor` writes `--primary`, +`--primary-foreground`, `--ring`, `--sidebar-primary` and `--sidebar-ring`; `accentColor` +writes `--accent` and `--accent-foreground`. Tailwind's utilities resolve through the same +tokens (`--color-primary: hsl(var(--primary))`), so every `bg-primary` / `text-primary` / +`ring-primary` / `bg-accent` consumer picks the brand colour up with no per-component +wiring. In dark mode a lighter variant of the same hue is used instead, and the shell +re-applies the values whenever the `dark` class on the `html` element changes. + +## Accessibility + +The AppShell includes: +- A keyboard shortcut for toggling the sidebar (`Cmd/Ctrl + B`), from `SidebarProvider` +- Keyboard navigation support +- Focus management +- Screen reader announcements + +## Related + +- [SidebarNav](/docs/sdk/layout/sidebar-nav) - Sidebar navigation component +- [PageHeader](/docs/ui/pages) - Page header component +- [Page](/docs/sdk/components/layout/page) - Page layout component diff --git a/content/docs/sdk/layout/meta.json b/content/docs/sdk/layout/meta.json new file mode 100644 index 00000000000..99e9a6e7fe1 --- /dev/null +++ b/content/docs/sdk/layout/meta.json @@ -0,0 +1,7 @@ +{ + "title": "Layout", + "pages": [ + "app-shell", + "sidebar-nav" + ] +} diff --git a/content/docs/sdk/layout/sidebar-nav.mdx b/content/docs/sdk/layout/sidebar-nav.mdx new file mode 100644 index 00000000000..9e42c981102 --- /dev/null +++ b/content/docs/sdk/layout/sidebar-nav.mdx @@ -0,0 +1,339 @@ +--- +title: "SidebarNav" +description: "Sidebar navigation component with active state, icons, badges, nested items, and search" +--- + +The SidebarNav component provides a responsive sidebar navigation menu with support for icons, active states, badges, nested collapsible items, grouped navigation, and built-in search filtering. + +## Basic Usage + +```tsx +import { SidebarNav } from '@object-ui/layout'; +import { Home, Users, Settings } from 'lucide-react'; + + +``` + +## Component Props + +```ts +interface SidebarNavProps { + items: NavItem[] | NavGroup[]; // Navigation items or grouped items + title?: string; // Sidebar title (default: "Application") + className?: string; // Additional CSS classes + collapsible?: "offcanvas" | "icon" | "none"; // Sidebar collapse mode + searchEnabled?: boolean; // Show search input to filter items + searchPlaceholder?: string; // Placeholder for search input +} + +interface NavItem { + title: string; // Item label + href: string; // Navigation URL + icon?: React.ComponentType; // Lucide icon component + badge?: string | number; // Badge text or count + badgeVariant?: 'default' | 'destructive' | 'outline'; // Badge style + children?: NavItem[]; // Nested collapsible sub-items +} + +interface NavGroup { + label: string; // Group section label + items: NavItem[]; // Items in the group +} +``` + +## Features + +### Active State + +The SidebarNav automatically highlights the active route based on the current URL: + +```plaintext +// Current URL: /users +// The "Users" menu item will be highlighted +``` + +### Icons + +Add Lucide icons to navigation items: + +```tsx +import { SidebarNav } from '@object-ui/layout'; +import { + LayoutDashboard, + Users, + FolderKanban, + Settings +} from 'lucide-react'; + + +``` + +### Badges + +Add badge counters or labels to navigation items with variant styling: + +```plaintext + +``` + +### Nested Items + +Create collapsible sub-navigation with the `children` property: + +```plaintext + +``` + +### Grouped Navigation + +Organize items into labeled sections using `NavGroup`: + +```plaintext + +``` + +### Search Filtering + +Enable built-in search to filter navigation items and their children: + +```plaintext + +``` + +### Custom Title + +Set a custom title for the sidebar: + +```plaintext + +``` + +## Complete Example + +```tsx +import { SidebarNav, type NavGroup } from '@object-ui/layout'; +import { + Home, + Users, + FolderOpen, + BarChart, + Settings, + HelpCircle, + Mail +} from 'lucide-react'; + +const navigationItems: NavGroup[] = [ + { + label: 'Workspace', + items: [ + { title: 'Dashboard', href: '/', icon: Home }, + { title: 'Inbox', href: '/inbox', icon: Mail, badge: 8, badgeVariant: 'destructive' }, + { + title: 'Projects', + href: '/projects', + icon: FolderOpen, + badge: 3, + children: [ + { title: 'Design System', href: '/projects/design' }, + { title: 'API v2', href: '/projects/api', badge: 'WIP', badgeVariant: 'outline' }, + { title: 'Mobile App', href: '/projects/mobile' }, + ], + }, + ], + }, + { + label: 'System', + items: [ + { title: 'Settings', href: '/settings', icon: Settings }, + { title: 'Help', href: '/help', icon: HelpCircle }, + ], + }, +]; + +function App() { + return ( + + ); +} +``` + +## Usage with AppShell + +The SidebarNav is designed to work with AppShell: + +```tsx +import { AppShell, SidebarNav } from '@object-ui/layout'; +import { Home, Info } from 'lucide-react'; + + + } +> +
Your content
+
+``` + +## Styling + +The SidebarNav uses Shadcn UI Sidebar components: + +- **SidebarGroupLabel**: Section title +- **SidebarMenuItem**: Individual menu items +- **SidebarMenuButton**: Clickable button with active state +- **SidebarMenuSub**: Nested sub-items container +- **Badge**: Badge counters with variant styling + +### Active Item + +Active items receive: +- Background color highlighting +- Bold text +- Visual indicator + +### Hover State + +Menu items on hover: +- Subtle background change +- Smooth transition +- Cursor pointer + +## Responsive Behavior + +The SidebarNav works with the AppShell's responsive behavior: + +- **Desktop**: Full sidebar visible +- **Mobile**: Collapsible sidebar with hamburger menu +- **Tablet**: Medium-width sidebar + +## Navigation + +The SidebarNav uses React Router's `NavLink` for: +- Client-side navigation +- Active state detection +- Browser history management +- Back button support + +## Accessibility + +The SidebarNav includes: +- Semantic `