@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.
Files changed (181) hide show
  1. package/dist/JSONGenerativeUI.client.d.ts +0 -1
  2. package/dist/JSONGenerativeUI.client.d.ts.map +1 -1
  3. package/dist/JSONGenerativeUI.server.d.ts +0 -1
  4. package/dist/JSONGenerativeUI.server.d.ts.map +1 -1
  5. package/dist/JSONGenerativeUI.shared.d.ts +0 -1
  6. package/dist/JSONGenerativeUI.shared.d.ts.map +1 -1
  7. package/dist/actionRegistry.d.ts +2 -11
  8. package/dist/actionRegistry.d.ts.map +1 -1
  9. package/dist/actionRegistry.js.map +1 -1
  10. package/dist/buildPresentParameters.d.ts +5 -5
  11. package/dist/buildPresentParameters.d.ts.map +1 -1
  12. package/dist/buildPresentParameters.js +10 -5
  13. package/dist/buildPresentParameters.js.map +1 -1
  14. package/dist/constants.d.ts.map +1 -1
  15. package/dist/defineGenerativeComponents.d.ts +0 -1
  16. package/dist/defineGenerativeComponents.d.ts.map +1 -1
  17. package/dist/generativeUIToJSX.d.ts +15 -10
  18. package/dist/generativeUIToJSX.d.ts.map +1 -1
  19. package/dist/generativeUIToJSX.js +47 -17
  20. package/dist/generativeUIToJSX.js.map +1 -1
  21. package/dist/index.d.ts +4 -3
  22. package/dist/index.js +2 -2
  23. package/dist/index.server.d.ts +4 -3
  24. package/dist/index.server.js +2 -2
  25. package/dist/index.shared.d.ts +3 -3
  26. package/dist/index.shared.js +2 -2
  27. package/dist/ir.d.ts +6 -6
  28. package/dist/ir.d.ts.map +1 -1
  29. package/dist/ir.js +27 -1
  30. package/dist/ir.js.map +1 -1
  31. package/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts +3 -3
  32. package/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts.map +1 -1
  33. package/dist/renderGenerativeUI.d.ts +0 -1
  34. package/dist/renderGenerativeUI.d.ts.map +1 -1
  35. package/dist/slack/constants.d.ts +111 -0
  36. package/dist/slack/constants.d.ts.map +1 -0
  37. package/dist/slack/constants.js +162 -0
  38. package/dist/slack/constants.js.map +1 -0
  39. package/dist/slack/decodeBlockAction.d.ts +11 -0
  40. package/dist/slack/decodeBlockAction.d.ts.map +1 -0
  41. package/dist/slack/decodeBlockAction.js +40 -0
  42. package/dist/slack/decodeBlockAction.js.map +1 -0
  43. package/dist/slack/fromSlackBlocks.d.ts +30 -0
  44. package/dist/slack/fromSlackBlocks.d.ts.map +1 -0
  45. package/dist/slack/fromSlackBlocks.js +447 -0
  46. package/dist/slack/fromSlackBlocks.js.map +1 -0
  47. package/dist/slack/toSlackBlocks.d.ts +7 -0
  48. package/dist/slack/toSlackBlocks.d.ts.map +1 -0
  49. package/dist/slack/toSlackBlocks.js +649 -0
  50. package/dist/slack/toSlackBlocks.js.map +1 -0
  51. package/dist/slack/types.d.ts +178 -0
  52. package/dist/slack/types.d.ts.map +1 -0
  53. package/dist/slack/types.js +0 -0
  54. package/dist/slack.d.ts +5 -0
  55. package/dist/slack.js +4 -0
  56. package/dist/teams/boundSpec.d.ts +20 -0
  57. package/dist/teams/boundSpec.d.ts.map +1 -0
  58. package/dist/teams/boundSpec.js +67 -0
  59. package/dist/teams/boundSpec.js.map +1 -0
  60. package/dist/teams/constants.d.ts +57 -0
  61. package/dist/teams/constants.d.ts.map +1 -0
  62. package/dist/teams/constants.js +92 -0
  63. package/dist/teams/constants.js.map +1 -0
  64. package/dist/teams/decodeSubmitData.d.ts +25 -0
  65. package/dist/teams/decodeSubmitData.d.ts.map +1 -0
  66. package/dist/teams/decodeSubmitData.js +44 -0
  67. package/dist/teams/decodeSubmitData.js.map +1 -0
  68. package/dist/teams/toAdaptiveCard.d.ts +39 -0
  69. package/dist/teams/toAdaptiveCard.d.ts.map +1 -0
  70. package/dist/teams/toAdaptiveCard.js +496 -0
  71. package/dist/teams/toAdaptiveCard.js.map +1 -0
  72. package/dist/teams/toTeamsAttachments.d.ts +13 -0
  73. package/dist/teams/toTeamsAttachments.d.ts.map +1 -0
  74. package/dist/teams/toTeamsAttachments.js +95 -0
  75. package/dist/teams/toTeamsAttachments.js.map +1 -0
  76. package/dist/teams/types.d.ts +196 -0
  77. package/dist/teams/types.d.ts.map +1 -0
  78. package/dist/teams/types.js +0 -0
  79. package/dist/teams.d.ts +5 -0
  80. package/dist/teams.js +4 -0
  81. package/dist/types.d.ts +7 -4
  82. package/dist/types.d.ts.map +1 -1
  83. package/dist/vocabulary/alert.d.ts +6 -12
  84. package/dist/vocabulary/alert.d.ts.map +1 -1
  85. package/dist/vocabulary/alert.js +13 -4
  86. package/dist/vocabulary/alert.js.map +1 -1
  87. package/dist/vocabulary/collectFormValues.d.ts +23 -0
  88. package/dist/vocabulary/collectFormValues.d.ts.map +1 -0
  89. package/dist/vocabulary/collectFormValues.js +32 -0
  90. package/dist/vocabulary/collectFormValues.js.map +1 -0
  91. package/dist/vocabulary/data.d.ts +16 -17
  92. package/dist/vocabulary/data.d.ts.map +1 -1
  93. package/dist/vocabulary/data.js +244 -13
  94. package/dist/vocabulary/data.js.map +1 -1
  95. package/dist/vocabulary/dispatch.d.ts +11 -0
  96. package/dist/vocabulary/dispatch.d.ts.map +1 -0
  97. package/dist/vocabulary/dispatch.js +21 -0
  98. package/dist/vocabulary/dispatch.js.map +1 -0
  99. package/dist/vocabulary/fact.d.ts +1 -6
  100. package/dist/vocabulary/fact.d.ts.map +1 -1
  101. package/dist/vocabulary/fact.js.map +1 -1
  102. package/dist/vocabulary/form.d.ts +14 -0
  103. package/dist/vocabulary/form.d.ts.map +1 -0
  104. package/dist/vocabulary/form.js +23 -0
  105. package/dist/vocabulary/form.js.map +1 -0
  106. package/dist/vocabulary/icon.d.ts +44 -0
  107. package/dist/vocabulary/icon.d.ts.map +1 -0
  108. package/dist/vocabulary/icon.js +272 -0
  109. package/dist/vocabulary/icon.js.map +1 -0
  110. package/dist/vocabulary/index.d.ts +0 -1
  111. package/dist/vocabulary/index.d.ts.map +1 -1
  112. package/dist/vocabulary/index.js +7 -1
  113. package/dist/vocabulary/index.js.map +1 -1
  114. package/dist/vocabulary/interactive.d.ts +45 -33
  115. package/dist/vocabulary/interactive.d.ts.map +1 -1
  116. package/dist/vocabulary/interactive.js +78 -29
  117. package/dist/vocabulary/interactive.js.map +1 -1
  118. package/dist/vocabulary/layout.d.ts +27 -23
  119. package/dist/vocabulary/layout.d.ts.map +1 -1
  120. package/dist/vocabulary/layout.js +78 -14
  121. package/dist/vocabulary/layout.js.map +1 -1
  122. package/dist/vocabulary/list.d.ts +17 -0
  123. package/dist/vocabulary/list.d.ts.map +1 -0
  124. package/dist/vocabulary/list.js +48 -0
  125. package/dist/vocabulary/list.js.map +1 -0
  126. package/dist/vocabulary/media.d.ts +3 -9
  127. package/dist/vocabulary/media.d.ts.map +1 -1
  128. package/dist/vocabulary/media.js +17 -7
  129. package/dist/vocabulary/media.js.map +1 -1
  130. package/dist/vocabulary/text.d.ts +3 -17
  131. package/dist/vocabulary/text.d.ts.map +1 -1
  132. package/dist/vocabulary/text.js.map +1 -1
  133. package/package.json +15 -7
  134. package/src/actionRegistry.ts +2 -10
  135. package/src/buildPresentParameters.ts +15 -9
  136. package/src/generativeUIToJSX.test.ts +186 -0
  137. package/src/generativeUIToJSX.ts +112 -21
  138. package/src/index.shared.ts +3 -0
  139. package/src/ir.ts +31 -5
  140. package/src/renderGenerativeUI.test.tsx +16 -2
  141. package/src/slack/constants.ts +212 -0
  142. package/src/slack/decodeBlockAction.test.ts +135 -0
  143. package/src/slack/decodeBlockAction.ts +66 -0
  144. package/src/slack/fromSlackBlocks.test.ts +1145 -0
  145. package/src/slack/fromSlackBlocks.ts +709 -0
  146. package/src/slack/toSlackBlocks.test.ts +1936 -0
  147. package/src/slack/toSlackBlocks.ts +1319 -0
  148. package/src/slack/types.ts +230 -0
  149. package/src/slack.ts +37 -0
  150. package/src/teams/boundSpec.ts +102 -0
  151. package/src/teams/constants.ts +127 -0
  152. package/src/teams/decodeSubmitData.test.ts +148 -0
  153. package/src/teams/decodeSubmitData.ts +57 -0
  154. package/src/teams/toAdaptiveCard.test.ts +1410 -0
  155. package/src/teams/toAdaptiveCard.ts +762 -0
  156. package/src/teams/toTeamsAttachments.test.ts +29 -0
  157. package/src/teams/toTeamsAttachments.ts +137 -0
  158. package/src/teams/types.ts +237 -0
  159. package/src/teams.ts +34 -0
  160. package/src/vocabulary/alert.test.tsx +72 -2
  161. package/src/vocabulary/alert.tsx +24 -5
  162. package/src/vocabulary/collectFormValues.test.ts +127 -0
  163. package/src/vocabulary/collectFormValues.ts +58 -0
  164. package/src/vocabulary/data.test.tsx +503 -3
  165. package/src/vocabulary/data.tsx +426 -18
  166. package/src/vocabulary/dispatch.ts +22 -0
  167. package/src/vocabulary/form.test.tsx +112 -0
  168. package/src/vocabulary/form.tsx +34 -0
  169. package/src/vocabulary/icon.test.tsx +64 -0
  170. package/src/vocabulary/icon.tsx +174 -0
  171. package/src/vocabulary/index.test.ts +7 -0
  172. package/src/vocabulary/index.ts +6 -0
  173. package/src/vocabulary/interactive.dispatch.test.tsx +243 -1
  174. package/src/vocabulary/interactive.test.tsx +143 -3
  175. package/src/vocabulary/interactive.tsx +134 -30
  176. package/src/vocabulary/layout.test.tsx +231 -3
  177. package/src/vocabulary/layout.tsx +168 -15
  178. package/src/vocabulary/list.test.tsx +195 -0
  179. package/src/vocabulary/list.tsx +48 -0
  180. package/src/vocabulary/media.test.tsx +36 -4
  181. 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("Padding in 4px units (e.g. 2 = 8px)."),
15
- background: z.string().optional().describe("Background token or color."),
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: ({ title, padding, background, children }) => (
18
- <section
19
- data-aui="card"
20
- data-aui-padding={padding}
21
- data-aui-background={background}
22
- >
23
- {title ? <header data-aui="card-title">{title}</header> : null}
24
- {children}
25
- </section>
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.number().optional().describe("Gap between children in 4px units."),
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.number().optional().describe("Gap between children in 4px units."),
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 px", () => {
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
- '<img data-aui="image" data-aui-size="128px" src="/a.png" alt="alt"/>',
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"/>',
@@ -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
- <img
18
- data-aui="image"
19
- data-aui-size={typeof size === "number" ? `${size}px` : size}
20
- src={src}
21
- alt={alt}
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.",