@assistant-ui/react-generative-ui 0.0.16 → 0.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/a2ui/convert.d.ts.map +1 -1
  2. package/dist/a2ui/convert.js +11 -2
  3. package/dist/a2ui/convert.js.map +1 -1
  4. package/dist/a2ui/reducer.d.ts.map +1 -1
  5. package/dist/a2ui/reducer.js +63 -10
  6. package/dist/a2ui/reducer.js.map +1 -1
  7. package/dist/buildPresentParameters.js +4 -3
  8. package/dist/buildPresentParameters.js.map +1 -1
  9. package/dist/convert/boundSpec.d.ts +5 -1
  10. package/dist/convert/boundSpec.d.ts.map +1 -1
  11. package/dist/convert/boundSpec.js +13 -4
  12. package/dist/convert/boundSpec.js.map +1 -1
  13. package/dist/renderGenerativeUI.js +1 -1
  14. package/dist/renderGenerativeUI.js.map +1 -1
  15. package/dist/teams/toTeamsAttachments.d.ts.map +1 -1
  16. package/dist/teams/toTeamsAttachments.js +2 -5
  17. package/dist/teams/toTeamsAttachments.js.map +1 -1
  18. package/dist/vocabulary/alert.d.ts +1 -1
  19. package/dist/vocabulary/alert.d.ts.map +1 -1
  20. package/dist/vocabulary/alert.js +21 -16
  21. package/dist/vocabulary/alert.js.map +1 -1
  22. package/dist/vocabulary/data.d.ts.map +1 -1
  23. package/dist/vocabulary/data.js +20 -12
  24. package/dist/vocabulary/data.js.map +1 -1
  25. package/dist/vocabulary/fact.d.ts.map +1 -1
  26. package/dist/vocabulary/fact.js +3 -2
  27. package/dist/vocabulary/fact.js.map +1 -1
  28. package/dist/vocabulary/interactive.d.ts +6 -5
  29. package/dist/vocabulary/interactive.d.ts.map +1 -1
  30. package/dist/vocabulary/interactive.js +28 -23
  31. package/dist/vocabulary/interactive.js.map +1 -1
  32. package/dist/vocabulary/layout.d.ts.map +1 -1
  33. package/dist/vocabulary/layout.js +7 -5
  34. package/dist/vocabulary/layout.js.map +1 -1
  35. package/dist/vocabulary/text.d.ts.map +1 -1
  36. package/dist/vocabulary/text.js +4 -3
  37. package/dist/vocabulary/text.js.map +1 -1
  38. package/dist/vocabulary/toTextContent.d.ts +5 -0
  39. package/dist/vocabulary/toTextContent.d.ts.map +1 -0
  40. package/dist/vocabulary/toTextContent.js +6 -0
  41. package/dist/vocabulary/toTextContent.js.map +1 -0
  42. package/package.json +5 -5
  43. package/src/a2ui/convert.test.ts +36 -0
  44. package/src/a2ui/convert.ts +21 -2
  45. package/src/a2ui/reducer.test.ts +223 -0
  46. package/src/a2ui/reducer.ts +76 -12
  47. package/src/buildPresentParameters.ts +7 -4
  48. package/src/convert/boundSpec.test.ts +47 -2
  49. package/src/convert/boundSpec.ts +12 -14
  50. package/src/renderGenerativeUI.test.tsx +57 -0
  51. package/src/renderGenerativeUI.tsx +3 -1
  52. package/src/teams/toTeamsAttachments.ts +2 -9
  53. package/src/vocabulary/alert.test.tsx +10 -0
  54. package/src/vocabulary/alert.tsx +16 -7
  55. package/src/vocabulary/data.test.tsx +40 -0
  56. package/src/vocabulary/data.tsx +47 -26
  57. package/src/vocabulary/fact.test.tsx +12 -0
  58. package/src/vocabulary/fact.tsx +3 -2
  59. package/src/vocabulary/interactive.test.tsx +41 -0
  60. package/src/vocabulary/interactive.tsx +42 -28
  61. package/src/vocabulary/layout.test.tsx +16 -0
  62. package/src/vocabulary/layout.tsx +8 -4
  63. package/src/vocabulary/text.test.tsx +29 -0
  64. package/src/vocabulary/text.tsx +4 -3
  65. package/src/vocabulary/toTextContent.ts +2 -0
@@ -81,26 +81,20 @@ function boundNode(
81
81
  ancestors.delete(value);
82
82
  return result;
83
83
  }
84
- if (
85
- value !== null &&
86
- typeof value === "object" &&
87
- "children" in (value as Record<string, unknown>)
88
- ) {
84
+ if (value !== null && typeof value === "object") {
85
+ const record = value as Record<string, unknown>;
86
+ const children = record["children"];
87
+ // The spread performs a `children` read of its own, so the copy states the
88
+ // bounded value even when there is none.
89
+ if (children === undefined) return { ...record, children: undefined };
89
90
  if (ancestors.has(value)) {
90
91
  onClamp("cycle");
91
92
  return null;
92
93
  }
93
94
  ancestors.add(value);
94
- const record = value as Record<string, unknown>;
95
95
  const result = {
96
96
  ...record,
97
- children: boundNode(
98
- record["children"],
99
- depth + 1,
100
- onClamp,
101
- state,
102
- ancestors,
103
- ),
97
+ children: boundNode(children, depth + 1, onClamp, state, ancestors),
104
98
  };
105
99
  ancestors.delete(value);
106
100
  return result;
@@ -116,7 +110,11 @@ function boundNode(
116
110
  * (root, or `children` at any depth) is capped to {@link CHILDREN_CAP}
117
111
  * entries by index, which bounds a proxy however it fabricates `length` or
118
112
  * replaces its array methods; recursion itself is capped at
119
- * {@link MAX_TRAVERSAL_DEPTH}. `onClamp` fires once per level that was
113
+ * {@link MAX_TRAVERSAL_DEPTH}. Every object is returned as a plain copy whose
114
+ * `children` came from a single read, because a record that answers
115
+ * `[[HasProperty]]` and `[[Get]]`, or two `[[Get]]`s, differently would
116
+ * otherwise hand the bound one value and `normalizeSpec` another, skipping
117
+ * every bound here for that subtree. `onClamp` fires once per level that was
120
118
  * truncated, receiving the reason for that truncation: `"children"`,
121
119
  * `"depth"`, `"budget"`, or `"cycle"`. The walk also spends a total budget of
122
120
  * {@link NODE_BUDGET} nodes, so shared references cannot multiply work
@@ -114,6 +114,32 @@ describe("renderGenerativeUI", () => {
114
114
  expect(html).toBe("");
115
115
  });
116
116
 
117
+ it.each(["toString", "constructor"])(
118
+ "treats inherited %s as an unknown component",
119
+ (type) => {
120
+ const html = renderToStaticMarkup(
121
+ <>{renderGenerativeUI({ $type: type }, library)}</>,
122
+ );
123
+ expect(html).toBe("");
124
+ },
125
+ );
126
+
127
+ it("renders an explicitly registered prototype-named component", () => {
128
+ const prototypeNamedLibrary: GenerativeUILibrary = {
129
+ ...library,
130
+ toString: {
131
+ description: "An explicitly registered component.",
132
+ properties: z.object({}),
133
+ render: () => <span>registered</span>,
134
+ },
135
+ };
136
+
137
+ const html = renderToStaticMarkup(
138
+ <>{renderGenerativeUI({ $type: "toString" }, prototypeNamedLibrary)}</>,
139
+ );
140
+ expect(html).toBe("<span>registered</span>");
141
+ });
142
+
117
143
  it("holds back a node whose `$type` is still streaming", () => {
118
144
  const error = vi.spyOn(console, "error").mockImplementation(() => {});
119
145
  try {
@@ -263,6 +289,37 @@ describe("buildPresentParameters", () => {
263
289
  expect(schema.required).toEqual(["$type"]);
264
290
  });
265
291
 
292
+ it("keeps props whose names are inherited from Object.prototype", () => {
293
+ const schema = buildPresentParameters({
294
+ PrototypeProps: {
295
+ description: "Declares props that shadow Object.prototype members.",
296
+ properties: z.object({
297
+ toString: z.string(),
298
+ valueOf: z.number(),
299
+ constructor: z.boolean(),
300
+ }),
301
+ render: () => null,
302
+ },
303
+ }) as any;
304
+
305
+ expect(schema.properties.toString.type).toBe("string");
306
+ expect(schema.properties.valueOf.type).toBe("number");
307
+ expect(schema.properties.constructor.type).toBe("boolean");
308
+ });
309
+
310
+ it("omits __proto__, which the tool-argument decoder rejects", () => {
311
+ const schema = buildPresentParameters({
312
+ PrototypeProps: {
313
+ description: "Declares an undeliverable prop name.",
314
+ properties: z.object({ ["__proto__"]: z.number(), label: z.string() }),
315
+ render: () => null,
316
+ },
317
+ }) as any;
318
+
319
+ expect(Object.hasOwn(schema.properties, "__proto__")).toBe(false);
320
+ expect(schema.properties.label.type).toBe("string");
321
+ });
322
+
266
323
  it("names every component that declares the same prop in the dev warning", () => {
267
324
  const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
268
325
  try {
@@ -61,7 +61,9 @@ function renderElement(
61
61
  library: GenerativeUILibrary,
62
62
  context: GenerativeUIRenderContext,
63
63
  ): ReactNode {
64
- const entry = library[element.type];
64
+ const entry = Object.hasOwn(library, element.type)
65
+ ? library[element.type]
66
+ : undefined;
65
67
  if (!entry) {
66
68
  reportUnknownComponent(element.type, Object.keys(library));
67
69
  return null;
@@ -1,4 +1,5 @@
1
1
  import { boundSpec, clampReasonDetail } from "../convert/boundSpec";
2
+ import { copyBounded } from "../convert/copyBounded";
2
3
  import { isElement } from "../convert/isElement";
3
4
  import {
4
5
  normalizeSpec,
@@ -31,14 +32,6 @@ const normalizedList = (
31
32
  return node.flatMap((child) => normalizedList(child));
32
33
  };
33
34
 
34
- const clampArray = <T>(
35
- value: readonly T[],
36
- cap: number,
37
- ): { readonly items: T[]; readonly truncated: boolean } => ({
38
- items: value.slice(0, cap),
39
- truncated: value.length > cap,
40
- });
41
-
42
35
  /**
43
36
  * Recognizes a root that is a single `Carousel` element, returning its `Card`
44
37
  * children. A non-card child is routed through {@link discardedChild}, which
@@ -96,7 +89,7 @@ export function toTeamsAttachments(
96
89
  const { root } = normalizeSpec(bounded as never);
97
90
  const carouselCards = rootCarouselCards(root, context);
98
91
  if (carouselCards !== undefined) {
99
- const { items, truncated } = clampArray(
92
+ const { items, truncated } = copyBounded(
100
93
  carouselCards,
101
94
  CAROUSEL_ATTACHMENT_CAP,
102
95
  );
@@ -26,6 +26,16 @@ describe("alertVocabulary", () => {
26
26
  ).toContain('data-aui-tone="danger"');
27
27
  });
28
28
 
29
+ it("Alert ignores malformed text properties", () => {
30
+ expect(
31
+ render({
32
+ $type: "Alert",
33
+ title: { unexpected: true },
34
+ description: { unexpected: true },
35
+ }),
36
+ ).toBe('<div data-aui="alert" data-aui-tone="info" role="alert"></div>');
37
+ });
38
+
29
39
  it("Carousel wraps each child Card in a slide, capped at 10", () => {
30
40
  const children = Array.from({ length: 12 }, (_, i) => ({
31
41
  $type: "Card",
@@ -2,6 +2,7 @@ import { Children } from "react";
2
2
  import { z } from "zod";
3
3
  import type { GenerativeUILibrary } from "../types";
4
4
  import { ALERT_TONES } from "../ir";
5
+ import { toTextContent } from "./toTextContent";
5
6
 
6
7
  const MAX_CARDS = 10;
7
8
 
@@ -20,13 +21,21 @@ export const alertVocabulary = {
20
21
  .optional()
21
22
  .describe("Severity tone; defaults to `info`."),
22
23
  }),
23
- render: ({ title, description, tone, children }) => (
24
- <div data-aui="alert" data-aui-tone={tone ?? "info"} role="alert">
25
- {title ? <header data-aui="alert-title">{title}</header> : null}
26
- {description ? <p data-aui="alert-desc">{description}</p> : null}
27
- {children}
28
- </div>
29
- ),
24
+ render: ({ title, description, tone, children }) => {
25
+ const alertTitle = toTextContent(title);
26
+ const alertDescription = toTextContent(description);
27
+ return (
28
+ <div data-aui="alert" data-aui-tone={tone ?? "info"} role="alert">
29
+ {alertTitle ? (
30
+ <header data-aui="alert-title">{alertTitle}</header>
31
+ ) : null}
32
+ {alertDescription ? (
33
+ <p data-aui="alert-desc">{alertDescription}</p>
34
+ ) : null}
35
+ {children}
36
+ </div>
37
+ );
38
+ },
30
39
  },
31
40
  Carousel: {
32
41
  description:
@@ -32,6 +32,32 @@ describe("dataVocabulary", () => {
32
32
  expect(html).toContain("<td>only</td>");
33
33
  });
34
34
 
35
+ it("Table ignores malformed collections instead of throwing", () => {
36
+ expect(
37
+ render({
38
+ $type: "Table",
39
+ columns: "not-an-array",
40
+ rows: [["kept", null, {}, false], null],
41
+ }),
42
+ ).toBe(
43
+ '<table data-aui="table"><tbody><tr><td>kept</td><td></td><td></td><td>false</td></tr></tbody></table>',
44
+ );
45
+
46
+ expect(render({ $type: "Table", columns: [null], rows: [null] })).toBe(
47
+ '<table data-aui="table"></table>',
48
+ );
49
+
50
+ expect(
51
+ render({
52
+ $type: "Table",
53
+ columns: [null, { label: "Name" }],
54
+ rows: "not-an-array",
55
+ }),
56
+ ).toBe(
57
+ '<table data-aui="table"><thead><tr><th data-aui="table-col"></th><th data-aui="table-col">Name</th></tr></thead></table>',
58
+ );
59
+ });
60
+
35
61
  it("Markdown renders the value in a div", () => {
36
62
  expect(render({ $type: "Markdown", value: "# hi" })).toBe(
37
63
  '<div data-aui="markdown"># hi</div>',
@@ -52,6 +78,20 @@ describe("dataVocabulary", () => {
52
78
  ).toBe('<div data-aui="markdown">partial</div>');
53
79
  });
54
80
 
81
+ it("Markdown ignores malformed streaming values", () => {
82
+ expect(
83
+ renderToStaticMarkup(
84
+ <>
85
+ {renderGenerativeUI(
86
+ { $type: "Markdown", value: { unexpected: true } },
87
+ dataVocabulary,
88
+ { status: "streaming" },
89
+ )}
90
+ </>,
91
+ ),
92
+ ).toBe('<div data-aui="markdown"></div>');
93
+ });
94
+
55
95
  it("Chart bar variant renders one rect per data point", () => {
56
96
  const html = render({
57
97
  $type: "Chart",
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import type { GenerativeUILibrary } from "../types";
3
+ import { toTextContent } from "./toTextContent";
3
4
 
4
5
  const columnSchema = z.object({
5
6
  label: z.string().describe("Column header label."),
@@ -9,6 +10,20 @@ const cellSchema = z
9
10
  .union([z.string(), z.number(), z.boolean()])
10
11
  .describe("A cell value.");
11
12
 
13
+ type TableColumn = { label: string };
14
+ type TableCell = string | number | boolean;
15
+
16
+ const isTableColumn = (column: unknown): column is TableColumn =>
17
+ column !== null &&
18
+ typeof column === "object" &&
19
+ "label" in column &&
20
+ typeof column.label === "string";
21
+
22
+ const isTableCell = (cell: unknown): cell is TableCell =>
23
+ typeof cell === "string" ||
24
+ typeof cell === "number" ||
25
+ typeof cell === "boolean";
26
+
12
27
  const CHART_HEIGHT = 40;
13
28
  const CHART_WIDTH = 100;
14
29
 
@@ -282,33 +297,39 @@ export const dataVocabulary = {
282
297
  columns: z.array(columnSchema).optional().describe("Column definitions."),
283
298
  rows: z.array(z.array(cellSchema)).optional().describe("Rows of cells."),
284
299
  }),
285
- render: ({ columns, rows, children }) => (
286
- <table data-aui="table">
287
- {columns?.length ? (
288
- <thead>
289
- <tr>
290
- {columns.map((c: { label: string }, i: number) => (
291
- <th key={i} data-aui="table-col">
292
- {c.label}
293
- </th>
294
- ))}
295
- </tr>
296
- </thead>
297
- ) : null}
298
- {rows?.length ? (
299
- <tbody>
300
- {rows.map((row: (string | number | boolean)[], r: number) => (
301
- <tr key={r}>
302
- {row.map((cell: string | number | boolean, c: number) => (
303
- <td key={c}>{String(cell)}</td>
300
+ render: ({ columns, rows, children }) => {
301
+ const safeColumns = Array.isArray(columns) ? columns : [];
302
+ const hasColumns = safeColumns.some(isTableColumn);
303
+ const safeRows = Array.isArray(rows) ? rows.filter(Array.isArray) : [];
304
+
305
+ return (
306
+ <table data-aui="table">
307
+ {hasColumns ? (
308
+ <thead>
309
+ <tr>
310
+ {safeColumns.map((column, i) => (
311
+ <th key={i} data-aui="table-col">
312
+ {isTableColumn(column) ? column.label : ""}
313
+ </th>
304
314
  ))}
305
315
  </tr>
306
- ))}
307
- </tbody>
308
- ) : null}
309
- {children}
310
- </table>
311
- ),
316
+ </thead>
317
+ ) : null}
318
+ {safeRows.length ? (
319
+ <tbody>
320
+ {safeRows.map((row, r) => (
321
+ <tr key={r}>
322
+ {row.map((cell, c) => (
323
+ <td key={c}>{isTableCell(cell) ? String(cell) : ""}</td>
324
+ ))}
325
+ </tr>
326
+ ))}
327
+ </tbody>
328
+ ) : null}
329
+ {children}
330
+ </table>
331
+ );
332
+ },
312
333
  },
313
334
  Markdown: {
314
335
  description:
@@ -319,7 +340,7 @@ export const dataVocabulary = {
319
340
  streamProperties: true,
320
341
  render: ({ value, children }) => (
321
342
  <div data-aui="markdown">
322
- {value}
343
+ {toTextContent(value)}
323
344
  {children}
324
345
  </div>
325
346
  ),
@@ -12,4 +12,16 @@ describe("factVocabulary", () => {
12
12
  '<dl data-aui="fact"><dt data-aui="fact-label">Status</dt><dd data-aui="fact-value">open</dd></dl>',
13
13
  );
14
14
  });
15
+
16
+ it("Fact ignores malformed text properties", () => {
17
+ expect(
18
+ render({
19
+ $type: "Fact",
20
+ label: { unexpected: true },
21
+ value: { unexpected: true },
22
+ }),
23
+ ).toBe(
24
+ '<dl data-aui="fact"><dt data-aui="fact-label"></dt><dd data-aui="fact-value"></dd></dl>',
25
+ );
26
+ });
15
27
  });
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import type { GenerativeUILibrary } from "../types";
3
+ import { toTextContent } from "./toTextContent";
3
4
 
4
5
  export const factVocabulary = {
5
6
  Fact: {
@@ -11,9 +12,9 @@ export const factVocabulary = {
11
12
  }),
12
13
  render: ({ label, value, children }) => (
13
14
  <dl data-aui="fact">
14
- <dt data-aui="fact-label">{label}</dt>
15
+ <dt data-aui="fact-label">{toTextContent(label)}</dt>
15
16
  <dd data-aui="fact-value">
16
- {value}
17
+ {toTextContent(value)}
17
18
  {children}
18
19
  </dd>
19
20
  </dl>
@@ -103,6 +103,26 @@ describe("interactiveVocabulary", () => {
103
103
  expect(html).toContain('<option value="b">B</option>');
104
104
  });
105
105
 
106
+ it("Select ignores malformed options instead of throwing", () => {
107
+ expect(render({ $type: "Select" })).toBe(
108
+ '<select data-aui="select"></select>',
109
+ );
110
+ expect(render({ $type: "Select", options: "not-an-array" })).toBe(
111
+ '<select data-aui="select"></select>',
112
+ );
113
+ expect(
114
+ render({
115
+ $type: "Select",
116
+ options: [
117
+ null,
118
+ { label: "Missing value" },
119
+ { value: "Missing label" },
120
+ { label: "A", value: "a" },
121
+ ],
122
+ }),
123
+ ).toBe('<select data-aui="select"><option value="a">A</option></select>');
124
+ });
125
+
106
126
  it("Input renders a single-line input by default", () => {
107
127
  expect(render({ $type: "Input", placeholder: "type here" })).toBe(
108
128
  '<input data-aui="input" placeholder="type here"/>',
@@ -157,6 +177,27 @@ describe("interactiveVocabulary", () => {
157
177
  expect(html).toContain('checked=""');
158
178
  });
159
179
 
180
+ it.each([
181
+ [
182
+ { $type: "Button", label: { unexpected: true } },
183
+ '<button type="button" data-aui="button"></button>',
184
+ ],
185
+ [
186
+ {
187
+ $type: "Select",
188
+ placeholder: { unexpected: true },
189
+ options: [],
190
+ },
191
+ '<select data-aui="select"></select>',
192
+ ],
193
+ [
194
+ { $type: "Checkbox", label: { unexpected: true } },
195
+ '<label data-aui="checkbox"><input type="checkbox"/><span data-aui="checkbox-label"></span></label>',
196
+ ],
197
+ ])("ignores malformed control text properties", (node, expected) => {
198
+ expect(render(node)).toBe(expected);
199
+ });
200
+
160
201
  it("RadioGroup renders a fieldset with one radio per option", () => {
161
202
  const html = render({
162
203
  $type: "RadioGroup",
@@ -4,14 +4,25 @@ import type { Action } from "../ir";
4
4
  import { BUTTON_STYLES } from "../ir";
5
5
  import type { GenerativeUIDispatch, GenerativeUILibrary } from "../types";
6
6
  import { actionAttr, fire } from "./dispatch";
7
+ import { toTextContent } from "./toTextContent";
7
8
 
8
9
  const optionSchema = z.object({
9
10
  label: z.string(),
10
11
  value: z.string(),
11
12
  });
12
13
 
14
+ type Option = { label: string; value: string };
15
+
16
+ const isOption = (option: unknown): option is Option =>
17
+ option !== null &&
18
+ typeof option === "object" &&
19
+ "label" in option &&
20
+ typeof option.label === "string" &&
21
+ "value" in option &&
22
+ typeof option.value === "string";
23
+
13
24
  type RadioGroupRenderProps = {
14
- options: { label: string; value: string }[];
25
+ options: Option[];
15
26
  name?: string;
16
27
  label?: string;
17
28
  defaultValue?: string;
@@ -37,9 +48,7 @@ function RadioGroupRender({
37
48
  aria-label={label}
38
49
  >
39
50
  {safeOptions.map((option, i) =>
40
- option &&
41
- typeof option.label === "string" &&
42
- typeof option.value === "string" ? (
51
+ isOption(option) ? (
43
52
  <label key={i} data-aui="radiogroup-option">
44
53
  <input
45
54
  type="radio"
@@ -92,7 +101,7 @@ export const interactiveVocabulary = {
92
101
  data-aui-action={actionAttr($action)}
93
102
  onClick={submit ? undefined : () => fire($action, $dispatch)}
94
103
  >
95
- {label}
104
+ {toTextContent(label)}
96
105
  {children}
97
106
  </button>
98
107
  ),
@@ -120,28 +129,33 @@ export const interactiveVocabulary = {
120
129
  $action,
121
130
  $dispatch,
122
131
  children,
123
- }) => (
124
- <select
125
- data-aui="select"
126
- data-aui-action={actionAttr($action)}
127
- name={name}
128
- aria-label={label}
129
- defaultValue=""
130
- onChange={(e) => fire($action, $dispatch, e.currentTarget.value)}
131
- >
132
- {placeholder ? (
133
- <option value="" disabled>
134
- {placeholder}
135
- </option>
136
- ) : null}
137
- {options.map((o: { label: string; value: string }, i: number) => (
138
- <option key={i} value={o.value}>
139
- {o.label}
140
- </option>
141
- ))}
142
- {children}
143
- </select>
144
- ),
132
+ }) => {
133
+ const placeholderText = toTextContent(placeholder);
134
+ return (
135
+ <select
136
+ data-aui="select"
137
+ data-aui-action={actionAttr($action)}
138
+ name={name}
139
+ aria-label={label}
140
+ defaultValue=""
141
+ onChange={(e) => fire($action, $dispatch, e.currentTarget.value)}
142
+ >
143
+ {placeholderText ? (
144
+ <option value="" disabled>
145
+ {placeholderText}
146
+ </option>
147
+ ) : null}
148
+ {(Array.isArray(options) ? options : []).map((option, i) =>
149
+ isOption(option) ? (
150
+ <option key={i} value={option.value}>
151
+ {option.label}
152
+ </option>
153
+ ) : null,
154
+ )}
155
+ {children}
156
+ </select>
157
+ );
158
+ },
145
159
  },
146
160
  Input: {
147
161
  description:
@@ -251,7 +265,7 @@ export const interactiveVocabulary = {
251
265
  defaultChecked={defaultChecked}
252
266
  onChange={(e) => fire($action, $dispatch, e.currentTarget.checked)}
253
267
  />
254
- <span data-aui="checkbox-label">{label}</span>
268
+ <span data-aui="checkbox-label">{toTextContent(label)}</span>
255
269
  </label>
256
270
  ),
257
271
  },
@@ -114,6 +114,22 @@ describe("layoutVocabulary", () => {
114
114
  '<span data-aui="badge" data-aui-variant="success">new</span>',
115
115
  );
116
116
  });
117
+
118
+ it("ignores malformed card and badge text properties", () => {
119
+ expect(
120
+ render({
121
+ $type: "Card",
122
+ title: { unexpected: true },
123
+ confirm: { label: { unexpected: true } },
124
+ cancel: { label: { unexpected: true } },
125
+ }),
126
+ ).toBe(
127
+ '<section data-aui="card" data-aui-surface=""><footer data-aui="card-footer"><button type="button" data-aui="card-confirm"></button><button type="button" data-aui="card-cancel"></button></footer></section>',
128
+ );
129
+ expect(render({ $type: "Badge", value: { unexpected: true } })).toBe(
130
+ '<span data-aui="badge"></span>',
131
+ );
132
+ });
117
133
  });
118
134
 
119
135
  describe("layoutVocabulary Box", () => {
@@ -4,6 +4,7 @@ import type { GenerativeUILibrary } from "../types";
4
4
  import { ALIGNS, JUSTIFIES } from "../ir";
5
5
  import { fire } from "./dispatch";
6
6
  import { collectFormValuesFromEvent } from "./collectFormValues";
7
+ import { toTextContent } from "./toTextContent";
7
8
 
8
9
  const toCssLength = (value: string | number): string =>
9
10
  typeof value === "number" ? `${value}px` : value;
@@ -65,6 +66,7 @@ export const layoutVocabulary = {
65
66
  children,
66
67
  }) => {
67
68
  const Root = asForm ? "form" : "section";
69
+ const cardTitle = toTextContent(title);
68
70
  const footer =
69
71
  confirm || cancel ? (
70
72
  <footer data-aui="card-footer">
@@ -76,7 +78,7 @@ export const layoutVocabulary = {
76
78
  asForm ? undefined : () => fire(confirm.$action, $dispatch)
77
79
  }
78
80
  >
79
- {confirm.label}
81
+ {toTextContent(confirm.label)}
80
82
  </button>
81
83
  ) : null}
82
84
  {cancel ? (
@@ -85,7 +87,7 @@ export const layoutVocabulary = {
85
87
  data-aui="card-cancel"
86
88
  onClick={() => fire(cancel.$action, $dispatch)}
87
89
  >
88
- {cancel.label}
90
+ {toTextContent(cancel.label)}
89
91
  </button>
90
92
  ) : null}
91
93
  </footer>
@@ -118,7 +120,9 @@ export const layoutVocabulary = {
118
120
  : undefined
119
121
  }
120
122
  >
121
- {title ? <header data-aui="card-title">{title}</header> : null}
123
+ {cardTitle ? (
124
+ <header data-aui="card-title">{cardTitle}</header>
125
+ ) : null}
122
126
  {children}
123
127
  {footer}
124
128
  </Root>
@@ -182,7 +186,7 @@ export const layoutVocabulary = {
182
186
  }),
183
187
  render: ({ value, variant, children }) => (
184
188
  <span data-aui="badge" data-aui-variant={variant}>
185
- {value}
189
+ {toTextContent(value)}
186
190
  {children}
187
191
  </span>
188
192
  ),