@assistant-ui/react-generative-ui 0.0.17 → 0.0.19

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 (141) hide show
  1. package/dist/JSONGenerativeUI.client.d.ts +1 -2
  2. package/dist/JSONGenerativeUI.client.d.ts.map +1 -1
  3. package/dist/JSONGenerativeUI.server.d.ts +1 -2
  4. package/dist/JSONGenerativeUI.server.d.ts.map +1 -1
  5. package/dist/JSONGenerativeUI.shared.d.ts +8 -9
  6. package/dist/JSONGenerativeUI.shared.d.ts.map +1 -1
  7. package/dist/a2ui/convert.d.ts +1 -2
  8. package/dist/a2ui/convert.d.ts.map +1 -1
  9. package/dist/a2ui/convert.js +11 -2
  10. package/dist/a2ui/convert.js.map +1 -1
  11. package/dist/a2ui/reducer.d.ts +1 -2
  12. package/dist/a2ui/reducer.d.ts.map +1 -1
  13. package/dist/a2ui/reducer.js +63 -10
  14. package/dist/a2ui/reducer.js.map +1 -1
  15. package/dist/a2ui/types.d.ts +17 -18
  16. package/dist/a2ui/types.d.ts.map +1 -1
  17. package/dist/actionRegistry.d.ts +5 -6
  18. package/dist/actionRegistry.d.ts.map +1 -1
  19. package/dist/buildPresentParameters.d.ts +2 -3
  20. package/dist/buildPresentParameters.d.ts.map +1 -1
  21. package/dist/buildPresentParameters.js +4 -3
  22. package/dist/buildPresentParameters.js.map +1 -1
  23. package/dist/constants.d.ts +2 -2
  24. package/dist/constants.d.ts.map +1 -1
  25. package/dist/constants.js +2 -1
  26. package/dist/constants.js.map +1 -1
  27. package/dist/convert/boundSpec.d.ts +7 -8
  28. package/dist/convert/boundSpec.d.ts.map +1 -1
  29. package/dist/convert/copyBounded.d.ts +1 -2
  30. package/dist/convert/copyBounded.d.ts.map +1 -1
  31. package/dist/convert/isElement.d.ts +1 -2
  32. package/dist/convert/isElement.d.ts.map +1 -1
  33. package/dist/convert/takeRun.d.ts +1 -2
  34. package/dist/convert/takeRun.d.ts.map +1 -1
  35. package/dist/defineGenerativeComponents.d.ts +1 -2
  36. package/dist/defineGenerativeComponents.d.ts.map +1 -1
  37. package/dist/generativeUIToJSX.d.ts +2 -3
  38. package/dist/generativeUIToJSX.d.ts.map +1 -1
  39. package/dist/ir.d.ts +28 -29
  40. package/dist/ir.d.ts.map +1 -1
  41. package/dist/renderGenerativeUI.d.ts +1 -2
  42. package/dist/renderGenerativeUI.d.ts.map +1 -1
  43. package/dist/renderGenerativeUI.js +1 -1
  44. package/dist/renderGenerativeUI.js.map +1 -1
  45. package/dist/slack/constants.d.ts +35 -36
  46. package/dist/slack/constants.d.ts.map +1 -1
  47. package/dist/slack/decodeBlockAction.d.ts +1 -2
  48. package/dist/slack/decodeBlockAction.d.ts.map +1 -1
  49. package/dist/slack/fromSlackBlocks.d.ts +1 -2
  50. package/dist/slack/fromSlackBlocks.d.ts.map +1 -1
  51. package/dist/slack/toSlackBlocks.d.ts +1 -2
  52. package/dist/slack/toSlackBlocks.d.ts.map +1 -1
  53. package/dist/slack/types.d.ts +32 -33
  54. package/dist/slack/types.d.ts.map +1 -1
  55. package/dist/teams/constants.d.ts +14 -15
  56. package/dist/teams/constants.d.ts.map +1 -1
  57. package/dist/teams/decodeSubmitData.d.ts +1 -2
  58. package/dist/teams/decodeSubmitData.d.ts.map +1 -1
  59. package/dist/teams/toAdaptiveCard.d.ts +6 -7
  60. package/dist/teams/toAdaptiveCard.d.ts.map +1 -1
  61. package/dist/teams/toTeamsAttachments.d.ts +1 -2
  62. package/dist/teams/toTeamsAttachments.d.ts.map +1 -1
  63. package/dist/teams/types.d.ts +29 -30
  64. package/dist/teams/types.d.ts.map +1 -1
  65. package/dist/types.d.ts +9 -10
  66. package/dist/types.d.ts.map +1 -1
  67. package/dist/vocabulary/alert.d.ts +2 -3
  68. package/dist/vocabulary/alert.d.ts.map +1 -1
  69. package/dist/vocabulary/alert.js +21 -16
  70. package/dist/vocabulary/alert.js.map +1 -1
  71. package/dist/vocabulary/collectFormValues.d.ts +6 -5
  72. package/dist/vocabulary/collectFormValues.d.ts.map +1 -1
  73. package/dist/vocabulary/collectFormValues.js +3 -2
  74. package/dist/vocabulary/collectFormValues.js.map +1 -1
  75. package/dist/vocabulary/data.d.ts +1 -2
  76. package/dist/vocabulary/data.d.ts.map +1 -1
  77. package/dist/vocabulary/data.js +20 -12
  78. package/dist/vocabulary/data.js.map +1 -1
  79. package/dist/vocabulary/dispatch.d.ts +2 -3
  80. package/dist/vocabulary/dispatch.d.ts.map +1 -1
  81. package/dist/vocabulary/fact.d.ts +1 -2
  82. package/dist/vocabulary/fact.d.ts.map +1 -1
  83. package/dist/vocabulary/fact.js +3 -2
  84. package/dist/vocabulary/fact.js.map +1 -1
  85. package/dist/vocabulary/form.d.ts +1 -2
  86. package/dist/vocabulary/form.d.ts.map +1 -1
  87. package/dist/vocabulary/icon.d.ts +1 -2
  88. package/dist/vocabulary/icon.d.ts.map +1 -1
  89. package/dist/vocabulary/index.d.ts +1 -2
  90. package/dist/vocabulary/index.d.ts.map +1 -1
  91. package/dist/vocabulary/interactive.d.ts +10 -9
  92. package/dist/vocabulary/interactive.d.ts.map +1 -1
  93. package/dist/vocabulary/interactive.js +30 -23
  94. package/dist/vocabulary/interactive.js.map +1 -1
  95. package/dist/vocabulary/layout.d.ts +1 -2
  96. package/dist/vocabulary/layout.d.ts.map +1 -1
  97. package/dist/vocabulary/layout.js +7 -5
  98. package/dist/vocabulary/layout.js.map +1 -1
  99. package/dist/vocabulary/list.d.ts +1 -2
  100. package/dist/vocabulary/list.d.ts.map +1 -1
  101. package/dist/vocabulary/media.d.ts +1 -2
  102. package/dist/vocabulary/media.d.ts.map +1 -1
  103. package/dist/vocabulary/text.d.ts +2 -3
  104. package/dist/vocabulary/text.d.ts.map +1 -1
  105. package/dist/vocabulary/text.js +4 -3
  106. package/dist/vocabulary/text.js.map +1 -1
  107. package/dist/vocabulary/toTextContent.d.ts +4 -0
  108. package/dist/vocabulary/toTextContent.d.ts.map +1 -0
  109. package/dist/vocabulary/toTextContent.js +6 -0
  110. package/dist/vocabulary/toTextContent.js.map +1 -0
  111. package/package.json +12 -10
  112. package/src/a2ui/convert.test.ts +36 -0
  113. package/src/a2ui/convert.ts +21 -2
  114. package/src/a2ui/reducer.test.ts +223 -0
  115. package/src/a2ui/reducer.ts +76 -12
  116. package/src/buildPresentParameters.ts +7 -4
  117. package/src/constants.ts +2 -0
  118. package/src/renderGenerativeUI.test.tsx +57 -0
  119. package/src/renderGenerativeUI.tsx +3 -1
  120. package/src/slack/fromSlackBlocks.test.ts +4 -1
  121. package/src/slack/toSlackBlocks.test.ts +8 -4
  122. package/src/teams/toAdaptiveCard.test.ts +5 -3
  123. package/src/vocabulary/alert.test.tsx +10 -0
  124. package/src/vocabulary/alert.tsx +16 -7
  125. package/src/vocabulary/collectFormValues.test.ts +38 -0
  126. package/src/vocabulary/collectFormValues.ts +11 -2
  127. package/src/vocabulary/data.test.tsx +40 -0
  128. package/src/vocabulary/data.tsx +47 -26
  129. package/src/vocabulary/fact.test.tsx +12 -0
  130. package/src/vocabulary/fact.tsx +3 -2
  131. package/src/vocabulary/form.test.tsx +1 -0
  132. package/src/vocabulary/interactive.test.tsx +55 -1
  133. package/src/vocabulary/interactive.tsx +50 -29
  134. package/src/vocabulary/layout.test.tsx +17 -0
  135. package/src/vocabulary/layout.tsx +8 -4
  136. package/src/vocabulary/list.test.tsx +5 -4
  137. package/src/vocabulary/text.test.tsx +29 -0
  138. package/src/vocabulary/text.tsx +4 -3
  139. package/src/vocabulary/toTextContent.ts +2 -0
  140. package/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts +0 -141
  141. package/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts.map +0 -1
@@ -1,4 +1,5 @@
1
1
  import type { FormEvent } from "react";
2
+ import { GENERATED_NAME_ATTR } from "../constants";
2
3
 
3
4
  /**
4
5
  * The subset of `HTMLInputElement`/`HTMLSelectElement`/`HTMLTextAreaElement` that {@link collectFormValues} reads. A structural type rather than the DOM interfaces themselves, so a plain object can stand in for a form control in tests.
@@ -9,10 +10,12 @@ export type FormControlElementLike = {
9
10
  readonly value: string;
10
11
  readonly checked?: boolean;
11
12
  readonly disabled: boolean;
13
+ readonly hasAttribute: (name: string) => boolean;
14
+ readonly matches?: ((selector: string) => boolean) | undefined;
12
15
  };
13
16
 
14
17
  /**
15
- * Collects a submitted form's named control values into a plain object, keyed by `name`, in document order. Reads each control's live DOM state rather than `FormData`, so a checkbox resolves to its `checked` boolean instead of an on/off string. A radio group resolves to its checked option's `value`, or `undefined` if none is checked. Any other repeated `name` resolves to an array of its controls' values, in document order. Controls without a `name`, or that are `disabled`, are skipped entirely.
18
+ * Collects a submitted form's named control values into a plain object, keyed by `name`, in document order. Reads each control's live DOM state rather than `FormData`, so a checkbox resolves to its `checked` boolean instead of an on/off string. A radio group resolves to its checked option's `value`, or `undefined` if none is checked. Any other repeated `name` resolves to an array of its controls' values, in document order. Controls without a `name`, that carry `data-aui-generated-name`, or that are effectively disabled, including through an ancestor disabled fieldset outside its first legend, are skipped entirely.
16
19
  */
17
20
  export function collectFormValues(
18
21
  elements: ArrayLike<FormControlElementLike>,
@@ -21,7 +24,13 @@ export function collectFormValues(
21
24
 
22
25
  for (const element of Array.from(elements)) {
23
26
  const { name, disabled } = element;
24
- if (!name || disabled) continue;
27
+ if (
28
+ !name ||
29
+ disabled ||
30
+ element.hasAttribute(GENERATED_NAME_ATTR) ||
31
+ element.matches?.(":disabled")
32
+ )
33
+ continue;
25
34
 
26
35
  if (element.type === "radio") {
27
36
  if (element.checked) values[name] = element.value;
@@ -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>
@@ -16,6 +16,7 @@ const el = (
16
16
  type: "text",
17
17
  value: "",
18
18
  disabled: false,
19
+ hasAttribute: () => false,
19
20
  ...partial,
20
21
  });
21
22
 
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import { renderToStaticMarkup } from "react-dom/server";
3
+ import { GENERATED_NAME_ATTR } from "../constants";
3
4
  import { renderGenerativeUI } from "../renderGenerativeUI";
4
5
  import { interactiveVocabulary } from "./interactive";
5
6
 
@@ -103,6 +104,26 @@ describe("interactiveVocabulary", () => {
103
104
  expect(html).toContain('<option value="b">B</option>');
104
105
  });
105
106
 
107
+ it("Select ignores malformed options instead of throwing", () => {
108
+ expect(render({ $type: "Select" })).toBe(
109
+ '<select data-aui="select"></select>',
110
+ );
111
+ expect(render({ $type: "Select", options: "not-an-array" })).toBe(
112
+ '<select data-aui="select"></select>',
113
+ );
114
+ expect(
115
+ render({
116
+ $type: "Select",
117
+ options: [
118
+ null,
119
+ { label: "Missing value" },
120
+ { value: "Missing label" },
121
+ { label: "A", value: "a" },
122
+ ],
123
+ }),
124
+ ).toBe('<select data-aui="select"><option value="a">A</option></select>');
125
+ });
126
+
106
127
  it("Input renders a single-line input by default", () => {
107
128
  expect(render({ $type: "Input", placeholder: "type here" })).toBe(
108
129
  '<input data-aui="input" placeholder="type here"/>',
@@ -157,6 +178,27 @@ describe("interactiveVocabulary", () => {
157
178
  expect(html).toContain('checked=""');
158
179
  });
159
180
 
181
+ it.each([
182
+ [
183
+ { $type: "Button", label: { unexpected: true } },
184
+ '<button type="button" data-aui="button"></button>',
185
+ ],
186
+ [
187
+ {
188
+ $type: "Select",
189
+ placeholder: { unexpected: true },
190
+ options: [],
191
+ },
192
+ '<select data-aui="select"></select>',
193
+ ],
194
+ [
195
+ { $type: "Checkbox", label: { unexpected: true } },
196
+ '<label data-aui="checkbox"><input type="checkbox"/><span data-aui="checkbox-label"></span></label>',
197
+ ],
198
+ ])("ignores malformed control text properties", (node, expected) => {
199
+ expect(render(node)).toBe(expected);
200
+ });
201
+
160
202
  it("RadioGroup renders a fieldset with one radio per option", () => {
161
203
  const html = render({
162
204
  $type: "RadioGroup",
@@ -191,6 +233,7 @@ describe("interactiveVocabulary", () => {
191
233
  ],
192
234
  });
193
235
  expect((html.match(/name="size"/g) ?? []).length).toBe(2);
236
+ expect(html).not.toContain(GENERATED_NAME_ATTR);
194
237
  });
195
238
 
196
239
  it("RadioGroup falls back to a generated shared name when name is omitted", () => {
@@ -201,10 +244,21 @@ describe("interactiveVocabulary", () => {
201
244
  { label: "Large", value: "lg" },
202
245
  ],
203
246
  });
204
- const names = [...html.matchAll(/name="([^"]*)"/g)].map((m) => m[1]);
247
+ const names = [...html.matchAll(/ name="([^"]*)"/g)].map((m) => m[1]);
205
248
  expect(names.length).toBe(2);
206
249
  expect(names[0]).toBe(names[1]);
207
250
  expect(names[0]).toBeTruthy();
251
+ expect(html.split(`${GENERATED_NAME_ATTR}=""`).length - 1).toBe(2);
252
+ });
253
+
254
+ it("RadioGroup marks a generated shared name when name is null", () => {
255
+ const html = render({
256
+ $type: "RadioGroup",
257
+ name: null,
258
+ options: [{ label: "Small", value: "sm" }],
259
+ });
260
+
261
+ expect(html).toContain(`${GENERATED_NAME_ATTR}=""`);
208
262
  });
209
263
 
210
264
  it("RadioGroup marks the option matching defaultValue as checked", () => {
@@ -1,20 +1,37 @@
1
1
  import { useId } from "react";
2
2
  import { z } from "zod";
3
+ import { GENERATED_NAME_ATTR } from "../constants";
3
4
  import type { Action } from "../ir";
4
5
  import { BUTTON_STYLES } from "../ir";
5
- import type { GenerativeUIDispatch, GenerativeUILibrary } from "../types";
6
+ import type {
7
+ GenerativeUIDispatch,
8
+ GenerativeUILibrary,
9
+ GenerativeUIStatus,
10
+ } from "../types";
6
11
  import { actionAttr, fire } from "./dispatch";
12
+ import { toTextContent } from "./toTextContent";
7
13
 
8
14
  const optionSchema = z.object({
9
15
  label: z.string(),
10
16
  value: z.string(),
11
17
  });
12
18
 
19
+ type Option = { label: string; value: string };
20
+
21
+ const isOption = (option: unknown): option is Option =>
22
+ option !== null &&
23
+ typeof option === "object" &&
24
+ "label" in option &&
25
+ typeof option.label === "string" &&
26
+ "value" in option &&
27
+ typeof option.value === "string";
28
+
13
29
  type RadioGroupRenderProps = {
14
- options: { label: string; value: string }[];
30
+ options: Option[];
15
31
  name?: string;
16
32
  label?: string;
17
33
  defaultValue?: string;
34
+ $status: GenerativeUIStatus;
18
35
  $action?: Action;
19
36
  $dispatch?: GenerativeUIDispatch;
20
37
  };
@@ -37,13 +54,12 @@ function RadioGroupRender({
37
54
  aria-label={label}
38
55
  >
39
56
  {safeOptions.map((option, i) =>
40
- option &&
41
- typeof option.label === "string" &&
42
- typeof option.value === "string" ? (
57
+ isOption(option) ? (
43
58
  <label key={i} data-aui="radiogroup-option">
44
59
  <input
45
60
  type="radio"
46
61
  name={fieldName}
62
+ {...(name == null ? { [GENERATED_NAME_ATTR]: "" } : {})}
47
63
  value={option.value}
48
64
  defaultChecked={defaultValue === option.value}
49
65
  onChange={() => fire($action, $dispatch, option.value)}
@@ -92,7 +108,7 @@ export const interactiveVocabulary = {
92
108
  data-aui-action={actionAttr($action)}
93
109
  onClick={submit ? undefined : () => fire($action, $dispatch)}
94
110
  >
95
- {label}
111
+ {toTextContent(label)}
96
112
  {children}
97
113
  </button>
98
114
  ),
@@ -120,28 +136,33 @@ export const interactiveVocabulary = {
120
136
  $action,
121
137
  $dispatch,
122
138
  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
- ),
139
+ }) => {
140
+ const placeholderText = toTextContent(placeholder);
141
+ return (
142
+ <select
143
+ data-aui="select"
144
+ data-aui-action={actionAttr($action)}
145
+ name={name}
146
+ aria-label={label}
147
+ defaultValue=""
148
+ onChange={(e) => fire($action, $dispatch, e.currentTarget.value)}
149
+ >
150
+ {placeholderText ? (
151
+ <option value="" disabled>
152
+ {placeholderText}
153
+ </option>
154
+ ) : null}
155
+ {(Array.isArray(options) ? options : []).map((option, i) =>
156
+ isOption(option) ? (
157
+ <option key={i} value={option.value}>
158
+ {option.label}
159
+ </option>
160
+ ) : null,
161
+ )}
162
+ {children}
163
+ </select>
164
+ );
165
+ },
145
166
  },
146
167
  Input: {
147
168
  description:
@@ -251,7 +272,7 @@ export const interactiveVocabulary = {
251
272
  defaultChecked={defaultChecked}
252
273
  onChange={(e) => fire($action, $dispatch, e.currentTarget.checked)}
253
274
  />
254
- <span data-aui="checkbox-label">{label}</span>
275
+ <span data-aui="checkbox-label">{toTextContent(label)}</span>
255
276
  </label>
256
277
  ),
257
278
  },
@@ -16,6 +16,7 @@ const el = (
16
16
  type: "text",
17
17
  value: "",
18
18
  disabled: false,
19
+ hasAttribute: () => false,
19
20
  ...partial,
20
21
  });
21
22
 
@@ -114,6 +115,22 @@ describe("layoutVocabulary", () => {
114
115
  '<span data-aui="badge" data-aui-variant="success">new</span>',
115
116
  );
116
117
  });
118
+
119
+ it("ignores malformed card and badge text properties", () => {
120
+ expect(
121
+ render({
122
+ $type: "Card",
123
+ title: { unexpected: true },
124
+ confirm: { label: { unexpected: true } },
125
+ cancel: { label: { unexpected: true } },
126
+ }),
127
+ ).toBe(
128
+ '<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>',
129
+ );
130
+ expect(render({ $type: "Badge", value: { unexpected: true } })).toBe(
131
+ '<span data-aui="badge"></span>',
132
+ );
133
+ });
117
134
  });
118
135
 
119
136
  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
  ),
@@ -3,6 +3,7 @@ import type { ReactElement } from "react";
3
3
  import { renderToStaticMarkup } from "react-dom/server";
4
4
  import { renderGenerativeUI } from "../renderGenerativeUI";
5
5
  import { createActionRegistry } from "../actionRegistry";
6
+ import type { GenerativeUIDispatch } from "../types";
6
7
  import { listVocabulary } from "./list";
7
8
 
8
9
  const render = (node: unknown) =>
@@ -55,13 +56,13 @@ describe("listVocabulary", () => {
55
56
  });
56
57
 
57
58
  describe("listVocabulary $action dispatch", () => {
58
- const rowOut = (dispatch: (a: unknown) => unknown) =>
59
+ const rowOut = (dispatch: GenerativeUIDispatch) =>
59
60
  listVocabulary.ListViewItem.render({
60
61
  $status: "done",
61
62
  $action: { type: "open" },
62
63
  $dispatch: dispatch,
63
64
  children: "row",
64
- }) as ReactElement;
65
+ }) as ReactElement<{ children: ReactElement }>;
65
66
 
66
67
  it("clicking the trigger fires $action with no $input", () => {
67
68
  const handler = vi.fn();
@@ -83,8 +84,8 @@ describe("listVocabulary $action dispatch", () => {
83
84
  preventDefault: () => void;
84
85
  };
85
86
 
86
- const getOnKeyDown = (dispatch: (a: unknown) => unknown) => {
87
- const trigger = rowOut(dispatch).props.children as ReactElement;
87
+ const getOnKeyDown = (dispatch: GenerativeUIDispatch) => {
88
+ const trigger = rowOut(dispatch).props.children;
88
89
  return (trigger.props as { onKeyDown: (e: KeyDownEvent) => void })
89
90
  .onKeyDown;
90
91
  };
@@ -70,4 +70,33 @@ describe("textVocabulary", () => {
70
70
  '<p data-aui="caption">muted note</p>',
71
71
  );
72
72
  });
73
+
74
+ it.each([
75
+ [
76
+ { $type: "Header", text: { unexpected: true } },
77
+ undefined,
78
+ '<h2 data-aui="header" data-aui-size="lg"></h2>',
79
+ ],
80
+ [
81
+ { $type: "Text", value: { unexpected: true } },
82
+ { status: "streaming" as const },
83
+ '<span data-aui="text" data-aui-size="md"></span>',
84
+ ],
85
+ [
86
+ { $type: "Caption", value: { unexpected: true } },
87
+ { status: "streaming" as const },
88
+ '<p data-aui="caption"></p>',
89
+ ],
90
+ [
91
+ { $type: "Text", value: 42 },
92
+ { status: "streaming" as const },
93
+ '<span data-aui="text" data-aui-size="md">42</span>',
94
+ ],
95
+ ])("renders only supported text properties", (node, options, expected) => {
96
+ expect(
97
+ renderToStaticMarkup(
98
+ <>{renderGenerativeUI(node, textVocabulary, options)}</>,
99
+ ),
100
+ ).toBe(expected);
101
+ });
73
102
  });
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import type { GenerativeUILibrary } from "../types";
3
3
  import { COLORS, TEXT_SIZES, WEIGHTS } from "../ir";
4
+ import { toTextContent } from "./toTextContent";
4
5
 
5
6
  export const textVocabulary = {
6
7
  Header: {
@@ -14,7 +15,7 @@ export const textVocabulary = {
14
15
  }),
15
16
  render: ({ text, size, children }) => (
16
17
  <h2 data-aui="header" data-aui-size={size ?? "lg"}>
17
- {text}
18
+ {toTextContent(text)}
18
19
  {children}
19
20
  </h2>
20
21
  ),
@@ -38,7 +39,7 @@ export const textVocabulary = {
38
39
  data-aui-weight={weight}
39
40
  data-aui-color={color}
40
41
  >
41
- {value}
42
+ {toTextContent(value)}
42
43
  {children}
43
44
  </span>
44
45
  ),
@@ -51,7 +52,7 @@ export const textVocabulary = {
51
52
  streamProperties: true,
52
53
  render: ({ value, children }) => (
53
54
  <p data-aui="caption">
54
- {value}
55
+ {toTextContent(value)}
55
56
  {children}
56
57
  </p>
57
58
  ),
@@ -0,0 +1,2 @@
1
+ export const toTextContent = (value: unknown): string | number | null =>
2
+ typeof value === "string" || typeof value === "number" ? value : null;