@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.
- package/dist/a2ui/convert.d.ts.map +1 -1
- package/dist/a2ui/convert.js +11 -2
- package/dist/a2ui/convert.js.map +1 -1
- package/dist/a2ui/reducer.d.ts.map +1 -1
- package/dist/a2ui/reducer.js +63 -10
- package/dist/a2ui/reducer.js.map +1 -1
- package/dist/buildPresentParameters.js +4 -3
- package/dist/buildPresentParameters.js.map +1 -1
- package/dist/convert/boundSpec.d.ts +5 -1
- package/dist/convert/boundSpec.d.ts.map +1 -1
- package/dist/convert/boundSpec.js +13 -4
- package/dist/convert/boundSpec.js.map +1 -1
- package/dist/renderGenerativeUI.js +1 -1
- package/dist/renderGenerativeUI.js.map +1 -1
- package/dist/teams/toTeamsAttachments.d.ts.map +1 -1
- package/dist/teams/toTeamsAttachments.js +2 -5
- package/dist/teams/toTeamsAttachments.js.map +1 -1
- package/dist/vocabulary/alert.d.ts +1 -1
- package/dist/vocabulary/alert.d.ts.map +1 -1
- package/dist/vocabulary/alert.js +21 -16
- package/dist/vocabulary/alert.js.map +1 -1
- package/dist/vocabulary/data.d.ts.map +1 -1
- package/dist/vocabulary/data.js +20 -12
- package/dist/vocabulary/data.js.map +1 -1
- package/dist/vocabulary/fact.d.ts.map +1 -1
- package/dist/vocabulary/fact.js +3 -2
- package/dist/vocabulary/fact.js.map +1 -1
- package/dist/vocabulary/interactive.d.ts +6 -5
- package/dist/vocabulary/interactive.d.ts.map +1 -1
- package/dist/vocabulary/interactive.js +28 -23
- package/dist/vocabulary/interactive.js.map +1 -1
- package/dist/vocabulary/layout.d.ts.map +1 -1
- package/dist/vocabulary/layout.js +7 -5
- package/dist/vocabulary/layout.js.map +1 -1
- package/dist/vocabulary/text.d.ts.map +1 -1
- package/dist/vocabulary/text.js +4 -3
- package/dist/vocabulary/text.js.map +1 -1
- package/dist/vocabulary/toTextContent.d.ts +5 -0
- package/dist/vocabulary/toTextContent.d.ts.map +1 -0
- package/dist/vocabulary/toTextContent.js +6 -0
- package/dist/vocabulary/toTextContent.js.map +1 -0
- package/package.json +5 -5
- package/src/a2ui/convert.test.ts +36 -0
- package/src/a2ui/convert.ts +21 -2
- package/src/a2ui/reducer.test.ts +223 -0
- package/src/a2ui/reducer.ts +76 -12
- package/src/buildPresentParameters.ts +7 -4
- package/src/convert/boundSpec.test.ts +47 -2
- package/src/convert/boundSpec.ts +12 -14
- package/src/renderGenerativeUI.test.tsx +57 -0
- package/src/renderGenerativeUI.tsx +3 -1
- package/src/teams/toTeamsAttachments.ts +2 -9
- package/src/vocabulary/alert.test.tsx +10 -0
- package/src/vocabulary/alert.tsx +16 -7
- package/src/vocabulary/data.test.tsx +40 -0
- package/src/vocabulary/data.tsx +47 -26
- package/src/vocabulary/fact.test.tsx +12 -0
- package/src/vocabulary/fact.tsx +3 -2
- package/src/vocabulary/interactive.test.tsx +41 -0
- package/src/vocabulary/interactive.tsx +42 -28
- package/src/vocabulary/layout.test.tsx +16 -0
- package/src/vocabulary/layout.tsx +8 -4
- package/src/vocabulary/text.test.tsx +29 -0
- package/src/vocabulary/text.tsx +4 -3
- package/src/vocabulary/toTextContent.ts +2 -0
package/src/convert/boundSpec.ts
CHANGED
|
@@ -81,26 +81,20 @@ function boundNode(
|
|
|
81
81
|
ancestors.delete(value);
|
|
82
82
|
return result;
|
|
83
83
|
}
|
|
84
|
-
if (
|
|
85
|
-
value
|
|
86
|
-
|
|
87
|
-
|
|
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}.
|
|
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
|
|
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 } =
|
|
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",
|
package/src/vocabulary/alert.tsx
CHANGED
|
@@ -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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
{
|
|
28
|
-
|
|
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",
|
package/src/vocabulary/data.tsx
CHANGED
|
@@ -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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
});
|
package/src/vocabulary/fact.tsx
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
{
|
|
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
|
),
|