@assistant-ui/react-generative-ui 0.0.7 → 0.0.9
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/JSONGenerativeUI.client.d.ts +0 -1
- package/dist/JSONGenerativeUI.client.d.ts.map +1 -1
- package/dist/JSONGenerativeUI.server.d.ts +0 -1
- package/dist/JSONGenerativeUI.server.d.ts.map +1 -1
- package/dist/JSONGenerativeUI.shared.d.ts +0 -1
- package/dist/JSONGenerativeUI.shared.d.ts.map +1 -1
- package/dist/actionRegistry.d.ts +2 -11
- package/dist/actionRegistry.d.ts.map +1 -1
- package/dist/actionRegistry.js.map +1 -1
- package/dist/buildPresentParameters.d.ts +5 -5
- package/dist/buildPresentParameters.d.ts.map +1 -1
- package/dist/buildPresentParameters.js +10 -5
- package/dist/buildPresentParameters.js.map +1 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/defineGenerativeComponents.d.ts +0 -1
- package/dist/defineGenerativeComponents.d.ts.map +1 -1
- package/dist/generativeUIToJSX.d.ts +15 -10
- package/dist/generativeUIToJSX.d.ts.map +1 -1
- package/dist/generativeUIToJSX.js +47 -17
- package/dist/generativeUIToJSX.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.js +2 -2
- package/dist/index.server.d.ts +4 -3
- package/dist/index.server.js +2 -2
- package/dist/index.shared.d.ts +3 -3
- package/dist/index.shared.js +2 -2
- package/dist/ir.d.ts +6 -6
- package/dist/ir.d.ts.map +1 -1
- package/dist/ir.js +27 -1
- package/dist/ir.js.map +1 -1
- package/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts +3 -3
- package/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts.map +1 -1
- package/dist/renderGenerativeUI.d.ts +0 -1
- package/dist/renderGenerativeUI.d.ts.map +1 -1
- package/dist/slack/constants.d.ts +111 -0
- package/dist/slack/constants.d.ts.map +1 -0
- package/dist/slack/constants.js +162 -0
- package/dist/slack/constants.js.map +1 -0
- package/dist/slack/decodeBlockAction.d.ts +11 -0
- package/dist/slack/decodeBlockAction.d.ts.map +1 -0
- package/dist/slack/decodeBlockAction.js +40 -0
- package/dist/slack/decodeBlockAction.js.map +1 -0
- package/dist/slack/fromSlackBlocks.d.ts +30 -0
- package/dist/slack/fromSlackBlocks.d.ts.map +1 -0
- package/dist/slack/fromSlackBlocks.js +447 -0
- package/dist/slack/fromSlackBlocks.js.map +1 -0
- package/dist/slack/toSlackBlocks.d.ts +7 -0
- package/dist/slack/toSlackBlocks.d.ts.map +1 -0
- package/dist/slack/toSlackBlocks.js +649 -0
- package/dist/slack/toSlackBlocks.js.map +1 -0
- package/dist/slack/types.d.ts +178 -0
- package/dist/slack/types.d.ts.map +1 -0
- package/dist/slack/types.js +0 -0
- package/dist/slack.d.ts +5 -0
- package/dist/slack.js +4 -0
- package/dist/teams/boundSpec.d.ts +20 -0
- package/dist/teams/boundSpec.d.ts.map +1 -0
- package/dist/teams/boundSpec.js +67 -0
- package/dist/teams/boundSpec.js.map +1 -0
- package/dist/teams/constants.d.ts +57 -0
- package/dist/teams/constants.d.ts.map +1 -0
- package/dist/teams/constants.js +92 -0
- package/dist/teams/constants.js.map +1 -0
- package/dist/teams/decodeSubmitData.d.ts +25 -0
- package/dist/teams/decodeSubmitData.d.ts.map +1 -0
- package/dist/teams/decodeSubmitData.js +44 -0
- package/dist/teams/decodeSubmitData.js.map +1 -0
- package/dist/teams/toAdaptiveCard.d.ts +39 -0
- package/dist/teams/toAdaptiveCard.d.ts.map +1 -0
- package/dist/teams/toAdaptiveCard.js +496 -0
- package/dist/teams/toAdaptiveCard.js.map +1 -0
- package/dist/teams/toTeamsAttachments.d.ts +13 -0
- package/dist/teams/toTeamsAttachments.d.ts.map +1 -0
- package/dist/teams/toTeamsAttachments.js +95 -0
- package/dist/teams/toTeamsAttachments.js.map +1 -0
- package/dist/teams/types.d.ts +196 -0
- package/dist/teams/types.d.ts.map +1 -0
- package/dist/teams/types.js +0 -0
- package/dist/teams.d.ts +5 -0
- package/dist/teams.js +4 -0
- package/dist/types.d.ts +7 -4
- package/dist/types.d.ts.map +1 -1
- package/dist/vocabulary/alert.d.ts +6 -12
- package/dist/vocabulary/alert.d.ts.map +1 -1
- package/dist/vocabulary/alert.js +13 -4
- package/dist/vocabulary/alert.js.map +1 -1
- package/dist/vocabulary/collectFormValues.d.ts +23 -0
- package/dist/vocabulary/collectFormValues.d.ts.map +1 -0
- package/dist/vocabulary/collectFormValues.js +32 -0
- package/dist/vocabulary/collectFormValues.js.map +1 -0
- package/dist/vocabulary/data.d.ts +16 -17
- package/dist/vocabulary/data.d.ts.map +1 -1
- package/dist/vocabulary/data.js +244 -13
- package/dist/vocabulary/data.js.map +1 -1
- package/dist/vocabulary/dispatch.d.ts +11 -0
- package/dist/vocabulary/dispatch.d.ts.map +1 -0
- package/dist/vocabulary/dispatch.js +21 -0
- package/dist/vocabulary/dispatch.js.map +1 -0
- package/dist/vocabulary/fact.d.ts +1 -6
- package/dist/vocabulary/fact.d.ts.map +1 -1
- package/dist/vocabulary/fact.js.map +1 -1
- package/dist/vocabulary/form.d.ts +14 -0
- package/dist/vocabulary/form.d.ts.map +1 -0
- package/dist/vocabulary/form.js +23 -0
- package/dist/vocabulary/form.js.map +1 -0
- package/dist/vocabulary/icon.d.ts +44 -0
- package/dist/vocabulary/icon.d.ts.map +1 -0
- package/dist/vocabulary/icon.js +272 -0
- package/dist/vocabulary/icon.js.map +1 -0
- package/dist/vocabulary/index.d.ts +0 -1
- package/dist/vocabulary/index.d.ts.map +1 -1
- package/dist/vocabulary/index.js +7 -1
- package/dist/vocabulary/index.js.map +1 -1
- package/dist/vocabulary/interactive.d.ts +45 -33
- package/dist/vocabulary/interactive.d.ts.map +1 -1
- package/dist/vocabulary/interactive.js +78 -29
- package/dist/vocabulary/interactive.js.map +1 -1
- package/dist/vocabulary/layout.d.ts +27 -23
- package/dist/vocabulary/layout.d.ts.map +1 -1
- package/dist/vocabulary/layout.js +78 -14
- package/dist/vocabulary/layout.js.map +1 -1
- package/dist/vocabulary/list.d.ts +17 -0
- package/dist/vocabulary/list.d.ts.map +1 -0
- package/dist/vocabulary/list.js +48 -0
- package/dist/vocabulary/list.js.map +1 -0
- package/dist/vocabulary/media.d.ts +3 -9
- package/dist/vocabulary/media.d.ts.map +1 -1
- package/dist/vocabulary/media.js +17 -7
- package/dist/vocabulary/media.js.map +1 -1
- package/dist/vocabulary/text.d.ts +3 -17
- package/dist/vocabulary/text.d.ts.map +1 -1
- package/dist/vocabulary/text.js.map +1 -1
- package/package.json +15 -7
- package/src/actionRegistry.ts +2 -10
- package/src/buildPresentParameters.ts +15 -9
- package/src/generativeUIToJSX.test.ts +186 -0
- package/src/generativeUIToJSX.ts +112 -21
- package/src/index.shared.ts +3 -0
- package/src/ir.ts +31 -5
- package/src/renderGenerativeUI.test.tsx +16 -2
- package/src/slack/constants.ts +212 -0
- package/src/slack/decodeBlockAction.test.ts +135 -0
- package/src/slack/decodeBlockAction.ts +66 -0
- package/src/slack/fromSlackBlocks.test.ts +1145 -0
- package/src/slack/fromSlackBlocks.ts +709 -0
- package/src/slack/toSlackBlocks.test.ts +1936 -0
- package/src/slack/toSlackBlocks.ts +1319 -0
- package/src/slack/types.ts +230 -0
- package/src/slack.ts +37 -0
- package/src/teams/boundSpec.ts +102 -0
- package/src/teams/constants.ts +127 -0
- package/src/teams/decodeSubmitData.test.ts +148 -0
- package/src/teams/decodeSubmitData.ts +57 -0
- package/src/teams/toAdaptiveCard.test.ts +1410 -0
- package/src/teams/toAdaptiveCard.ts +762 -0
- package/src/teams/toTeamsAttachments.test.ts +29 -0
- package/src/teams/toTeamsAttachments.ts +137 -0
- package/src/teams/types.ts +237 -0
- package/src/teams.ts +34 -0
- package/src/vocabulary/alert.test.tsx +72 -2
- package/src/vocabulary/alert.tsx +24 -5
- package/src/vocabulary/collectFormValues.test.ts +127 -0
- package/src/vocabulary/collectFormValues.ts +58 -0
- package/src/vocabulary/data.test.tsx +503 -3
- package/src/vocabulary/data.tsx +426 -18
- package/src/vocabulary/dispatch.ts +22 -0
- package/src/vocabulary/form.test.tsx +112 -0
- package/src/vocabulary/form.tsx +34 -0
- package/src/vocabulary/icon.test.tsx +64 -0
- package/src/vocabulary/icon.tsx +174 -0
- package/src/vocabulary/index.test.ts +7 -0
- package/src/vocabulary/index.ts +6 -0
- package/src/vocabulary/interactive.dispatch.test.tsx +243 -1
- package/src/vocabulary/interactive.test.tsx +143 -3
- package/src/vocabulary/interactive.tsx +134 -30
- package/src/vocabulary/layout.test.tsx +231 -3
- package/src/vocabulary/layout.tsx +168 -15
- package/src/vocabulary/list.test.tsx +195 -0
- package/src/vocabulary/list.tsx +48 -0
- package/src/vocabulary/media.test.tsx +36 -4
- package/src/vocabulary/media.tsx +27 -8
|
@@ -1,34 +1,138 @@
|
|
|
1
|
+
import type { CSSProperties, FormEvent } from "react";
|
|
1
2
|
import { z } from "zod";
|
|
2
3
|
import type { GenerativeUILibrary } from "../types";
|
|
3
4
|
import { ALIGNS, JUSTIFIES } from "../ir";
|
|
5
|
+
import { fire } from "./dispatch";
|
|
6
|
+
import { collectFormValuesFromEvent } from "./collectFormValues";
|
|
7
|
+
|
|
8
|
+
const toCssLength = (value: string | number): string =>
|
|
9
|
+
typeof value === "number" ? `${value}px` : value;
|
|
10
|
+
|
|
11
|
+
const cardActionSchema = z.looseObject({
|
|
12
|
+
type: z
|
|
13
|
+
.string()
|
|
14
|
+
.describe("The action type, resolved by the host's action registry."),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const cardFooterButtonSchema = z.object({
|
|
18
|
+
label: z.string().describe("Footer button label."),
|
|
19
|
+
$action: cardActionSchema
|
|
20
|
+
.optional()
|
|
21
|
+
.describe("Action fired when the button is activated."),
|
|
22
|
+
});
|
|
4
23
|
|
|
5
24
|
export const layoutVocabulary = {
|
|
6
25
|
Card: {
|
|
7
26
|
description:
|
|
8
|
-
"A bordered container grouping related content. Optionally titled.",
|
|
27
|
+
"A bordered container grouping related content. Optionally titled. Set `asForm` to collect named child control values on submit, and `confirm`/`cancel` to add a footer with action buttons.",
|
|
9
28
|
properties: z.object({
|
|
10
29
|
title: z.string().optional().describe("Optional card title."),
|
|
11
30
|
padding: z
|
|
12
31
|
.number()
|
|
32
|
+
.min(0)
|
|
33
|
+
.max(8)
|
|
34
|
+
.optional()
|
|
35
|
+
.describe(
|
|
36
|
+
"Padding in 4px units (e.g. 2 = 8px). 0 to 8 is the supported range.",
|
|
37
|
+
),
|
|
38
|
+
background: z
|
|
39
|
+
.string()
|
|
40
|
+
.optional()
|
|
41
|
+
.describe(
|
|
42
|
+
"Background color or gradient (any CSS background value). Setting this also switches the card's own text to white, since a custom background makes the card a tinted surface; children can still opt into a different color explicitly.",
|
|
43
|
+
),
|
|
44
|
+
asForm: z
|
|
45
|
+
.boolean()
|
|
46
|
+
.optional()
|
|
47
|
+
.describe(
|
|
48
|
+
"Render as a form; submitting it fires `confirm.$action` with every named child control's value, keyed by `name`.",
|
|
49
|
+
),
|
|
50
|
+
confirm: cardFooterButtonSchema
|
|
13
51
|
.optional()
|
|
14
|
-
.describe("
|
|
15
|
-
|
|
52
|
+
.describe("Confirm button shown in the footer."),
|
|
53
|
+
cancel: cardFooterButtonSchema
|
|
54
|
+
.optional()
|
|
55
|
+
.describe("Cancel button shown in the footer."),
|
|
16
56
|
}),
|
|
17
|
-
render: ({
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
)
|
|
57
|
+
render: ({
|
|
58
|
+
title,
|
|
59
|
+
padding,
|
|
60
|
+
background,
|
|
61
|
+
asForm,
|
|
62
|
+
confirm,
|
|
63
|
+
cancel,
|
|
64
|
+
$dispatch,
|
|
65
|
+
children,
|
|
66
|
+
}) => {
|
|
67
|
+
const Root = asForm ? "form" : "section";
|
|
68
|
+
const footer =
|
|
69
|
+
confirm || cancel ? (
|
|
70
|
+
<footer data-aui="card-footer">
|
|
71
|
+
{confirm ? (
|
|
72
|
+
<button
|
|
73
|
+
type={asForm ? "submit" : "button"}
|
|
74
|
+
data-aui="card-confirm"
|
|
75
|
+
onClick={
|
|
76
|
+
asForm ? undefined : () => fire(confirm.$action, $dispatch)
|
|
77
|
+
}
|
|
78
|
+
>
|
|
79
|
+
{confirm.label}
|
|
80
|
+
</button>
|
|
81
|
+
) : null}
|
|
82
|
+
{cancel ? (
|
|
83
|
+
<button
|
|
84
|
+
type="button"
|
|
85
|
+
data-aui="card-cancel"
|
|
86
|
+
onClick={() => fire(cancel.$action, $dispatch)}
|
|
87
|
+
>
|
|
88
|
+
{cancel.label}
|
|
89
|
+
</button>
|
|
90
|
+
) : null}
|
|
91
|
+
</footer>
|
|
92
|
+
) : null;
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<Root
|
|
96
|
+
data-aui="card"
|
|
97
|
+
data-aui-padding={padding}
|
|
98
|
+
data-aui-background={background}
|
|
99
|
+
style={
|
|
100
|
+
background !== undefined
|
|
101
|
+
? { background, color: "white" }
|
|
102
|
+
: undefined
|
|
103
|
+
}
|
|
104
|
+
data-aui-asform={asForm || undefined}
|
|
105
|
+
onSubmit={
|
|
106
|
+
asForm
|
|
107
|
+
? (event: FormEvent<HTMLFormElement>) => {
|
|
108
|
+
event.preventDefault();
|
|
109
|
+
fire(
|
|
110
|
+
confirm?.$action,
|
|
111
|
+
$dispatch,
|
|
112
|
+
collectFormValuesFromEvent(event),
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
: undefined
|
|
116
|
+
}
|
|
117
|
+
>
|
|
118
|
+
{title ? <header data-aui="card-title">{title}</header> : null}
|
|
119
|
+
{children}
|
|
120
|
+
{footer}
|
|
121
|
+
</Root>
|
|
122
|
+
);
|
|
123
|
+
},
|
|
27
124
|
},
|
|
28
125
|
Col: {
|
|
29
126
|
description: "A vertical stack; children laid out top to bottom.",
|
|
30
127
|
properties: z.object({
|
|
31
|
-
gap: z
|
|
128
|
+
gap: z
|
|
129
|
+
.number()
|
|
130
|
+
.min(0)
|
|
131
|
+
.max(8)
|
|
132
|
+
.optional()
|
|
133
|
+
.describe(
|
|
134
|
+
"Gap between children in 4px units. 0 to 8 is the supported range.",
|
|
135
|
+
),
|
|
32
136
|
align: z.enum(ALIGNS).optional().describe("Cross-axis alignment."),
|
|
33
137
|
}),
|
|
34
138
|
render: ({ gap, align, children }) => (
|
|
@@ -40,7 +144,14 @@ export const layoutVocabulary = {
|
|
|
40
144
|
Row: {
|
|
41
145
|
description: "A horizontal row; children laid out left to right.",
|
|
42
146
|
properties: z.object({
|
|
43
|
-
gap: z
|
|
147
|
+
gap: z
|
|
148
|
+
.number()
|
|
149
|
+
.min(0)
|
|
150
|
+
.max(8)
|
|
151
|
+
.optional()
|
|
152
|
+
.describe(
|
|
153
|
+
"Gap between children in 4px units. 0 to 8 is the supported range.",
|
|
154
|
+
),
|
|
44
155
|
align: z.enum(ALIGNS).optional().describe("Cross-axis alignment."),
|
|
45
156
|
justify: z.enum(JUSTIFIES).optional().describe("Main-axis distribution."),
|
|
46
157
|
}),
|
|
@@ -73,4 +184,46 @@ export const layoutVocabulary = {
|
|
|
73
184
|
</span>
|
|
74
185
|
),
|
|
75
186
|
},
|
|
187
|
+
Box: {
|
|
188
|
+
description:
|
|
189
|
+
"A generic container for composition, e.g. a progress bar built from an outer track Box containing a partial-width fill Box. Size, radius, and background accept arbitrary values, so they render as inline styles rather than data-attribute hooks.",
|
|
190
|
+
properties: z.object({
|
|
191
|
+
width: z
|
|
192
|
+
.union([z.string(), z.number()])
|
|
193
|
+
.optional()
|
|
194
|
+
.describe("Width; a number is pixels."),
|
|
195
|
+
height: z
|
|
196
|
+
.union([z.string(), z.number()])
|
|
197
|
+
.optional()
|
|
198
|
+
.describe("Height; a number is pixels."),
|
|
199
|
+
radius: z
|
|
200
|
+
.union([z.literal("full"), z.number()])
|
|
201
|
+
.optional()
|
|
202
|
+
.describe(
|
|
203
|
+
'Corner radius; "full" is a pill/circle, a number is pixels.',
|
|
204
|
+
),
|
|
205
|
+
background: z
|
|
206
|
+
.string()
|
|
207
|
+
.optional()
|
|
208
|
+
.describe("Background color or gradient (any CSS background value)."),
|
|
209
|
+
}),
|
|
210
|
+
render: ({ width, height, radius, background, children }) => {
|
|
211
|
+
const style: CSSProperties = {};
|
|
212
|
+
if (width !== undefined) style.width = toCssLength(width);
|
|
213
|
+
if (height !== undefined) style.height = toCssLength(height);
|
|
214
|
+
if (background !== undefined) style.background = background;
|
|
215
|
+
if (radius === "full") style.borderRadius = "9999px";
|
|
216
|
+
else if (typeof radius === "number") style.borderRadius = `${radius}px`;
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<div
|
|
220
|
+
data-aui="box"
|
|
221
|
+
data-aui-radius={radius === "full" ? "full" : undefined}
|
|
222
|
+
style={style}
|
|
223
|
+
>
|
|
224
|
+
{children}
|
|
225
|
+
</div>
|
|
226
|
+
);
|
|
227
|
+
},
|
|
228
|
+
},
|
|
76
229
|
} satisfies GenerativeUILibrary;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import type { ReactElement } from "react";
|
|
3
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
4
|
+
import { renderGenerativeUI } from "../renderGenerativeUI";
|
|
5
|
+
import { createActionRegistry } from "../actionRegistry";
|
|
6
|
+
import { listVocabulary } from "./list";
|
|
7
|
+
|
|
8
|
+
const render = (node: unknown) =>
|
|
9
|
+
renderToStaticMarkup(<>{renderGenerativeUI(node, listVocabulary)}</>);
|
|
10
|
+
|
|
11
|
+
describe("listVocabulary", () => {
|
|
12
|
+
it("ListView renders a ul wrapping its children", () => {
|
|
13
|
+
const html = render({
|
|
14
|
+
$type: "ListView",
|
|
15
|
+
children: [
|
|
16
|
+
{ $type: "ListViewItem", children: "a" },
|
|
17
|
+
{ $type: "ListViewItem", children: "b" },
|
|
18
|
+
],
|
|
19
|
+
});
|
|
20
|
+
expect(html).toContain('<ul data-aui="listview">');
|
|
21
|
+
expect((html.match(/data-aui="listview-item"/g) ?? []).length).toBe(2);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("ListViewItem without $action renders children directly with no trigger wrapper", () => {
|
|
25
|
+
expect(render({ $type: "ListViewItem", children: "row" })).toBe(
|
|
26
|
+
'<li data-aui="listview-item">row</li>',
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("ListViewItem with $action but no dispatch renders children directly (read-only)", () => {
|
|
31
|
+
expect(
|
|
32
|
+
render({
|
|
33
|
+
$type: "ListViewItem",
|
|
34
|
+
$action: { type: "open" },
|
|
35
|
+
children: "row",
|
|
36
|
+
}),
|
|
37
|
+
).toBe('<li data-aui="listview-item">row</li>');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("ListViewItem wraps children in a clickable trigger when $action and dispatch are both present", () => {
|
|
41
|
+
const registry = createActionRegistry({ open: vi.fn() });
|
|
42
|
+
const html = renderToStaticMarkup(
|
|
43
|
+
<>
|
|
44
|
+
{renderGenerativeUI(
|
|
45
|
+
{ $type: "ListViewItem", $action: { type: "open" }, children: "row" },
|
|
46
|
+
listVocabulary,
|
|
47
|
+
{ status: "done", dispatch: registry.dispatch },
|
|
48
|
+
)}
|
|
49
|
+
</>,
|
|
50
|
+
);
|
|
51
|
+
expect(html).toContain('data-aui="listview-item-trigger"');
|
|
52
|
+
expect(html).toContain('role="button"');
|
|
53
|
+
expect(html).toContain('tabindex="0"');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("listVocabulary $action dispatch", () => {
|
|
58
|
+
const rowOut = (dispatch: (a: unknown) => unknown) =>
|
|
59
|
+
listVocabulary.ListViewItem.render({
|
|
60
|
+
$status: "done",
|
|
61
|
+
$action: { type: "open" },
|
|
62
|
+
$dispatch: dispatch,
|
|
63
|
+
children: "row",
|
|
64
|
+
}) as ReactElement;
|
|
65
|
+
|
|
66
|
+
it("clicking the trigger fires $action with no $input", () => {
|
|
67
|
+
const handler = vi.fn();
|
|
68
|
+
const registry = createActionRegistry({ open: handler });
|
|
69
|
+
const trigger = rowOut(registry.dispatch).props.children as ReactElement;
|
|
70
|
+
const onClick = (trigger.props as { onClick: (e: unknown) => void })
|
|
71
|
+
.onClick;
|
|
72
|
+
onClick({ target: { closest: () => null } });
|
|
73
|
+
expect(handler).toHaveBeenCalledWith({ payload: { type: "open" } });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
type KeyDownEvent = {
|
|
77
|
+
key: string;
|
|
78
|
+
target: { closest: (selector: string) => unknown };
|
|
79
|
+
preventDefault: () => void;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const getOnKeyDown = (dispatch: (a: unknown) => unknown) => {
|
|
83
|
+
const trigger = rowOut(dispatch).props.children as ReactElement;
|
|
84
|
+
return (trigger.props as { onKeyDown: (e: KeyDownEvent) => void })
|
|
85
|
+
.onKeyDown;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
it("Enter and Space keydown both fire $action", () => {
|
|
89
|
+
const handler = vi.fn();
|
|
90
|
+
const registry = createActionRegistry({ open: handler });
|
|
91
|
+
const onKeyDown = getOnKeyDown(registry.dispatch);
|
|
92
|
+
onKeyDown({
|
|
93
|
+
key: "Enter",
|
|
94
|
+
target: { closest: () => null },
|
|
95
|
+
preventDefault: vi.fn(),
|
|
96
|
+
});
|
|
97
|
+
onKeyDown({
|
|
98
|
+
key: " ",
|
|
99
|
+
target: { closest: () => null },
|
|
100
|
+
preventDefault: vi.fn(),
|
|
101
|
+
});
|
|
102
|
+
expect(handler).toHaveBeenCalledTimes(2);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("a keydown for any other key does not fire $action", () => {
|
|
106
|
+
const handler = vi.fn();
|
|
107
|
+
const registry = createActionRegistry({ open: handler });
|
|
108
|
+
const onKeyDown = getOnKeyDown(registry.dispatch);
|
|
109
|
+
onKeyDown({
|
|
110
|
+
key: "Tab",
|
|
111
|
+
target: { closest: () => null },
|
|
112
|
+
preventDefault: vi.fn(),
|
|
113
|
+
});
|
|
114
|
+
expect(handler).not.toHaveBeenCalled();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("a click whose target sits inside a nested interactive element does not fire the row action", () => {
|
|
118
|
+
const handler = vi.fn();
|
|
119
|
+
const registry = createActionRegistry({ open: handler });
|
|
120
|
+
const trigger = rowOut(registry.dispatch).props.children as ReactElement;
|
|
121
|
+
const onClick = (trigger.props as { onClick: (e: unknown) => void })
|
|
122
|
+
.onClick;
|
|
123
|
+
const nestedButton = {
|
|
124
|
+
closest: (selector: string) =>
|
|
125
|
+
selector.includes("button") ? nestedButton : null,
|
|
126
|
+
};
|
|
127
|
+
onClick({ target: nestedButton });
|
|
128
|
+
expect(handler).not.toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("a keydown Enter whose target sits inside a nested interactive element does not fire the row action", () => {
|
|
132
|
+
const handler = vi.fn();
|
|
133
|
+
const registry = createActionRegistry({ open: handler });
|
|
134
|
+
const onKeyDown = getOnKeyDown(registry.dispatch);
|
|
135
|
+
const nestedInput = {
|
|
136
|
+
closest: (selector: string) =>
|
|
137
|
+
selector.includes("input") ? nestedInput : null,
|
|
138
|
+
};
|
|
139
|
+
const preventDefault = vi.fn();
|
|
140
|
+
onKeyDown({ key: "Enter", target: nestedInput, preventDefault });
|
|
141
|
+
expect(handler).not.toHaveBeenCalled();
|
|
142
|
+
expect(preventDefault).not.toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('a click whose target sits inside a nested role="button" element does not fire the row action', () => {
|
|
146
|
+
const handler = vi.fn();
|
|
147
|
+
const registry = createActionRegistry({ open: handler });
|
|
148
|
+
const trigger = rowOut(registry.dispatch).props.children as ReactElement;
|
|
149
|
+
const onClick = (trigger.props as { onClick: (e: unknown) => void })
|
|
150
|
+
.onClick;
|
|
151
|
+
const nestedRoleButton = {
|
|
152
|
+
closest: (selector: string) =>
|
|
153
|
+
selector.includes('[role="button"]') ? nestedRoleButton : null,
|
|
154
|
+
};
|
|
155
|
+
onClick({ target: nestedRoleButton });
|
|
156
|
+
expect(handler).not.toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('a keydown Enter whose target sits inside a nested role="button" element (e.g. a nested ListViewItem trigger) does not fire the row action', () => {
|
|
160
|
+
const handler = vi.fn();
|
|
161
|
+
const registry = createActionRegistry({ open: handler });
|
|
162
|
+
const onKeyDown = getOnKeyDown(registry.dispatch);
|
|
163
|
+
const nestedRoleButton = {
|
|
164
|
+
closest: (selector: string) =>
|
|
165
|
+
selector.includes('[role="button"]') ? nestedRoleButton : null,
|
|
166
|
+
};
|
|
167
|
+
const preventDefault = vi.fn();
|
|
168
|
+
onKeyDown({ key: "Enter", target: nestedRoleButton, preventDefault });
|
|
169
|
+
expect(handler).not.toHaveBeenCalled();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("Space on the trigger itself fires the row action and prevents default", () => {
|
|
173
|
+
const handler = vi.fn();
|
|
174
|
+
const registry = createActionRegistry({ open: handler });
|
|
175
|
+
const onKeyDown = getOnKeyDown(registry.dispatch);
|
|
176
|
+
const preventDefault = vi.fn();
|
|
177
|
+
onKeyDown({ key: " ", target: { closest: () => null }, preventDefault });
|
|
178
|
+
expect(handler).toHaveBeenCalledWith({ payload: { type: "open" } });
|
|
179
|
+
expect(preventDefault).toHaveBeenCalledTimes(1);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("Enter on the trigger itself fires without calling preventDefault", () => {
|
|
183
|
+
const handler = vi.fn();
|
|
184
|
+
const registry = createActionRegistry({ open: handler });
|
|
185
|
+
const onKeyDown = getOnKeyDown(registry.dispatch);
|
|
186
|
+
const preventDefault = vi.fn();
|
|
187
|
+
onKeyDown({
|
|
188
|
+
key: "Enter",
|
|
189
|
+
target: { closest: () => null },
|
|
190
|
+
preventDefault,
|
|
191
|
+
});
|
|
192
|
+
expect(handler).toHaveBeenCalledWith({ payload: { type: "open" } });
|
|
193
|
+
expect(preventDefault).not.toHaveBeenCalled();
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { GenerativeUILibrary } from "../types";
|
|
3
|
+
import { fire } from "./dispatch";
|
|
4
|
+
|
|
5
|
+
const INTERACTIVE_DESCENDANT_SELECTOR =
|
|
6
|
+
'button, a, input, select, textarea, label, [role="button"], [role="link"], [role="checkbox"], [role="radio"], [role="switch"], [role="textbox"], [role="combobox"], [role="menuitem"]';
|
|
7
|
+
|
|
8
|
+
export const listVocabulary = {
|
|
9
|
+
ListView: {
|
|
10
|
+
description:
|
|
11
|
+
"A vertical list container. Wrap `ListViewItem` children as rows.",
|
|
12
|
+
properties: z.object({}),
|
|
13
|
+
render: ({ children }) => <ul data-aui="listview">{children}</ul>,
|
|
14
|
+
},
|
|
15
|
+
ListViewItem: {
|
|
16
|
+
description:
|
|
17
|
+
"A row inside a ListView. Carries `$action` to make the whole row clickable and keyboard-activatable.",
|
|
18
|
+
properties: z.object({}),
|
|
19
|
+
render: ({ $action, $dispatch, children }) => {
|
|
20
|
+
if (!$action || !$dispatch) {
|
|
21
|
+
return <li data-aui="listview-item">{children}</li>;
|
|
22
|
+
}
|
|
23
|
+
return (
|
|
24
|
+
<li data-aui="listview-item">
|
|
25
|
+
<div
|
|
26
|
+
role="button"
|
|
27
|
+
tabIndex={0}
|
|
28
|
+
data-aui="listview-item-trigger"
|
|
29
|
+
onClick={(event) => {
|
|
30
|
+
const target = event.target as Element;
|
|
31
|
+
if (target.closest(INTERACTIVE_DESCENDANT_SELECTOR)) return;
|
|
32
|
+
fire($action, $dispatch);
|
|
33
|
+
}}
|
|
34
|
+
onKeyDown={(event) => {
|
|
35
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
36
|
+
const target = event.target as Element;
|
|
37
|
+
if (target.closest(INTERACTIVE_DESCENDANT_SELECTOR)) return;
|
|
38
|
+
if (event.key === " ") event.preventDefault();
|
|
39
|
+
fire($action, $dispatch);
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
42
|
+
{children}
|
|
43
|
+
</div>
|
|
44
|
+
</li>
|
|
45
|
+
);
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
} satisfies GenerativeUILibrary;
|
|
@@ -19,16 +19,27 @@ describe("mediaVocabulary", () => {
|
|
|
19
19
|
);
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
-
it("Image renders a numeric size as
|
|
22
|
+
it("Image renders a numeric size as an inline max-width style, not a dead data-aui-size attribute", () => {
|
|
23
23
|
const html = render({
|
|
24
24
|
$type: "Image",
|
|
25
25
|
src: "/a.png",
|
|
26
26
|
alt: "alt",
|
|
27
27
|
size: 128,
|
|
28
28
|
});
|
|
29
|
-
expect(html).toContain(
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
expect(html).not.toContain("data-aui-size");
|
|
30
|
+
expect(html).toContain('style="max-width:128px"');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("Image renders a numeric size on a round avatar as an inline width/height square", () => {
|
|
34
|
+
const html = render({
|
|
35
|
+
$type: "Image",
|
|
36
|
+
src: "/a.png",
|
|
37
|
+
alt: "avatar",
|
|
38
|
+
size: 64,
|
|
39
|
+
round: true,
|
|
40
|
+
});
|
|
41
|
+
expect(html).not.toContain("data-aui-size");
|
|
42
|
+
expect(html).toContain('style="width:64px;height:64px"');
|
|
32
43
|
});
|
|
33
44
|
|
|
34
45
|
it("Image `alt` is required by the schema (not optional)", () => {
|
|
@@ -39,6 +50,27 @@ describe("mediaVocabulary", () => {
|
|
|
39
50
|
expect(schema.safeParse({ src: "/a.png", alt: "x" }).success).toBe(true);
|
|
40
51
|
});
|
|
41
52
|
|
|
53
|
+
it("Image omits data-aui-round when round is not set", () => {
|
|
54
|
+
const html = render({ $type: "Image", src: "/a.png", alt: "alt" });
|
|
55
|
+
expect(html).not.toContain("data-aui-round");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("Image renders data-aui-round when round is true", () => {
|
|
59
|
+
const html = render({
|
|
60
|
+
$type: "Image",
|
|
61
|
+
src: "/a.png",
|
|
62
|
+
alt: "avatar",
|
|
63
|
+
round: true,
|
|
64
|
+
});
|
|
65
|
+
expect(html).toContain('data-aui-round="true"');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("Image with a missing alt still renders (defensive: schema validation is not runtime enforcement)", () => {
|
|
69
|
+
expect(() =>
|
|
70
|
+
render({ $type: "Image", src: "/a.png" } as never),
|
|
71
|
+
).not.toThrow();
|
|
72
|
+
});
|
|
73
|
+
|
|
42
74
|
it("Divider omits data-aui-flush when flush is false", () => {
|
|
43
75
|
expect(render({ $type: "Divider", flush: false })).toBe(
|
|
44
76
|
'<hr data-aui="divider"/>',
|
package/src/vocabulary/media.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
1
2
|
import { z } from "zod";
|
|
2
3
|
import type { GenerativeUILibrary } from "../types";
|
|
3
4
|
import { IMAGE_SIZE_TOKENS } from "../ir";
|
|
@@ -12,15 +13,33 @@ export const mediaVocabulary = {
|
|
|
12
13
|
.union([z.enum(IMAGE_SIZE_TOKENS), z.number()])
|
|
13
14
|
.optional()
|
|
14
15
|
.describe("Size token or pixel value."),
|
|
16
|
+
round: z
|
|
17
|
+
.boolean()
|
|
18
|
+
.optional()
|
|
19
|
+
.describe(
|
|
20
|
+
"Render as a circular avatar (fully rounded, cropped to a square).",
|
|
21
|
+
),
|
|
15
22
|
}),
|
|
16
|
-
render: ({ src, alt, size }) =>
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
render: ({ src, alt, size, round }) => {
|
|
24
|
+
const numericSize = typeof size === "number" ? size : undefined;
|
|
25
|
+
const style: CSSProperties | undefined =
|
|
26
|
+
numericSize === undefined
|
|
27
|
+
? undefined
|
|
28
|
+
: round
|
|
29
|
+
? { width: `${numericSize}px`, height: `${numericSize}px` }
|
|
30
|
+
: { maxWidth: `${numericSize}px` };
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<img
|
|
34
|
+
data-aui="image"
|
|
35
|
+
data-aui-size={numericSize === undefined ? size : undefined}
|
|
36
|
+
data-aui-round={round || undefined}
|
|
37
|
+
src={src}
|
|
38
|
+
alt={alt}
|
|
39
|
+
style={style}
|
|
40
|
+
/>
|
|
41
|
+
);
|
|
42
|
+
},
|
|
24
43
|
},
|
|
25
44
|
Divider: {
|
|
26
45
|
description: "A horizontal rule between sections.",
|