@assistant-ui/react-generative-ui 0.0.6 → 0.0.8

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 (201) hide show
  1. package/dist/JSONGenerativeUI.client.d.ts +4 -2
  2. package/dist/JSONGenerativeUI.client.d.ts.map +1 -1
  3. package/dist/JSONGenerativeUI.client.js +9 -2
  4. package/dist/JSONGenerativeUI.client.js.map +1 -1
  5. package/dist/JSONGenerativeUI.server.d.ts +0 -1
  6. package/dist/JSONGenerativeUI.server.d.ts.map +1 -1
  7. package/dist/JSONGenerativeUI.shared.d.ts +8 -1
  8. package/dist/JSONGenerativeUI.shared.d.ts.map +1 -1
  9. package/dist/JSONGenerativeUI.shared.js.map +1 -1
  10. package/dist/actionRegistry.d.ts +32 -0
  11. package/dist/actionRegistry.d.ts.map +1 -0
  12. package/dist/actionRegistry.js +41 -0
  13. package/dist/actionRegistry.js.map +1 -0
  14. package/dist/buildPresentParameters.d.ts +5 -5
  15. package/dist/buildPresentParameters.d.ts.map +1 -1
  16. package/dist/buildPresentParameters.js +20 -6
  17. package/dist/buildPresentParameters.js.map +1 -1
  18. package/dist/constants.d.ts.map +1 -1
  19. package/dist/defineGenerativeComponents.d.ts +0 -1
  20. package/dist/defineGenerativeComponents.d.ts.map +1 -1
  21. package/dist/generativeUIToJSX.d.ts +15 -10
  22. package/dist/generativeUIToJSX.d.ts.map +1 -1
  23. package/dist/generativeUIToJSX.js +47 -17
  24. package/dist/generativeUIToJSX.js.map +1 -1
  25. package/dist/index.d.ts +8 -4
  26. package/dist/index.js +5 -2
  27. package/dist/index.server.d.ts +13 -0
  28. package/dist/index.server.js +10 -0
  29. package/dist/index.shared.d.ts +11 -0
  30. package/dist/index.shared.js +9 -0
  31. package/dist/ir.d.ts +115 -0
  32. package/dist/ir.d.ts.map +1 -0
  33. package/dist/ir.js +175 -0
  34. package/dist/ir.js.map +1 -0
  35. package/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts +3 -3
  36. package/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts.map +1 -1
  37. package/dist/renderGenerativeUI.d.ts +5 -5
  38. package/dist/renderGenerativeUI.d.ts.map +1 -1
  39. package/dist/renderGenerativeUI.js +18 -45
  40. package/dist/renderGenerativeUI.js.map +1 -1
  41. package/dist/slack/constants.d.ts +111 -0
  42. package/dist/slack/constants.d.ts.map +1 -0
  43. package/dist/slack/constants.js +168 -0
  44. package/dist/slack/constants.js.map +1 -0
  45. package/dist/slack/decodeBlockAction.d.ts +11 -0
  46. package/dist/slack/decodeBlockAction.d.ts.map +1 -0
  47. package/dist/slack/decodeBlockAction.js +40 -0
  48. package/dist/slack/decodeBlockAction.js.map +1 -0
  49. package/dist/slack/fromSlackBlocks.d.ts +30 -0
  50. package/dist/slack/fromSlackBlocks.d.ts.map +1 -0
  51. package/dist/slack/fromSlackBlocks.js +447 -0
  52. package/dist/slack/fromSlackBlocks.js.map +1 -0
  53. package/dist/slack/toSlackBlocks.d.ts +7 -0
  54. package/dist/slack/toSlackBlocks.d.ts.map +1 -0
  55. package/dist/slack/toSlackBlocks.js +649 -0
  56. package/dist/slack/toSlackBlocks.js.map +1 -0
  57. package/dist/slack/types.d.ts +178 -0
  58. package/dist/slack/types.d.ts.map +1 -0
  59. package/dist/slack/types.js +0 -0
  60. package/dist/slack.d.ts +5 -0
  61. package/dist/slack.js +4 -0
  62. package/dist/teams/boundSpec.d.ts +20 -0
  63. package/dist/teams/boundSpec.d.ts.map +1 -0
  64. package/dist/teams/boundSpec.js +67 -0
  65. package/dist/teams/boundSpec.js.map +1 -0
  66. package/dist/teams/constants.d.ts +57 -0
  67. package/dist/teams/constants.d.ts.map +1 -0
  68. package/dist/teams/constants.js +92 -0
  69. package/dist/teams/constants.js.map +1 -0
  70. package/dist/teams/decodeSubmitData.d.ts +25 -0
  71. package/dist/teams/decodeSubmitData.d.ts.map +1 -0
  72. package/dist/teams/decodeSubmitData.js +44 -0
  73. package/dist/teams/decodeSubmitData.js.map +1 -0
  74. package/dist/teams/toAdaptiveCard.d.ts +39 -0
  75. package/dist/teams/toAdaptiveCard.d.ts.map +1 -0
  76. package/dist/teams/toAdaptiveCard.js +496 -0
  77. package/dist/teams/toAdaptiveCard.js.map +1 -0
  78. package/dist/teams/toTeamsAttachments.d.ts +13 -0
  79. package/dist/teams/toTeamsAttachments.d.ts.map +1 -0
  80. package/dist/teams/toTeamsAttachments.js +95 -0
  81. package/dist/teams/toTeamsAttachments.js.map +1 -0
  82. package/dist/teams/types.d.ts +196 -0
  83. package/dist/teams/types.d.ts.map +1 -0
  84. package/dist/teams/types.js +0 -0
  85. package/dist/teams.d.ts +5 -0
  86. package/dist/teams.js +4 -0
  87. package/dist/types.d.ts +41 -34
  88. package/dist/types.d.ts.map +1 -1
  89. package/dist/vocabulary/alert.d.ts +28 -0
  90. package/dist/vocabulary/alert.d.ts.map +1 -0
  91. package/dist/vocabulary/alert.js +58 -0
  92. package/dist/vocabulary/alert.js.map +1 -0
  93. package/dist/vocabulary/collectFormValues.d.ts +23 -0
  94. package/dist/vocabulary/collectFormValues.d.ts.map +1 -0
  95. package/dist/vocabulary/collectFormValues.js +32 -0
  96. package/dist/vocabulary/collectFormValues.js.map +1 -0
  97. package/dist/vocabulary/data.d.ts +52 -0
  98. package/dist/vocabulary/data.d.ts.map +1 -0
  99. package/dist/vocabulary/data.js +294 -0
  100. package/dist/vocabulary/data.js.map +1 -0
  101. package/dist/vocabulary/dispatch.d.ts +11 -0
  102. package/dist/vocabulary/dispatch.d.ts.map +1 -0
  103. package/dist/vocabulary/dispatch.js +21 -0
  104. package/dist/vocabulary/dispatch.js.map +1 -0
  105. package/dist/vocabulary/fact.d.ts +15 -0
  106. package/dist/vocabulary/fact.d.ts.map +1 -0
  107. package/dist/vocabulary/fact.js +24 -0
  108. package/dist/vocabulary/fact.js.map +1 -0
  109. package/dist/vocabulary/form.d.ts +14 -0
  110. package/dist/vocabulary/form.d.ts.map +1 -0
  111. package/dist/vocabulary/form.js +23 -0
  112. package/dist/vocabulary/form.js.map +1 -0
  113. package/dist/vocabulary/icon.d.ts +44 -0
  114. package/dist/vocabulary/icon.d.ts.map +1 -0
  115. package/dist/vocabulary/icon.js +272 -0
  116. package/dist/vocabulary/icon.js.map +1 -0
  117. package/dist/vocabulary/index.d.ts +15 -0
  118. package/dist/vocabulary/index.d.ts.map +1 -0
  119. package/dist/vocabulary/index.js +36 -0
  120. package/dist/vocabulary/index.js.map +1 -0
  121. package/dist/vocabulary/interactive.d.ts +93 -0
  122. package/dist/vocabulary/interactive.d.ts.map +1 -0
  123. package/dist/vocabulary/interactive.js +172 -0
  124. package/dist/vocabulary/interactive.js.map +1 -0
  125. package/dist/vocabulary/layout.d.ts +82 -0
  126. package/dist/vocabulary/layout.d.ts.map +1 -0
  127. package/dist/vocabulary/layout.js +136 -0
  128. package/dist/vocabulary/layout.js.map +1 -0
  129. package/dist/vocabulary/list.d.ts +17 -0
  130. package/dist/vocabulary/list.d.ts.map +1 -0
  131. package/dist/vocabulary/list.js +48 -0
  132. package/dist/vocabulary/list.js.map +1 -0
  133. package/dist/vocabulary/media.d.ts +28 -0
  134. package/dist/vocabulary/media.d.ts.map +1 -0
  135. package/dist/vocabulary/media.js +42 -0
  136. package/dist/vocabulary/media.js.map +1 -0
  137. package/dist/vocabulary/text.d.ts +60 -0
  138. package/dist/vocabulary/text.d.ts.map +1 -0
  139. package/dist/vocabulary/text.js +48 -0
  140. package/dist/vocabulary/text.js.map +1 -0
  141. package/package.json +21 -13
  142. package/src/JSONGenerativeUI.client.tsx +10 -2
  143. package/src/JSONGenerativeUI.shared.ts +8 -0
  144. package/src/actionRegistry.test.ts +90 -0
  145. package/src/actionRegistry.ts +88 -0
  146. package/src/buildPresentParameters.ts +38 -16
  147. package/src/generativeUIToJSX.test.ts +186 -0
  148. package/src/generativeUIToJSX.ts +112 -21
  149. package/src/index.server.ts +2 -0
  150. package/src/index.shared.ts +61 -0
  151. package/src/index.ts +2 -26
  152. package/src/ir.test.ts +203 -0
  153. package/src/ir.ts +279 -0
  154. package/src/renderGenerativeUI.test.tsx +69 -2
  155. package/src/renderGenerativeUI.tsx +49 -82
  156. package/src/slack/constants.ts +217 -0
  157. package/src/slack/decodeBlockAction.test.ts +135 -0
  158. package/src/slack/decodeBlockAction.ts +66 -0
  159. package/src/slack/fromSlackBlocks.test.ts +1145 -0
  160. package/src/slack/fromSlackBlocks.ts +709 -0
  161. package/src/slack/toSlackBlocks.test.ts +1936 -0
  162. package/src/slack/toSlackBlocks.ts +1319 -0
  163. package/src/slack/types.ts +230 -0
  164. package/src/slack.ts +37 -0
  165. package/src/teams/boundSpec.ts +102 -0
  166. package/src/teams/constants.ts +127 -0
  167. package/src/teams/decodeSubmitData.test.ts +148 -0
  168. package/src/teams/decodeSubmitData.ts +57 -0
  169. package/src/teams/toAdaptiveCard.test.ts +1410 -0
  170. package/src/teams/toAdaptiveCard.ts +762 -0
  171. package/src/teams/toTeamsAttachments.test.ts +29 -0
  172. package/src/teams/toTeamsAttachments.ts +137 -0
  173. package/src/teams/types.ts +237 -0
  174. package/src/teams.ts +34 -0
  175. package/src/types.ts +57 -42
  176. package/src/vocabulary/alert.test.tsx +136 -0
  177. package/src/vocabulary/alert.tsx +66 -0
  178. package/src/vocabulary/collectFormValues.test.ts +127 -0
  179. package/src/vocabulary/collectFormValues.ts +58 -0
  180. package/src/vocabulary/data.test.tsx +567 -0
  181. package/src/vocabulary/data.tsx +494 -0
  182. package/src/vocabulary/dispatch.ts +22 -0
  183. package/src/vocabulary/fact.test.tsx +15 -0
  184. package/src/vocabulary/fact.tsx +22 -0
  185. package/src/vocabulary/form.test.tsx +112 -0
  186. package/src/vocabulary/form.tsx +34 -0
  187. package/src/vocabulary/icon.test.tsx +64 -0
  188. package/src/vocabulary/icon.tsx +174 -0
  189. package/src/vocabulary/index.test.ts +63 -0
  190. package/src/vocabulary/index.ts +33 -0
  191. package/src/vocabulary/interactive.dispatch.test.tsx +352 -0
  192. package/src/vocabulary/interactive.test.tsx +248 -0
  193. package/src/vocabulary/interactive.tsx +269 -0
  194. package/src/vocabulary/layout.test.tsx +289 -0
  195. package/src/vocabulary/layout.tsx +229 -0
  196. package/src/vocabulary/list.test.tsx +195 -0
  197. package/src/vocabulary/list.tsx +48 -0
  198. package/src/vocabulary/media.test.tsx +85 -0
  199. package/src/vocabulary/media.tsx +56 -0
  200. package/src/vocabulary/text.test.tsx +73 -0
  201. package/src/vocabulary/text.tsx +59 -0
@@ -0,0 +1,64 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { renderGenerativeUI } from "../renderGenerativeUI";
4
+ import { buildPresentParameters } from "../buildPresentParameters";
5
+ import { iconVocabulary } from "./icon";
6
+ import { ICON_NAMES } from "../ir";
7
+
8
+ const render = (node: unknown) =>
9
+ renderToStaticMarkup(<>{renderGenerativeUI(node, iconVocabulary)}</>);
10
+
11
+ describe("iconVocabulary", () => {
12
+ it("a known name renders an svg with the data-aui hooks and default size", () => {
13
+ const html = render({ $type: "Icon", name: "check" });
14
+ expect(html).toContain('data-aui="icon"');
15
+ expect(html).toContain('data-aui-name="check"');
16
+ expect(html).toContain('viewBox="0 0 24 24"');
17
+ expect(html).toContain('width="16"');
18
+ expect(html).toContain('height="16"');
19
+ expect(html).toContain('aria-hidden="true"');
20
+ });
21
+
22
+ it("every built-in name renders without throwing and without a data-aui-size attribute when size is omitted", () => {
23
+ for (const name of ICON_NAMES) {
24
+ const html = render({ $type: "Icon", name });
25
+ expect(html, name).toContain("<svg");
26
+ expect(html, name).not.toContain("data-aui-size=");
27
+ }
28
+ });
29
+
30
+ it("maps size tokens to pixel dimensions", () => {
31
+ expect(render({ $type: "Icon", name: "star", size: "sm" })).toContain(
32
+ 'width="14"',
33
+ );
34
+ expect(render({ $type: "Icon", name: "star", size: "md" })).toContain(
35
+ 'width="16"',
36
+ );
37
+ expect(render({ $type: "Icon", name: "star", size: "lg" })).toContain(
38
+ 'width="20"',
39
+ );
40
+ });
41
+
42
+ it("emits the size token as a data-aui-size hook when given", () => {
43
+ expect(render({ $type: "Icon", name: "bell", size: "lg" })).toContain(
44
+ 'data-aui-size="lg"',
45
+ );
46
+ });
47
+
48
+ it("an unknown name renders nothing rather than throwing", () => {
49
+ expect(() =>
50
+ render({ $type: "Icon", name: "not-a-real-icon" }),
51
+ ).not.toThrow();
52
+ expect(render({ $type: "Icon", name: "not-a-real-icon" })).toBe("");
53
+ });
54
+
55
+ it("exposes the closed name enum in buildPresentParameters", () => {
56
+ const schema = buildPresentParameters(iconVocabulary) as Record<
57
+ string,
58
+ unknown
59
+ >;
60
+ const properties = schema["properties"] as Record<string, unknown>;
61
+ const nameProp = properties["name"] as { enum?: unknown[] };
62
+ expect(nameProp.enum).toEqual([...ICON_NAMES]);
63
+ });
64
+ });
@@ -0,0 +1,174 @@
1
+ import type { ReactNode } from "react";
2
+ import { z } from "zod";
3
+ import type { GenerativeUILibrary } from "../types";
4
+ import { ICON_NAMES } from "../ir";
5
+
6
+ const ICON_SIZE_TOKENS = ["sm", "md", "lg"] as const;
7
+ const ICON_SIZE_PX: Record<(typeof ICON_SIZE_TOKENS)[number], number> = {
8
+ sm: 14,
9
+ md: 16,
10
+ lg: 20,
11
+ };
12
+
13
+ const ICON_GLYPHS: Record<(typeof ICON_NAMES)[number], ReactNode> = {
14
+ sun: (
15
+ <>
16
+ <circle cx="12" cy="12" r="4.5" />
17
+ <line x1="12" y1="2" x2="12" y2="5" />
18
+ <line x1="12" y1="19" x2="12" y2="22" />
19
+ <line x1="2" y1="12" x2="5" y2="12" />
20
+ <line x1="19" y1="12" x2="22" y2="12" />
21
+ <line x1="5.5" y1="5.5" x2="7.5" y2="7.5" />
22
+ <line x1="16.5" y1="16.5" x2="18.5" y2="18.5" />
23
+ <line x1="18.5" y1="5.5" x2="16.5" y2="7.5" />
24
+ <line x1="7.5" y1="16.5" x2="5.5" y2="18.5" />
25
+ </>
26
+ ),
27
+ moon: <path d="M19 13.5A8.5 8.5 0 1 1 10.5 4.5 6.8 6.8 0 0 0 19 13.5Z" />,
28
+ cloud: (
29
+ <path d="M7 18a4 4 0 1 1 .5-8A5.5 5.5 0 0 1 17.5 11.5 3.5 3.5 0 0 1 17 18Z" />
30
+ ),
31
+ rain: (
32
+ <>
33
+ <path d="M6.5 13a3 3 0 1 1 .4-6A4.5 4.5 0 0 1 15.5 8.5 3 3 0 0 1 15 13Z" />
34
+ <line x1="8" y1="15" x2="7" y2="18" />
35
+ <line x1="12" y1="15" x2="11" y2="18" />
36
+ <line x1="16" y1="15" x2="15" y2="18" />
37
+ </>
38
+ ),
39
+ snow: (
40
+ <>
41
+ <path d="M6.5 13a3 3 0 1 1 .4-6A4.5 4.5 0 0 1 15.5 8.5 3 3 0 0 1 15 13Z" />
42
+ <circle cx="7.5" cy="17" r="0.75" fill="currentColor" stroke="none" />
43
+ <circle cx="12" cy="18" r="0.75" fill="currentColor" stroke="none" />
44
+ <circle cx="16.5" cy="17" r="0.75" fill="currentColor" stroke="none" />
45
+ </>
46
+ ),
47
+ wind: (
48
+ <>
49
+ <path d="M3 7.5h10.5a2.5 2.5 0 1 0-2-4" />
50
+ <path d="M3 12h14a2.5 2.5 0 1 1-2 4" />
51
+ <path d="M3 16.5h8" />
52
+ </>
53
+ ),
54
+ play: <path d="M7 4.5v15l13-7.5z" />,
55
+ pause: (
56
+ <>
57
+ <line x1="8" y1="4" x2="8" y2="20" />
58
+ <line x1="16" y1="4" x2="16" y2="20" />
59
+ </>
60
+ ),
61
+ check: <path d="M4 12.5l5 5.5 11-12" />,
62
+ x: (
63
+ <>
64
+ <line x1="5" y1="5" x2="19" y2="19" />
65
+ <line x1="19" y1="5" x2="5" y2="19" />
66
+ </>
67
+ ),
68
+ star: (
69
+ <path d="M12 2.5l2.6 5.9 6.4.6-4.8 4.3 1.4 6.3-5.6-3-5.6 3 1.4-6.3-4.8-4.3 6.4-.6z" />
70
+ ),
71
+ heart: (
72
+ <path d="M12 20s-7.5-4.7-9.8-9.6C.6 6.8 3 3.3 6.6 3.3c2 0 3.7 1.1 5.4 3.3 1.7-2.2 3.4-3.3 5.4-3.3 3.6 0 6 3.5 4.4 7.1C19.5 15.3 12 20 12 20z" />
73
+ ),
74
+ "arrow-right": <path d="M4 12h15M13 6l6 6-6 6" />,
75
+ "arrow-up-right": <path d="M7 17L17 7M9 7h8v8" />,
76
+ "chevron-right": <path d="M9 5l7 7-7 7" />,
77
+ calendar: (
78
+ <>
79
+ <rect x="3.5" y="5" width="17" height="15" rx="2" />
80
+ <line x1="3.5" y1="9.5" x2="20.5" y2="9.5" />
81
+ <line x1="8" y1="3" x2="8" y2="7" />
82
+ <line x1="16" y1="3" x2="16" y2="7" />
83
+ </>
84
+ ),
85
+ clock: (
86
+ <>
87
+ <circle cx="12" cy="12" r="8.5" />
88
+ <path d="M12 7.5V12l3.5 2" />
89
+ </>
90
+ ),
91
+ "map-pin": (
92
+ <>
93
+ <path d="M12 21s7-6.7 7-12.2A7 7 0 1 0 5 8.8C5 14.3 12 21 12 21z" />
94
+ <circle cx="12" cy="8.8" r="2.3" />
95
+ </>
96
+ ),
97
+ plane: (
98
+ <>
99
+ <path d="M3 12l18-8-6 18-3-7-7-3z" />
100
+ <path d="M12 15l3-3" />
101
+ </>
102
+ ),
103
+ truck: (
104
+ <>
105
+ <path d="M2 8h11v9H2z" />
106
+ <path d="M13 11h4l3 3v3h-7z" />
107
+ <circle cx="6" cy="19" r="1.8" />
108
+ <circle cx="17" cy="19" r="1.8" />
109
+ </>
110
+ ),
111
+ "credit-card": (
112
+ <>
113
+ <rect x="2.5" y="5.5" width="19" height="13" rx="2" />
114
+ <line x1="2.5" y1="10" x2="21.5" y2="10" />
115
+ <line x1="6" y1="15" x2="10" y2="15" />
116
+ </>
117
+ ),
118
+ user: (
119
+ <>
120
+ <circle cx="12" cy="8" r="3.6" />
121
+ <path d="M4.5 20.5a7.5 7.5 0 0 1 15 0" />
122
+ </>
123
+ ),
124
+ search: (
125
+ <>
126
+ <circle cx="10.5" cy="10.5" r="6.5" />
127
+ <line x1="20" y1="20" x2="15.3" y2="15.3" />
128
+ </>
129
+ ),
130
+ bell: (
131
+ <>
132
+ <path d="M12 3a5.5 5.5 0 0 0-5.5 5.5c0 4.5-2 6-2 6h15s-2-1.5-2-6A5.5 5.5 0 0 0 12 3z" />
133
+ <path d="M10 19a2 2 0 0 0 4 0" />
134
+ </>
135
+ ),
136
+ };
137
+
138
+ export const iconVocabulary = {
139
+ Icon: {
140
+ description:
141
+ "A small line icon from the built-in set. `size` controls the rendered pixel size.",
142
+ properties: z.object({
143
+ name: z.enum(ICON_NAMES).describe("Which built-in icon to render."),
144
+ size: z
145
+ .enum(ICON_SIZE_TOKENS)
146
+ .optional()
147
+ .describe("Icon size; defaults to `md` (16px)."),
148
+ }),
149
+ render: ({ name, size }) => {
150
+ const glyph = ICON_GLYPHS[name as keyof typeof ICON_GLYPHS];
151
+ if (!glyph) return null;
152
+ const px = ICON_SIZE_PX[(size as keyof typeof ICON_SIZE_PX) ?? "md"];
153
+
154
+ return (
155
+ <svg
156
+ data-aui="icon"
157
+ data-aui-name={name}
158
+ data-aui-size={size}
159
+ viewBox="0 0 24 24"
160
+ width={px}
161
+ height={px}
162
+ fill="none"
163
+ stroke="currentColor"
164
+ strokeWidth="2"
165
+ strokeLinecap="round"
166
+ strokeLinejoin="round"
167
+ aria-hidden="true"
168
+ >
169
+ {glyph}
170
+ </svg>
171
+ );
172
+ },
173
+ },
174
+ } satisfies GenerativeUILibrary;
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { buildPresentParameters } from "../buildPresentParameters";
3
+ import { defaultGenerativeUILibrary } from "./index";
4
+
5
+ const EXPECTED_KEYS = [
6
+ "Header",
7
+ "Text",
8
+ "Caption",
9
+ "Image",
10
+ "Divider",
11
+ "Fact",
12
+ "Button",
13
+ "Select",
14
+ "Input",
15
+ "DatePicker",
16
+ "Checkbox",
17
+ "RadioGroup",
18
+ "Form",
19
+ "Card",
20
+ "Col",
21
+ "Row",
22
+ "Spacer",
23
+ "Badge",
24
+ "Box",
25
+ "ListView",
26
+ "ListViewItem",
27
+ "Table",
28
+ "Markdown",
29
+ "Chart",
30
+ "Alert",
31
+ "Carousel",
32
+ "Icon",
33
+ ] as const;
34
+
35
+ describe("defaultGenerativeUILibrary", () => {
36
+ it("exposes exactly the closed vocabulary set, no stray or missing entries", () => {
37
+ expect(Object.keys(defaultGenerativeUILibrary).sort()).toEqual(
38
+ [...EXPECTED_KEYS].sort(),
39
+ );
40
+ });
41
+
42
+ it("every entry has a description, a zod properties schema, and a render fn", () => {
43
+ for (const [name, entry] of Object.entries(defaultGenerativeUILibrary)) {
44
+ expect(typeof entry.description, `${name}.description`).toBe("string");
45
+ expect(typeof entry.render, `${name}.render`).toBe("function");
46
+ expect(
47
+ typeof (entry.properties as { safeParse?: unknown }).safeParse,
48
+ `${name}.properties is a zod schema`,
49
+ ).toBe("function");
50
+ }
51
+ });
52
+
53
+ it("buildPresentParameters produces a $type enum of the vocabulary names", () => {
54
+ const schema = buildPresentParameters(defaultGenerativeUILibrary) as Record<
55
+ string,
56
+ unknown
57
+ >;
58
+ const typeProp = (schema.properties as Record<string, unknown>)[
59
+ "$type"
60
+ ] as { enum?: unknown[] };
61
+ expect(typeProp.enum).toEqual([...EXPECTED_KEYS]);
62
+ });
63
+ });
@@ -0,0 +1,33 @@
1
+ import type { GenerativeUILibrary } from "../types";
2
+ import { alertVocabulary } from "./alert";
3
+ import { dataVocabulary } from "./data";
4
+ import { factVocabulary } from "./fact";
5
+ import { formVocabulary } from "./form";
6
+ import { iconVocabulary } from "./icon";
7
+ import { interactiveVocabulary } from "./interactive";
8
+ import { layoutVocabulary } from "./layout";
9
+ import { listVocabulary } from "./list";
10
+ import { mediaVocabulary } from "./media";
11
+ import { textVocabulary } from "./text";
12
+
13
+ /**
14
+ * The closed generative-ui vocabulary the factory ships: a fixed set of
15
+ * intrinsic components the model may render, each a zod `properties` schema
16
+ * plus an unstyled structural `render` (semantic HTML with a `data-aui`
17
+ * attribute naming the component, plus `data-aui-<prop>` hooks, for the host
18
+ * to style). Users opt in by passing it to
19
+ * `new JSONGenerativeUI({ library: defaultGenerativeUILibrary })`, and override
20
+ * or extend entries with their own `defineGenerativeComponents`.
21
+ */
22
+ export const defaultGenerativeUILibrary: GenerativeUILibrary = {
23
+ ...textVocabulary,
24
+ ...mediaVocabulary,
25
+ ...factVocabulary,
26
+ ...interactiveVocabulary,
27
+ ...formVocabulary,
28
+ ...layoutVocabulary,
29
+ ...listVocabulary,
30
+ ...dataVocabulary,
31
+ ...alertVocabulary,
32
+ ...iconVocabulary,
33
+ };
@@ -0,0 +1,352 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { isValidElement, type ReactElement, type ReactNode } from "react";
3
+ import { renderToStaticMarkup } from "react-dom/server";
4
+ import { interactiveVocabulary } from "./interactive";
5
+ import { createActionRegistry } from "../actionRegistry";
6
+
7
+ /** The props shape `Button.render` receives when the renderer injects `$action`
8
+ * and `$dispatch`. Used to call `render` directly so the test can invoke
9
+ * `onClick` without a DOM. */
10
+ type ButtonRenderProps = {
11
+ label: string;
12
+ $status: "done";
13
+ $action?: { type: string; [k: string]: unknown };
14
+ $dispatch?: (a: unknown) => unknown;
15
+ };
16
+
17
+ /**
18
+ * Calls a `render` that itself uses hooks (e.g. `RadioGroup`'s `useId`) from inside an active React render pass, then hands back the resulting element with its handlers intact so the test can invoke them directly.
19
+ */
20
+ function renderWithHooks(render: () => ReactNode): ReactElement {
21
+ let captured: ReactElement | undefined;
22
+ function Capture() {
23
+ captured = render() as ReactElement;
24
+ return captured;
25
+ }
26
+ renderToStaticMarkup(<Capture />);
27
+ return captured!;
28
+ }
29
+
30
+ describe("interactiveVocabulary $action dispatch", () => {
31
+ it("Button render attaches an onClick that fires $dispatch with the $action payload", () => {
32
+ const handler = vi.fn();
33
+ const registry = createActionRegistry({ purchase: handler });
34
+ const out = interactiveVocabulary.Button.render({
35
+ label: "Buy",
36
+ $status: "done",
37
+ $action: { type: "purchase", itemId: "sku-1" },
38
+ $dispatch: registry.dispatch,
39
+ } as ButtonRenderProps) as ReactNode;
40
+ expect(isValidElement(out)).toBe(true);
41
+ const onClick = (out as { props: { onClick: () => void } }).props.onClick;
42
+ expect(typeof onClick).toBe("function");
43
+ onClick();
44
+ expect(handler).toHaveBeenCalledWith({
45
+ payload: { type: "purchase", itemId: "sku-1" },
46
+ });
47
+ });
48
+
49
+ it("Button onClick is a no-op when no $dispatch is wired (read-only render)", () => {
50
+ const out = interactiveVocabulary.Button.render({
51
+ label: "Buy",
52
+ $status: "done",
53
+ $action: { type: "purchase", itemId: "sku-1" },
54
+ } as ButtonRenderProps) as ReactNode;
55
+ const onClick = (out as { props: { onClick: () => void } }).props.onClick;
56
+ expect(typeof onClick).toBe("function");
57
+ expect(() => onClick()).not.toThrow();
58
+ });
59
+
60
+ it("Button onClick is a no-op when $action is absent", () => {
61
+ const handler = vi.fn();
62
+ const registry = createActionRegistry({ purchase: handler });
63
+ const out = interactiveVocabulary.Button.render({
64
+ label: "Go",
65
+ $status: "done",
66
+ $dispatch: registry.dispatch,
67
+ } as ButtonRenderProps) as ReactNode;
68
+ (out as { props: { onClick: () => void } }).props.onClick();
69
+ expect(handler).not.toHaveBeenCalled();
70
+ });
71
+
72
+ it("Select onChange merges the selected value into $input and preserves a model-supplied $action.value", () => {
73
+ const handler = vi.fn();
74
+ const registry = createActionRegistry({ pick: handler });
75
+ const out = interactiveVocabulary.Select.render({
76
+ options: [{ label: "A", value: "a" }],
77
+ $status: "done",
78
+ $action: { type: "pick", value: "model-supplied" },
79
+ $dispatch: registry.dispatch,
80
+ }) as ReactNode;
81
+ const onChange = (
82
+ out as {
83
+ props: { onChange: (e: { currentTarget: { value: string } }) => void };
84
+ }
85
+ ).props.onChange;
86
+ onChange({ currentTarget: { value: "user-picked" } });
87
+ expect(handler).toHaveBeenCalledWith({
88
+ payload: {
89
+ type: "pick",
90
+ value: "model-supplied",
91
+ $input: "user-picked",
92
+ },
93
+ });
94
+ });
95
+
96
+ it("Input onKeyDown (Enter) merges the entered value into $input", () => {
97
+ const handler = vi.fn();
98
+ const registry = createActionRegistry({ submit: handler });
99
+ const out = interactiveVocabulary.Input.render({
100
+ $status: "done",
101
+ $action: { type: "submit" },
102
+ $dispatch: registry.dispatch,
103
+ }) as ReactNode;
104
+ const onKeyDown = (
105
+ out as {
106
+ props: {
107
+ onKeyDown: (e: {
108
+ key: string;
109
+ nativeEvent: { isComposing: boolean };
110
+ currentTarget: { value: string };
111
+ }) => void;
112
+ };
113
+ }
114
+ ).props.onKeyDown;
115
+ onKeyDown({
116
+ key: "Enter",
117
+ nativeEvent: { isComposing: false },
118
+ currentTarget: { value: "typed text" },
119
+ });
120
+ expect(handler).toHaveBeenCalledWith({
121
+ payload: { type: "submit", $input: "typed text" },
122
+ });
123
+ });
124
+
125
+ it("Input onKeyDown (Enter) defers to an ancestor form instead of firing its own $action", () => {
126
+ const handler = vi.fn();
127
+ const registry = createActionRegistry({ submit: handler });
128
+ const out = interactiveVocabulary.Input.render({
129
+ name: "email",
130
+ $status: "done",
131
+ $action: { type: "submit" },
132
+ $dispatch: registry.dispatch,
133
+ }) as ReactNode;
134
+ const onKeyDown = (
135
+ out as {
136
+ props: {
137
+ onKeyDown: (e: {
138
+ key: string;
139
+ nativeEvent: { isComposing: boolean };
140
+ currentTarget: { value: string; form: unknown };
141
+ }) => void;
142
+ };
143
+ }
144
+ ).props.onKeyDown;
145
+ onKeyDown({
146
+ key: "Enter",
147
+ nativeEvent: { isComposing: false },
148
+ currentTarget: { value: "typed text", form: {} },
149
+ });
150
+ expect(handler).not.toHaveBeenCalled();
151
+ });
152
+
153
+ type TextareaKeyDownEvent = {
154
+ key: string;
155
+ ctrlKey: boolean;
156
+ metaKey: boolean;
157
+ nativeEvent: { isComposing: boolean };
158
+ currentTarget: {
159
+ value: string;
160
+ form: { requestSubmit: () => void } | null;
161
+ };
162
+ preventDefault: () => void;
163
+ };
164
+
165
+ it("Input multiline (textarea) Ctrl+Enter inside a form calls HTMLFormElement.prototype.requestSubmit instead of firing its own $action", () => {
166
+ const handler = vi.fn();
167
+ const registry = createActionRegistry({ submit: handler });
168
+ const out = interactiveVocabulary.Input.render({
169
+ multiline: true,
170
+ name: "notes",
171
+ $status: "done",
172
+ $action: { type: "submit" },
173
+ $dispatch: registry.dispatch,
174
+ }) as ReactNode;
175
+ const onKeyDown = (
176
+ out as { props: { onKeyDown: (e: TextareaKeyDownEvent) => void } }
177
+ ).props.onKeyDown;
178
+
179
+ // This environment has no DOM, so `HTMLFormElement` is not a global; install a minimal stand-in so the fix's `HTMLFormElement.prototype.requestSubmit.call(...)` has a real, spyable target instead of throwing a ReferenceError.
180
+ class FakeHTMLFormElement {
181
+ requestSubmit(): void {}
182
+ }
183
+ const originalHTMLFormElement = globalThis.HTMLFormElement;
184
+ globalThis.HTMLFormElement =
185
+ FakeHTMLFormElement as unknown as typeof HTMLFormElement;
186
+ const requestSubmitSpy = vi.spyOn(
187
+ FakeHTMLFormElement.prototype,
188
+ "requestSubmit",
189
+ );
190
+ try {
191
+ const form = new FakeHTMLFormElement();
192
+ const preventDefault = vi.fn();
193
+ onKeyDown({
194
+ key: "Enter",
195
+ ctrlKey: true,
196
+ metaKey: false,
197
+ nativeEvent: { isComposing: false },
198
+ currentTarget: { value: "typed text", form },
199
+ preventDefault,
200
+ });
201
+ expect(preventDefault).toHaveBeenCalledTimes(1);
202
+ expect(requestSubmitSpy).toHaveBeenCalledTimes(1);
203
+ expect(requestSubmitSpy.mock.contexts[0]).toBe(form);
204
+ expect(handler).not.toHaveBeenCalled();
205
+ } finally {
206
+ requestSubmitSpy.mockRestore();
207
+ globalThis.HTMLFormElement = originalHTMLFormElement;
208
+ }
209
+ });
210
+
211
+ it("Input multiline (textarea) Ctrl+Enter without a form still fires its own $action", () => {
212
+ const handler = vi.fn();
213
+ const registry = createActionRegistry({ submit: handler });
214
+ const out = interactiveVocabulary.Input.render({
215
+ multiline: true,
216
+ $status: "done",
217
+ $action: { type: "submit" },
218
+ $dispatch: registry.dispatch,
219
+ }) as ReactNode;
220
+ const onKeyDown = (
221
+ out as { props: { onKeyDown: (e: TextareaKeyDownEvent) => void } }
222
+ ).props.onKeyDown;
223
+ const preventDefault = vi.fn();
224
+ onKeyDown({
225
+ key: "Enter",
226
+ ctrlKey: true,
227
+ metaKey: false,
228
+ nativeEvent: { isComposing: false },
229
+ currentTarget: { value: "typed text", form: null },
230
+ preventDefault,
231
+ });
232
+ expect(preventDefault).not.toHaveBeenCalled();
233
+ expect(handler).toHaveBeenCalledWith({
234
+ payload: { type: "submit", $input: "typed text" },
235
+ });
236
+ });
237
+
238
+ it("Button with submit set renders no onClick handler, so it never fires $action on click", () => {
239
+ const handler = vi.fn();
240
+ const registry = createActionRegistry({ purchase: handler });
241
+ const out = interactiveVocabulary.Button.render({
242
+ label: "Buy",
243
+ submit: true,
244
+ $status: "done",
245
+ $action: { type: "purchase" },
246
+ $dispatch: registry.dispatch,
247
+ } as ButtonRenderProps & { submit: boolean }) as ReactNode;
248
+ expect(
249
+ (out as { props: { onClick?: unknown } }).props.onClick,
250
+ ).toBeUndefined();
251
+ expect(handler).not.toHaveBeenCalled();
252
+ });
253
+
254
+ it("Button without submit keeps firing $action on click", () => {
255
+ const handler = vi.fn();
256
+ const registry = createActionRegistry({ purchase: handler });
257
+ const out = interactiveVocabulary.Button.render({
258
+ label: "Buy",
259
+ $status: "done",
260
+ $action: { type: "purchase" },
261
+ $dispatch: registry.dispatch,
262
+ } as ButtonRenderProps) as ReactNode;
263
+ const onClick = (out as { props: { onClick: () => void } }).props.onClick;
264
+ onClick();
265
+ expect(handler).toHaveBeenCalledWith({ payload: { type: "purchase" } });
266
+ });
267
+
268
+ it("Checkbox onChange fires $dispatch with the checked boolean as $input", () => {
269
+ const handler = vi.fn();
270
+ const registry = createActionRegistry({ toggle: handler });
271
+ const out = interactiveVocabulary.Checkbox.render({
272
+ label: "Accept",
273
+ $status: "done",
274
+ $action: { type: "toggle" },
275
+ $dispatch: registry.dispatch,
276
+ }) as ReactElement;
277
+ const input = (out.props as { children: ReactElement[] }).children[0];
278
+ const onChange = (
279
+ input as {
280
+ props: {
281
+ onChange: (e: { currentTarget: { checked: boolean } }) => void;
282
+ };
283
+ }
284
+ ).props.onChange;
285
+ onChange({ currentTarget: { checked: true } });
286
+ expect(handler).toHaveBeenCalledWith({
287
+ payload: { type: "toggle", $input: true },
288
+ });
289
+ });
290
+
291
+ it("Checkbox onChange is a no-op when no $dispatch is wired", () => {
292
+ const out = interactiveVocabulary.Checkbox.render({
293
+ label: "Accept",
294
+ $status: "done",
295
+ $action: { type: "toggle" },
296
+ }) as ReactElement;
297
+ const input = (out.props as { children: ReactElement[] }).children[0];
298
+ const onChange = (
299
+ input as {
300
+ props: {
301
+ onChange: (e: { currentTarget: { checked: boolean } }) => void;
302
+ };
303
+ }
304
+ ).props.onChange;
305
+ expect(() => onChange({ currentTarget: { checked: true } })).not.toThrow();
306
+ });
307
+
308
+ it("RadioGroup onChange fires $dispatch with the option's value as $input", () => {
309
+ const handler = vi.fn();
310
+ const registry = createActionRegistry({ pick: handler });
311
+ const out = renderWithHooks(() =>
312
+ interactiveVocabulary.RadioGroup.render({
313
+ $status: "done",
314
+ $action: { type: "pick" },
315
+ $dispatch: registry.dispatch,
316
+ options: [
317
+ { label: "Small", value: "sm" },
318
+ { label: "Large", value: "lg" },
319
+ ],
320
+ }),
321
+ );
322
+ const options = (out.props as { children: ReactElement[] }).children;
323
+ const secondRadio = (options[1]!.props as { children: ReactElement[] })
324
+ .children[0] as ReactElement;
325
+ const onChange = (secondRadio.props as { onChange: () => void }).onChange;
326
+ onChange();
327
+ expect(handler).toHaveBeenCalledWith({
328
+ payload: { type: "pick", $input: "lg" },
329
+ });
330
+ });
331
+
332
+ it("RadioGroup falls back to useId() for the shared radio name when name is omitted", () => {
333
+ const out = renderWithHooks(() =>
334
+ interactiveVocabulary.RadioGroup.render({
335
+ $status: "done",
336
+ options: [
337
+ { label: "Small", value: "sm" },
338
+ { label: "Large", value: "lg" },
339
+ ],
340
+ }),
341
+ );
342
+ const options = (out.props as { children: ReactElement[] }).children;
343
+ const firstRadio = (options[0]!.props as { children: ReactElement[] })
344
+ .children[0] as ReactElement;
345
+ const secondRadio = (options[1]!.props as { children: ReactElement[] })
346
+ .children[0] as ReactElement;
347
+ const firstName = (firstRadio.props as { name: string }).name;
348
+ const secondName = (secondRadio.props as { name: string }).name;
349
+ expect(firstName).toBeTruthy();
350
+ expect(firstName).toBe(secondName);
351
+ });
352
+ });