Skip to content

Commit 3afaeed

Browse files
authored
feat(ui): add element:text_input — free-text data-entry element for SDUI pages (#2321)
Adds the ElementTextInputPropsSchema + element:text_input component type (enum + ComponentPropsMap) and the showcase_contact_form demo. Pairs with objectui#1995 (renderer + page-variable submit bridge).
1 parent 75abedc commit 3afaeed

10 files changed

Lines changed: 245 additions & 4 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(ui): add `element:text_input` — free-text data-entry element for SDUI pages
6+
7+
SDUI pages could display and navigate but not collect free-text input. This adds
8+
that half of the contract:
9+
10+
- `ElementTextInputPropsSchema` (label, placeholder, `inputType`
11+
text/email/number/tel/url/password — defaultValue, required, disabled,
12+
description) wired into `PageComponentType` and `ComponentPropsMap` as
13+
`element:text_input`.
14+
15+
The objectui renderer binds the typed value into a page variable
16+
(`PageVariableSchema.source`); a submit `element:button` reads it back via
17+
`{{page.<var>}}` token interpolation in the console action runtime. Showcase:
18+
`showcase_contact_form` (text inputs → page variables → POST web-to-lead).

examples/app-showcase/objectstack.config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { ChartGalleryDashboard, OpsDashboard } from './src/dashboards/index.js';
2222
import { ShowcaseTaskDataset, ShowcaseProjectDataset } from './src/datasets/index.js';
2323
import { allReports } from './src/reports/index.js';
2424
import { allActions } from './src/actions/index.js';
25-
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';
25+
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';
2626
import { allFlows } from './src/flows/index.js';
2727
import { allWebhooks } from './src/webhooks/index.js';
2828
import { allHooks } from './src/hooks/index.js';
@@ -155,7 +155,7 @@ export default defineStack({
155155
apps: [ShowcaseApp],
156156
portals: allPortals,
157157
views: [TaskViews, ProjectViews, InquiryViews, BusinessUnitViews],
158-
pages: [ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, AccountDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage, StylingGalleryPage, CommandCenterPage, PageVariablesPage],
158+
pages: [ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, AccountDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage, StylingGalleryPage, CommandCenterPage, PageVariablesPage, ContactFormPage],
159159
dashboards: [ChartGalleryDashboard, OpsDashboard],
160160
books: allBooks,
161161
datasets: [ShowcaseTaskDataset, ShowcaseProjectDataset],

examples/app-showcase/src/apps/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export const ShowcaseApp = App.create({
6767
{ id: 'nav_gallery', type: 'page', pageName: 'showcase_component_gallery', label: 'Component Gallery', icon: 'layout-template' },
6868
{ id: 'nav_styling_gallery', type: 'page', pageName: 'showcase_styling_gallery', label: 'Styling (ADR-0065)', icon: 'palette' },
6969
{ id: 'nav_page_variables', type: 'page', pageName: 'showcase_page_variables', label: 'Page Variables', icon: 'mouse-pointer-click' },
70+
{ id: 'nav_contact_form', type: 'page', pageName: 'showcase_contact_form', label: 'Contact Form', icon: 'mail-plus' },
7071
{ id: 'nav_project_workspace', type: 'page', pageName: 'showcase_project_workspace', label: 'New Project + Tasks', icon: 'folder-plus' },
7172
// ADR-0047 interface mode: same object as nav_tasks, curated surface.
7273
{ id: 'nav_task_workbench', type: 'page', pageName: 'showcase_task_workbench', label: 'Task Workbench', icon: 'sliders-horizontal' },
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { definePage } from '@objectstack/spec/ui';
4+
5+
/**
6+
* Contact Form — a pure-SDUI DATA-ENTRY page (the data-entry half of SDUI pages).
7+
*
8+
* Demonstrates the SDUI form loop end to end, with NO custom code:
9+
* 1. `variables` declares one page variable per field, each fed by a text
10+
* input (PageVariableSchema.source = that input's component id).
11+
* 2. `element:text_input` writes each keystroke into its bound variable
12+
* (objectui components/src/renderers/basic/text-input.tsx).
13+
* 3. The submit `element:button` runs an `api` action whose params reference
14+
* the variables as `{{page.<var>}}`. The console action runtime resolves
15+
* those tokens against the live page-variable snapshot (published by
16+
* PageVariableActionBridge) and POSTs the body to the public web-to-lead
17+
* endpoint, creating a `showcase_inquiry`.
18+
*
19+
* POST /api/v1/forms/contact-us/submit (ADR-0056 public form -> showcase_inquiry)
20+
*
21+
* This is the showcase analog of a branded cloud onboarding screen — collect a
22+
* few free-text fields, post them to a backend endpoint — but rendered by the
23+
* console design system (dark mode, i18n) instead of hand-rolled vanilla HTML.
24+
*/
25+
export const ContactFormPage = definePage({
26+
name: 'showcase_contact_form',
27+
label: 'Contact Form (SDUI data entry)',
28+
icon: 'mail-plus',
29+
type: 'app',
30+
template: 'header-sidebar-main',
31+
isDefault: false,
32+
// One page variable per field, each written by the text input whose `id`
33+
// matches `source`. Read back at submit time as `{{page.<name>}}`.
34+
variables: [
35+
{ name: 'inquiryName', type: 'string', source: 'field_name' },
36+
{ name: 'inquiryEmail', type: 'string', source: 'field_email' },
37+
{ name: 'inquiryCompany', type: 'string', source: 'field_company' },
38+
{ name: 'inquiryMessage', type: 'string', source: 'field_message' },
39+
],
40+
regions: [
41+
{
42+
name: 'header',
43+
width: 'full',
44+
components: [
45+
{
46+
type: 'page:header',
47+
properties: {
48+
title: 'Get in touch',
49+
subtitle:
50+
'A pure-SDUI form — each text input writes a page variable; Submit posts them as one request. No custom code.',
51+
},
52+
},
53+
],
54+
},
55+
{
56+
name: 'main',
57+
width: 'large',
58+
components: [
59+
{
60+
type: 'element:text',
61+
properties: {
62+
content:
63+
'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.',
64+
variant: 'body',
65+
},
66+
},
67+
{
68+
type: 'element:text_input',
69+
id: 'field_name',
70+
properties: { label: 'Name', placeholder: 'Ada Lovelace', required: true },
71+
},
72+
{
73+
type: 'element:text_input',
74+
id: 'field_email',
75+
properties: {
76+
label: 'Email',
77+
inputType: 'email',
78+
placeholder: 'ada@example.com',
79+
required: true,
80+
},
81+
},
82+
{
83+
type: 'element:text_input',
84+
id: 'field_company',
85+
properties: { label: 'Company', placeholder: 'Analytical Engines Ltd.' },
86+
},
87+
{
88+
type: 'element:text_input',
89+
id: 'field_message',
90+
properties: { label: 'Message', placeholder: 'What can we help with?', required: true },
91+
},
92+
// Live page-variable feedback — appears the instant an email is typed,
93+
// proving the input wrote `page.inquiryEmail` (re-evaluated, no reload).
94+
{
95+
type: 'element:text',
96+
id: 'ready_hint',
97+
visibility: "page.inquiryEmail != ''",
98+
properties: {
99+
content: '✓ Looks good — hit Submit to send your inquiry.',
100+
variant: 'caption',
101+
},
102+
},
103+
{ type: 'element:divider' },
104+
{
105+
type: 'element:button',
106+
id: 'submit_inquiry',
107+
properties: {
108+
label: 'Submit inquiry',
109+
variant: 'primary',
110+
icon: 'send',
111+
// `api` action -> absolute endpoint. The runtime resolves the
112+
// `{{page.<var>}}` tokens against the live snapshot before POSTing.
113+
action: {
114+
type: 'api',
115+
target: '/api/v1/forms/contact-us/submit',
116+
method: 'POST',
117+
params: {
118+
name: '{{page.inquiryName}}',
119+
email: '{{page.inquiryEmail}}',
120+
company: '{{page.inquiryCompany}}',
121+
message: '{{page.inquiryMessage}}',
122+
},
123+
successMessage: 'Thanks! We received your inquiry.',
124+
refreshAfter: false,
125+
},
126+
},
127+
},
128+
],
129+
},
130+
],
131+
});

examples/app-showcase/src/pages/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export { SettingsPage } from './settings.page.js';
1616
export { StylingGalleryPage } from './styling-gallery.page.js';
1717
export { CommandCenterPage } from './command-center.page.js';
1818
export { PageVariablesPage } from './page-variables.page.js';
19+
export { ContactFormPage } from './contact-form.page.js';
1920
export {
2021
TaskBoardPage,
2122
TaskCalendarPage,

packages/spec/api-surface.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3086,6 +3086,7 @@
30863086
"ElementMetadataViewerPropsSchema (const)",
30873087
"ElementNumberPropsSchema (const)",
30883088
"ElementRecordPickerPropsSchema (const)",
3089+
"ElementTextInputPropsSchema (const)",
30893090
"ElementTextPropsSchema (const)",
30903091
"EmbedConfig (type)",
30913092
"EmbedConfigSchema (const)",

packages/spec/liveness/page.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
},
2525
"variables": {
2626
"status": "live",
27-
"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."
27+
"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."
2828
},
2929
"object": {
3030
"status": "live",

packages/spec/src/ui/component.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
ElementFilterPropsSchema,
1717
ElementFormPropsSchema,
1818
ElementRecordPickerPropsSchema,
19+
ElementTextInputPropsSchema,
1920
} from './component.zod';
2021
import { PageComponentSchema } from './page.zod';
2122

@@ -556,6 +557,57 @@ describe('Interactive Elements — element:record_picker', () => {
556557
});
557558
});
558559

560+
// ---------------------------------------------------------------------------
561+
// Interactive Elements — element:text_input
562+
// ---------------------------------------------------------------------------
563+
describe('Interactive Elements — element:text_input', () => {
564+
it('should accept element:text_input component', () => {
565+
expect(() => PageComponentSchema.parse({
566+
type: 'element:text_input',
567+
properties: { label: 'Workspace name' },
568+
})).not.toThrow();
569+
});
570+
571+
it('should parse text_input props with defaults', () => {
572+
const props = ElementTextInputPropsSchema.parse({});
573+
expect(props.inputType).toBe('text');
574+
expect(props.required).toBe(false);
575+
expect(props.disabled).toBe(false);
576+
});
577+
578+
it('should accept full text_input props', () => {
579+
const props = ElementTextInputPropsSchema.parse({
580+
inputType: 'email',
581+
label: 'Email',
582+
placeholder: 'you@example.com',
583+
defaultValue: 'a@b.com',
584+
required: true,
585+
disabled: false,
586+
description: 'We never share it',
587+
targetVariable: 'email',
588+
});
589+
expect(props.inputType).toBe('email');
590+
expect(props.required).toBe(true);
591+
expect(props.targetVariable).toBe('email');
592+
});
593+
594+
it('should accept all input types', () => {
595+
const types = ['text', 'email', 'number', 'tel', 'url', 'password'] as const;
596+
types.forEach(inputType => {
597+
expect(() => ElementTextInputPropsSchema.parse({ inputType })).not.toThrow();
598+
});
599+
});
600+
601+
it('should accept a numeric defaultValue', () => {
602+
const props = ElementTextInputPropsSchema.parse({ inputType: 'number', defaultValue: 42 });
603+
expect(props.defaultValue).toBe(42);
604+
});
605+
606+
it('should reject an unknown input type', () => {
607+
expect(() => ElementTextInputPropsSchema.parse({ inputType: 'color' })).toThrow();
608+
});
609+
});
610+
559611
// ---------------------------------------------------------------------------
560612
// ComponentPropsMap — interactive elements
561613
// ---------------------------------------------------------------------------
@@ -576,6 +628,10 @@ describe('ComponentPropsMap interactive elements', () => {
576628
expect(ComponentPropsMap['element:record_picker']).toBeDefined();
577629
});
578630

631+
it('should contain element:text_input', () => {
632+
expect(ComponentPropsMap['element:text_input']).toBeDefined();
633+
});
634+
579635
it('should parse element:button props', () => {
580636
const result = ComponentPropsMap['element:button'].parse({ label: 'Click Me' });
581637
expect(result.label).toBe('Click Me');
@@ -601,6 +657,11 @@ describe('ComponentPropsMap interactive elements', () => {
601657
});
602658
expect(result.object).toBe('account');
603659
});
660+
661+
it('should parse element:text_input props', () => {
662+
const result = ComponentPropsMap['element:text_input'].parse({ label: 'Name' });
663+
expect(result.inputType).toBe('text');
664+
});
604665
});
605666

606667
// ---------------------------------------------------------------------------

packages/spec/src/ui/component.zod.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,33 @@ export const ElementRecordPickerPropsSchema = lazySchema(() => z.object({
296296
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'),
297297
}));
298298

299+
/**
300+
* A single-line free-text input — the data-entry half of an SDUI page (Airtable
301+
* "text"/"number" field parity). The console renderer binds the typed value into
302+
* a page variable via the {@link PageVariableSchema} `source` convention (the
303+
* variable whose `source` equals this component's `id`), exposing it to
304+
* expressions as `page.<var>` and to submit actions as a `{{page.<var>}}` token.
305+
* A whole free-text family lives under one element: `inputType` selects the
306+
* native modality (text/email/number/…), keeping genuinely-distinct inputs
307+
* (textarea, select, checkbox) free to arrive as their own elements later.
308+
*/
309+
export const ElementTextInputPropsSchema = lazySchema(() => z.object({
310+
inputType: z.enum(['text', 'email', 'number', 'tel', 'url', 'password'])
311+
.optional().default('text')
312+
.describe('Native input type — drives keyboard/validation affordance and how the bound value is coerced (number → numeric).'),
313+
label: I18nLabelSchema.optional().describe('Field label shown above the input'),
314+
placeholder: I18nLabelSchema.optional().describe('Placeholder text shown when empty'),
315+
defaultValue: z.union([z.string(), z.number()]).optional()
316+
.describe('Initial value; seeds the bound page variable on mount'),
317+
required: z.boolean().optional().default(false).describe('Mark the field as required'),
318+
disabled: z.boolean().optional().default(false).describe('Disable the input'),
319+
description: I18nLabelSchema.optional().describe('Helper text shown below the input'),
320+
targetVariable: z.string().optional()
321+
.describe('Page variable this input writes to. Declarative hint; the live binding resolves via the variable whose `source` equals this component id (see PageVariableSchema).'),
322+
/** ARIA accessibility */
323+
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'),
324+
}));
325+
299326
/**
300327
* ----------------------------------------------------------------------
301328
* Component Props Map
@@ -346,6 +373,7 @@ export const ComponentPropsMap = {
346373
'element:filter': ElementFilterPropsSchema,
347374
'element:form': ElementFormPropsSchema,
348375
'element:record_picker': ElementRecordPickerPropsSchema,
376+
'element:text_input': ElementTextInputPropsSchema,
349377
} as const;
350378

351379
/**

packages/spec/src/ui/page.zod.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export const PageComponentType = z.enum([
4444
// Content Elements (Airtable Interface parity)
4545
'element:text', 'element:number', 'element:image', 'element:divider',
4646
// Interactive Elements (Phase B — Element Library)
47-
'element:button', 'element:filter', 'element:form', 'element:record_picker'
47+
'element:button', 'element:filter', 'element:form', 'element:record_picker', 'element:text_input'
4848
]);
4949

5050
/**

0 commit comments

Comments
 (0)