Skip to content

Commit 71f0e33

Browse files
committed
feat: add field-control archetype and new form components
Add support for input, textarea, checkbox and radio-group components following the field-control semantic archetype: - Extend component schemas and validation for boolean/numeric typed props - Add runtime renderer support for field-control components with label/control/description structure - Update test fixtures and validation tests for new form components - Update documentation and roadmap to reflect current component adoption progress - Add proper prop coercion for boolean and numeric values in render mappings
1 parent 193e78d commit 71f0e33

27 files changed

Lines changed: 1044 additions & 38 deletions

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,10 @@ The schema is the public contract. Agents write content structure, not raw HTML,
9898
9999
╰──────────────
100100
```
101+
thanks:
102+
- [Linux DO](linux.do)
101103

104+
## Ref
102105
- [Quick Start](https://agent-html.pages.dev/docs)
103106
- [Best Practice](https://agent-html.pages.dev/docs/best-practice)
104107
- [Dev Docs](https://agent-html.pages.dev/docs/dev-docs)

packages/ahtml/src/cli/cli-test-helpers.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,17 @@ export const validAgentHtmlFixtures = [
2626
'<meta-agent profile="ops-compact" />',
2727
'<page title="Dashboard"><card title="Queue">Ready.</card></page>',
2828
].join("\n"),
29+
[
30+
'<page title="Review Form">',
31+
' <card title="Decision">',
32+
' <checkbox label="Ship now" checked="true" description="Boolean field." />',
33+
' <radio-group label="Direction" value="ship" description="Single-select field.">',
34+
' <option value="ship" label="Ship">Use the current direction.</option>',
35+
' <option value="hold" label="Hold">Wait for the guard.</option>',
36+
" </radio-group>",
37+
" </card>",
38+
"</page>",
39+
].join("\n"),
2940
]
3041

3142
export type CliSchemaOutput = {

packages/ahtml/src/cli/runtime-renderability.mjs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ const rendererSpecScalarFields = [
1212
"requiredRegistryItem",
1313
"kind",
1414
"component",
15+
"control",
16+
"label",
17+
"description",
1518
"root",
1619
"title",
1720
"titleContainer",
@@ -24,11 +27,15 @@ const rendererSpecScalarFields = [
2427
"headerCell",
2528
"bodyCell",
2629
"item",
30+
"labelClassName",
31+
"descriptionClassName",
2732
"itemSlot",
2833
"rowSlot",
2934
"cellSlot",
3035
"rootClassName",
3136
"titleClassName",
37+
"labelProp",
38+
"descriptionProp",
3239
"titleProp",
3340
"defaultProp",
3441
"fallback",

packages/ahtml/src/cli/runtime-surface.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -436,7 +436,7 @@ async function createRuntimeFixture({
436436
)
437437
await writeFile(
438438
path.join(runtimePaths.runtimeSrcDir, "renderer", "kinds.ts"),
439-
'export const runtimeRendererKinds = ["collection","compound","interactive-collection","primitive","table","tabs"] as const\nexport type RendererKind = (typeof runtimeRendererKinds)[number]\n',
439+
'export const runtimeRendererKinds = ["collection","compound","field-control","interactive-collection","primitive","table","tabs"] as const\nexport type RendererKind = (typeof runtimeRendererKinds)[number]\n',
440440
)
441441
await writeFile(
442442
path.join(runtimePaths.runtimeSrcDir, "renderer", "parity.ts"),
@@ -608,6 +608,10 @@ function getRequiredComponentExports(component: string) {
608608
alert: ["Alert", "AlertDescription", "AlertTitle"],
609609
badge: ["Badge"],
610610
card: ["Card", "CardContent", "CardHeader", "CardTitle"],
611+
checkbox: ["Checkbox"],
612+
input: ["Input"],
613+
progress: ["Progress"],
614+
"radio-group": ["RadioGroup", "RadioGroupItem"],
611615
separator: ["Separator"],
612616
table: [
613617
"Table",
@@ -617,6 +621,7 @@ function getRequiredComponentExports(component: string) {
617621
"TableHeader",
618622
"TableRow",
619623
],
624+
textarea: ["Textarea"],
620625
tabs: ["Tabs", "TabsContent", "TabsList", "TabsTrigger"],
621626
}
622627
const exports = exportMap[component]

packages/ahtml/src/cli/runtime-template/src/renderer/kinds.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export const runtimeRendererKinds = [
22
"collection",
33
"compound",
4+
"field-control",
45
"interactive-collection",
56
"primitive",
67
"table",

packages/ahtml/src/cli/runtime-template/src/renderer/render-node.test.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,180 @@ describe("createRendererNode", () => {
152152
expect(markup).toContain('value="0"')
153153
})
154154

155+
it("renders field-control components with visible labels and default values", () => {
156+
const rendererSpecByName = new Map([
157+
[
158+
"textarea",
159+
{
160+
name: "textarea",
161+
kind: "field-control",
162+
renderKind: "field-control",
163+
slots: [{ name: "children", children: [] }],
164+
root: "div",
165+
label: "p",
166+
control: "textarea",
167+
description: "p",
168+
rootClassName: "grid gap-2",
169+
labelClassName: "label",
170+
descriptionClassName: "description",
171+
labelProp: "label",
172+
descriptionProp: "description",
173+
propMappings: [
174+
{ prop: "value", target: "defaultValue" },
175+
{ prop: "label", target: "aria-label" },
176+
],
177+
},
178+
],
179+
])
180+
181+
const RendererNode = createRendererNode(rendererSpecByName)
182+
const markup = renderToStaticMarkup(
183+
React.createElement(RendererNode, {
184+
node: {
185+
type: "component",
186+
name: "textarea",
187+
props: {
188+
label: "Notes",
189+
value: "Ship after the guard lands.",
190+
description: "Long-form field.",
191+
},
192+
children: [],
193+
},
194+
}),
195+
)
196+
197+
expect(markup).toContain('data-agent-html-component="textarea"')
198+
expect(markup).toContain("<p class=\"label\">Notes</p>")
199+
expect(markup).toContain(
200+
"<textarea aria-label=\"Notes\">Ship after the guard lands.</textarea>",
201+
)
202+
expect(markup).toContain('aria-label="Notes"')
203+
expect(markup).toContain("<p class=\"description\">Long-form field.</p>")
204+
})
205+
206+
it("renders checkbox field-control components with boolean prop coercion", () => {
207+
const rendererSpecByName = new Map([
208+
[
209+
"checkbox",
210+
{
211+
name: "checkbox",
212+
kind: "field-control",
213+
renderKind: "field-control",
214+
slots: [{ name: "children", children: [] }],
215+
root: "div",
216+
label: "p",
217+
control: "input",
218+
description: "p",
219+
rootClassName: "grid gap-2",
220+
labelClassName: "label",
221+
descriptionClassName: "description",
222+
labelProp: "label",
223+
descriptionProp: "description",
224+
propMappings: [
225+
{ prop: "checked", target: "defaultChecked", coerce: "boolean" },
226+
{ prop: "label", target: "aria-label" },
227+
],
228+
},
229+
],
230+
])
231+
232+
const RendererNode = createRendererNode(rendererSpecByName)
233+
const markup = renderToStaticMarkup(
234+
React.createElement(RendererNode, {
235+
node: {
236+
type: "component",
237+
name: "checkbox",
238+
props: {
239+
label: "Ship now",
240+
checked: "true",
241+
description: "Boolean field.",
242+
},
243+
children: [],
244+
},
245+
}),
246+
)
247+
248+
expect(markup).toContain("<p class=\"label\">Ship now</p>")
249+
expect(markup).toContain("<input")
250+
expect(markup).toContain('aria-label="Ship now"')
251+
expect(markup).toContain("checked")
252+
expect(markup).toContain("<p class=\"description\">Boolean field.</p>")
253+
})
254+
255+
it("renders radio-group field-control components with option children", () => {
256+
const rendererSpecByName = new Map([
257+
[
258+
"radio-group",
259+
{
260+
name: "radio-group",
261+
kind: "field-control",
262+
renderKind: "field-control",
263+
slots: [
264+
{
265+
name: "option",
266+
childNames: ["option"],
267+
children: ["text"],
268+
},
269+
],
270+
root: "div",
271+
label: "p",
272+
control: "RadioGroup",
273+
item: "RadioGroupItem",
274+
itemSlot: "option",
275+
itemValueProp: "value",
276+
itemHeadingProp: "label",
277+
description: "p",
278+
rootClassName: "grid gap-3",
279+
labelClassName: "label",
280+
descriptionClassName: "description",
281+
labelProp: "label",
282+
descriptionProp: "description",
283+
propMappings: [
284+
{ prop: "value", target: "defaultValue" },
285+
{ prop: "label", target: "aria-label" },
286+
],
287+
},
288+
],
289+
])
290+
291+
const RendererNode = createRendererNode(rendererSpecByName)
292+
const markup = renderToStaticMarkup(
293+
React.createElement(RendererNode, {
294+
node: {
295+
type: "component",
296+
name: "radio-group",
297+
props: {
298+
label: "Direction",
299+
value: "ship",
300+
description: "Single-select field.",
301+
},
302+
children: [
303+
{
304+
type: "component",
305+
name: "option",
306+
props: { value: "ship", label: "Ship" },
307+
children: [{ type: "text", value: "Use the current direction." }],
308+
},
309+
{
310+
type: "component",
311+
name: "option",
312+
props: { value: "hold", label: "Hold" },
313+
children: [{ type: "text", value: "Wait for the guard." }],
314+
},
315+
],
316+
},
317+
}),
318+
)
319+
320+
expect(markup).toContain("<p class=\"label\">Direction</p>")
321+
expect(markup).toContain("<RadioGroup")
322+
expect(markup).toContain('aria-label="Direction"')
323+
expect(markup).toContain("<RadioGroupItem")
324+
expect(markup).toContain(">Ship</span>")
325+
expect(markup).toContain("Use the current direction.")
326+
expect(markup).toContain("<p class=\"description\">Single-select field.</p>")
327+
})
328+
155329
it("renders a no-script fallback for accordion items when configured", () => {
156330
const rendererSpecByName = new Map([
157331
[

0 commit comments

Comments
 (0)