Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/sdui-text-input-element.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/spec': minor
---

feat(ui): add `element:text_input` — free-text data-entry element for SDUI pages

SDUI pages could display and navigate but not collect free-text input. This adds
that half of the contract:

- `ElementTextInputPropsSchema` (label, placeholder, `inputType` —
text/email/number/tel/url/password — defaultValue, required, disabled,
description) wired into `PageComponentType` and `ComponentPropsMap` as
`element:text_input`.

The objectui renderer binds the typed value into a page variable
(`PageVariableSchema.source`); a submit `element:button` reads it back via
`{{page.<var>}}` token interpolation in the console action runtime. Showcase:
`showcase_contact_form` (text inputs → page variables → POST web-to-lead).
4 changes: 2 additions & 2 deletions examples/app-showcase/objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { ChartGalleryDashboard, OpsDashboard } from './src/dashboards/index.js';
import { ShowcaseTaskDataset, ShowcaseProjectDataset } from './src/datasets/index.js';
import { allReports } from './src/reports/index.js';
import { allActions } from './src/actions/index.js';
import { ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, AccountDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage, StylingGalleryPage, CommandCenterPage, PageVariablesPage } from './src/pages/index.js';
import { ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, AccountDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage, StylingGalleryPage, CommandCenterPage, PageVariablesPage, ContactFormPage } from './src/pages/index.js';
import { allFlows } from './src/flows/index.js';
import { allWebhooks } from './src/webhooks/index.js';
import { allHooks } from './src/hooks/index.js';
Expand Down Expand Up @@ -155,7 +155,7 @@ export default defineStack({
apps: [ShowcaseApp],
portals: allPortals,
views: [TaskViews, ProjectViews, InquiryViews, BusinessUnitViews],
pages: [ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, AccountDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage, StylingGalleryPage, CommandCenterPage, PageVariablesPage],
pages: [ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, AccountDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage, StylingGalleryPage, CommandCenterPage, PageVariablesPage, ContactFormPage],
dashboards: [ChartGalleryDashboard, OpsDashboard],
books: allBooks,
datasets: [ShowcaseTaskDataset, ShowcaseProjectDataset],
Expand Down
1 change: 1 addition & 0 deletions examples/app-showcase/src/apps/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export const ShowcaseApp = App.create({
{ id: 'nav_gallery', type: 'page', pageName: 'showcase_component_gallery', label: 'Component Gallery', icon: 'layout-template' },
{ id: 'nav_styling_gallery', type: 'page', pageName: 'showcase_styling_gallery', label: 'Styling (ADR-0065)', icon: 'palette' },
{ id: 'nav_page_variables', type: 'page', pageName: 'showcase_page_variables', label: 'Page Variables', icon: 'mouse-pointer-click' },
{ id: 'nav_contact_form', type: 'page', pageName: 'showcase_contact_form', label: 'Contact Form', icon: 'mail-plus' },
{ id: 'nav_project_workspace', type: 'page', pageName: 'showcase_project_workspace', label: 'New Project + Tasks', icon: 'folder-plus' },
// ADR-0047 interface mode: same object as nav_tasks, curated surface.
{ id: 'nav_task_workbench', type: 'page', pageName: 'showcase_task_workbench', label: 'Task Workbench', icon: 'sliders-horizontal' },
Expand Down
131 changes: 131 additions & 0 deletions examples/app-showcase/src/pages/contact-form.page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { definePage } from '@objectstack/spec/ui';

/**
* Contact Form — a pure-SDUI DATA-ENTRY page (the data-entry half of SDUI pages).
*
* Demonstrates the SDUI form loop end to end, with NO custom code:
* 1. `variables` declares one page variable per field, each fed by a text
* input (PageVariableSchema.source = that input's component id).
* 2. `element:text_input` writes each keystroke into its bound variable
* (objectui components/src/renderers/basic/text-input.tsx).
* 3. The submit `element:button` runs an `api` action whose params reference
* the variables as `{{page.<var>}}`. The console action runtime resolves
* those tokens against the live page-variable snapshot (published by
* PageVariableActionBridge) and POSTs the body to the public web-to-lead
* endpoint, creating a `showcase_inquiry`.
*
* POST /api/v1/forms/contact-us/submit (ADR-0056 public form -> showcase_inquiry)
*
* This is the showcase analog of a branded cloud onboarding screen — collect a
* few free-text fields, post them to a backend endpoint — but rendered by the
* console design system (dark mode, i18n) instead of hand-rolled vanilla HTML.
*/
export const ContactFormPage = definePage({
name: 'showcase_contact_form',
label: 'Contact Form (SDUI data entry)',
icon: 'mail-plus',
type: 'app',
template: 'header-sidebar-main',
isDefault: false,
// One page variable per field, each written by the text input whose `id`
// matches `source`. Read back at submit time as `{{page.<name>}}`.
variables: [
{ name: 'inquiryName', type: 'string', source: 'field_name' },
{ name: 'inquiryEmail', type: 'string', source: 'field_email' },
{ name: 'inquiryCompany', type: 'string', source: 'field_company' },
{ name: 'inquiryMessage', type: 'string', source: 'field_message' },
],
regions: [
{
name: 'header',
width: 'full',
components: [
{
type: 'page:header',
properties: {
title: 'Get in touch',
subtitle:
'A pure-SDUI form — each text input writes a page variable; Submit posts them as one request. No custom code.',
},
},
],
},
{
name: 'main',
width: 'large',
components: [
{
type: 'element:text',
properties: {
content:
'Tell us about yourself and we’ll reach out. Each field writes a `page.<var>`; the Submit button reads them back as `{{page.<var>}}` and posts them to the web-to-lead endpoint.',
variant: 'body',
},
},
{
type: 'element:text_input',
id: 'field_name',
properties: { label: 'Name', placeholder: 'Ada Lovelace', required: true },
},
{
type: 'element:text_input',
id: 'field_email',
properties: {
label: 'Email',
inputType: 'email',
placeholder: 'ada@example.com',
required: true,
},
},
{
type: 'element:text_input',
id: 'field_company',
properties: { label: 'Company', placeholder: 'Analytical Engines Ltd.' },
},
{
type: 'element:text_input',
id: 'field_message',
properties: { label: 'Message', placeholder: 'What can we help with?', required: true },
},
// Live page-variable feedback — appears the instant an email is typed,
// proving the input wrote `page.inquiryEmail` (re-evaluated, no reload).
{
type: 'element:text',
id: 'ready_hint',
visibility: "page.inquiryEmail != ''",
properties: {
content: '✓ Looks good — hit Submit to send your inquiry.',
variant: 'caption',
},
},
{ type: 'element:divider' },
{
type: 'element:button',
id: 'submit_inquiry',
properties: {
label: 'Submit inquiry',
variant: 'primary',
icon: 'send',
// `api` action -> absolute endpoint. The runtime resolves the
// `{{page.<var>}}` tokens against the live snapshot before POSTing.
action: {
type: 'api',
target: '/api/v1/forms/contact-us/submit',
method: 'POST',
params: {
name: '{{page.inquiryName}}',
email: '{{page.inquiryEmail}}',
company: '{{page.inquiryCompany}}',
message: '{{page.inquiryMessage}}',
},
successMessage: 'Thanks! We received your inquiry.',
refreshAfter: false,
},
},
},
],
},
],
});
1 change: 1 addition & 0 deletions examples/app-showcase/src/pages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export { SettingsPage } from './settings.page.js';
export { StylingGalleryPage } from './styling-gallery.page.js';
export { CommandCenterPage } from './command-center.page.js';
export { PageVariablesPage } from './page-variables.page.js';
export { ContactFormPage } from './contact-form.page.js';
export {
TaskBoardPage,
TaskCalendarPage,
Expand Down
1 change: 1 addition & 0 deletions packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -3086,6 +3086,7 @@
"ElementMetadataViewerPropsSchema (const)",
"ElementNumberPropsSchema (const)",
"ElementRecordPickerPropsSchema (const)",
"ElementTextInputPropsSchema (const)",
"ElementTextPropsSchema (const)",
"EmbedConfig (type)",
"EmbedConfigSchema (const)",
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/liveness/page.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
},
"variables": {
"status": "live",
"note": "Page-local state (ADR-0049). objectui injects variables into the visible/CEL expression context as `page.<var>` (react/src/SchemaRenderer.tsx) and ships element:record_picker, which writes its selection into the variable named by PageVariableSchema.source (components/src/renderers/basic/record-picker.tsx); usePageVariableBinding resolves writer->variable. Showcase: showcase_page_variables (master/detail — picker drives a detail panel's visibility). objectui#1957."
"note": "Page-local state (ADR-0049). objectui injects variables into the visible/CEL expression context as `page.<var>` (react/src/SchemaRenderer.tsx) and ships element:record_picker + element:text_input, which write the user's selection / typed value into the variable named by PageVariableSchema.source (components/src/renderers/basic/record-picker.tsx, text-input.tsx); usePageVariableBinding resolves writer->variable. Submit actions read them back via `{{page.<var>}}` token interpolation in the console action runtime (app-shell/src/hooks/useConsoleActionRuntime.tsx) — the SDUI data-entry form loop. Showcases: showcase_page_variables (master/detail — picker drives a detail panel's visibility), showcase_contact_form (text inputs → page variables → submit). objectui#1957."
},
"object": {
"status": "live",
Expand Down
61 changes: 61 additions & 0 deletions packages/spec/src/ui/component.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
ElementFilterPropsSchema,
ElementFormPropsSchema,
ElementRecordPickerPropsSchema,
ElementTextInputPropsSchema,
} from './component.zod';
import { PageComponentSchema } from './page.zod';

Expand Down Expand Up @@ -556,6 +557,57 @@ describe('Interactive Elements — element:record_picker', () => {
});
});

// ---------------------------------------------------------------------------
// Interactive Elements — element:text_input
// ---------------------------------------------------------------------------
describe('Interactive Elements — element:text_input', () => {
it('should accept element:text_input component', () => {
expect(() => PageComponentSchema.parse({
type: 'element:text_input',
properties: { label: 'Workspace name' },
})).not.toThrow();
});

it('should parse text_input props with defaults', () => {
const props = ElementTextInputPropsSchema.parse({});
expect(props.inputType).toBe('text');
expect(props.required).toBe(false);
expect(props.disabled).toBe(false);
});

it('should accept full text_input props', () => {
const props = ElementTextInputPropsSchema.parse({
inputType: 'email',
label: 'Email',
placeholder: 'you@example.com',
defaultValue: 'a@b.com',
required: true,
disabled: false,
description: 'We never share it',
targetVariable: 'email',
});
expect(props.inputType).toBe('email');
expect(props.required).toBe(true);
expect(props.targetVariable).toBe('email');
});

it('should accept all input types', () => {
const types = ['text', 'email', 'number', 'tel', 'url', 'password'] as const;
types.forEach(inputType => {
expect(() => ElementTextInputPropsSchema.parse({ inputType })).not.toThrow();
});
});

it('should accept a numeric defaultValue', () => {
const props = ElementTextInputPropsSchema.parse({ inputType: 'number', defaultValue: 42 });
expect(props.defaultValue).toBe(42);
});

it('should reject an unknown input type', () => {
expect(() => ElementTextInputPropsSchema.parse({ inputType: 'color' })).toThrow();
});
});

// ---------------------------------------------------------------------------
// ComponentPropsMap — interactive elements
// ---------------------------------------------------------------------------
Expand All @@ -576,6 +628,10 @@ describe('ComponentPropsMap interactive elements', () => {
expect(ComponentPropsMap['element:record_picker']).toBeDefined();
});

it('should contain element:text_input', () => {
expect(ComponentPropsMap['element:text_input']).toBeDefined();
});

it('should parse element:button props', () => {
const result = ComponentPropsMap['element:button'].parse({ label: 'Click Me' });
expect(result.label).toBe('Click Me');
Expand All @@ -601,6 +657,11 @@ describe('ComponentPropsMap interactive elements', () => {
});
expect(result.object).toBe('account');
});

it('should parse element:text_input props', () => {
const result = ComponentPropsMap['element:text_input'].parse({ label: 'Name' });
expect(result.inputType).toBe('text');
});
});

// ---------------------------------------------------------------------------
Expand Down
28 changes: 28 additions & 0 deletions packages/spec/src/ui/component.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,33 @@ export const ElementRecordPickerPropsSchema = lazySchema(() => z.object({
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'),
}));

/**
* A single-line free-text input — the data-entry half of an SDUI page (Airtable
* "text"/"number" field parity). The console renderer binds the typed value into
* a page variable via the {@link PageVariableSchema} `source` convention (the
* variable whose `source` equals this component's `id`), exposing it to
* expressions as `page.<var>` and to submit actions as a `{{page.<var>}}` token.
* A whole free-text family lives under one element: `inputType` selects the
* native modality (text/email/number/…), keeping genuinely-distinct inputs
* (textarea, select, checkbox) free to arrive as their own elements later.
*/
export const ElementTextInputPropsSchema = lazySchema(() => z.object({
inputType: z.enum(['text', 'email', 'number', 'tel', 'url', 'password'])
.optional().default('text')
.describe('Native input type — drives keyboard/validation affordance and how the bound value is coerced (number → numeric).'),
label: I18nLabelSchema.optional().describe('Field label shown above the input'),
placeholder: I18nLabelSchema.optional().describe('Placeholder text shown when empty'),
defaultValue: z.union([z.string(), z.number()]).optional()
.describe('Initial value; seeds the bound page variable on mount'),
required: z.boolean().optional().default(false).describe('Mark the field as required'),
disabled: z.boolean().optional().default(false).describe('Disable the input'),
description: I18nLabelSchema.optional().describe('Helper text shown below the input'),
targetVariable: z.string().optional()
.describe('Page variable this input writes to. Declarative hint; the live binding resolves via the variable whose `source` equals this component id (see PageVariableSchema).'),
/** ARIA accessibility */
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'),
}));

/**
* ----------------------------------------------------------------------
* Component Props Map
Expand Down Expand Up @@ -346,6 +373,7 @@ export const ComponentPropsMap = {
'element:filter': ElementFilterPropsSchema,
'element:form': ElementFormPropsSchema,
'element:record_picker': ElementRecordPickerPropsSchema,
'element:text_input': ElementTextInputPropsSchema,
} as const;

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/src/ui/page.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export const PageComponentType = z.enum([
// Content Elements (Airtable Interface parity)
'element:text', 'element:number', 'element:image', 'element:divider',
// Interactive Elements (Phase B — Element Library)
'element:button', 'element:filter', 'element:form', 'element:record_picker'
'element:button', 'element:filter', 'element:form', 'element:record_picker', 'element:text_input'
]);

/**
Expand Down