@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
@@ -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;
@@ -13,16 +13,23 @@ const EXPECTED_KEYS = [
13
13
  "Select",
14
14
  "Input",
15
15
  "DatePicker",
16
+ "Checkbox",
17
+ "RadioGroup",
18
+ "Form",
16
19
  "Card",
17
20
  "Col",
18
21
  "Row",
19
22
  "Spacer",
20
23
  "Badge",
24
+ "Box",
25
+ "ListView",
26
+ "ListViewItem",
21
27
  "Table",
22
28
  "Markdown",
23
29
  "Chart",
24
30
  "Alert",
25
31
  "Carousel",
32
+ "Icon",
26
33
  ] as const;
27
34
 
28
35
  describe("defaultGenerativeUILibrary", () => {
@@ -2,8 +2,11 @@ import type { GenerativeUILibrary } from "../types";
2
2
  import { alertVocabulary } from "./alert";
3
3
  import { dataVocabulary } from "./data";
4
4
  import { factVocabulary } from "./fact";
5
+ import { formVocabulary } from "./form";
6
+ import { iconVocabulary } from "./icon";
5
7
  import { interactiveVocabulary } from "./interactive";
6
8
  import { layoutVocabulary } from "./layout";
9
+ import { listVocabulary } from "./list";
7
10
  import { mediaVocabulary } from "./media";
8
11
  import { textVocabulary } from "./text";
9
12
 
@@ -21,7 +24,10 @@ export const defaultGenerativeUILibrary: GenerativeUILibrary = {
21
24
  ...mediaVocabulary,
22
25
  ...factVocabulary,
23
26
  ...interactiveVocabulary,
27
+ ...formVocabulary,
24
28
  ...layoutVocabulary,
29
+ ...listVocabulary,
25
30
  ...dataVocabulary,
26
31
  ...alertVocabulary,
32
+ ...iconVocabulary,
27
33
  };
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect, vi } from "vitest";
2
- import { isValidElement, type ReactNode } from "react";
2
+ import { isValidElement, type ReactElement, type ReactNode } from "react";
3
+ import { renderToStaticMarkup } from "react-dom/server";
3
4
  import { interactiveVocabulary } from "./interactive";
4
5
  import { createActionRegistry } from "../actionRegistry";
5
6
 
@@ -13,6 +14,19 @@ type ButtonRenderProps = {
13
14
  $dispatch?: (a: unknown) => unknown;
14
15
  };
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
+
16
30
  describe("interactiveVocabulary $action dispatch", () => {
17
31
  it("Button render attaches an onClick that fires $dispatch with the $action payload", () => {
18
32
  const handler = vi.fn();
@@ -107,4 +121,232 @@ describe("interactiveVocabulary $action dispatch", () => {
107
121
  payload: { type: "submit", $input: "typed text" },
108
122
  });
109
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
+ });
110
352
  });
@@ -16,16 +16,29 @@ describe("interactiveVocabulary", () => {
16
16
  block: true,
17
17
  }),
18
18
  ).toBe(
19
- '<button data-aui="button" data-aui-style="primary" data-aui-block="true">Go</button>',
19
+ '<button type="button" data-aui="button" data-aui-style="primary" data-aui-block="true">Go</button>',
20
20
  );
21
21
  });
22
22
 
23
23
  it("Button omits data-aui-block when block is false or omitted", () => {
24
24
  expect(render({ $type: "Button", label: "Go", block: false })).toBe(
25
- '<button data-aui="button">Go</button>',
25
+ '<button type="button" data-aui="button">Go</button>',
26
26
  );
27
27
  });
28
28
 
29
+ it('Button renders type="button" by default and type="submit" with the submit prop', () => {
30
+ expect(render({ $type: "Button", label: "Go" })).toContain('type="button"');
31
+ const html = render({ $type: "Button", label: "Go", submit: true });
32
+ expect(html).toContain('type="submit"');
33
+ expect(html).toContain("data-aui-submit");
34
+ });
35
+
36
+ it("Button omits data-aui-submit when submit is false or omitted", () => {
37
+ expect(
38
+ render({ $type: "Button", label: "Go", submit: false }),
39
+ ).not.toContain("data-aui-submit");
40
+ });
41
+
29
42
  it("Select renders an aria-label when label is provided", () => {
30
43
  const html = render({
31
44
  $type: "Select",
@@ -47,6 +60,15 @@ describe("interactiveVocabulary", () => {
47
60
  expect(html).toContain('<option value="x">B</option>');
48
61
  });
49
62
 
63
+ it("Select renders a name attribute when provided", () => {
64
+ const html = render({
65
+ $type: "Select",
66
+ name: "role",
67
+ options: [{ label: "A", value: "a" }],
68
+ });
69
+ expect(html).toContain('name="role"');
70
+ });
71
+
50
72
  it("Button stashes $action on a data-aui-action attribute", () => {
51
73
  const html = render({
52
74
  $type: "Button",
@@ -60,7 +82,7 @@ describe("interactiveVocabulary", () => {
60
82
 
61
83
  it("Button without $action omits the data-aui-action attribute", () => {
62
84
  expect(render({ $type: "Button", label: "Go" })).toBe(
63
- '<button data-aui="button">Go</button>',
85
+ '<button type="button" data-aui="button">Go</button>',
64
86
  );
65
87
  });
66
88
 
@@ -87,6 +109,10 @@ describe("interactiveVocabulary", () => {
87
109
  );
88
110
  });
89
111
 
112
+ it("Input renders a name attribute when provided", () => {
113
+ expect(render({ $type: "Input", name: "email" })).toContain('name="email"');
114
+ });
115
+
90
116
  it("Input multiline renders a textarea with the multiline hook", () => {
91
117
  const html = render({ $type: "Input", multiline: true });
92
118
  expect(html).toContain("data-aui-multiline");
@@ -105,4 +131,118 @@ describe("interactiveVocabulary", () => {
105
131
  expect(html).toContain('min="2025-01-01"');
106
132
  expect(html).toContain('max="2027-01-01"');
107
133
  });
134
+
135
+ it("DatePicker renders a name attribute when provided", () => {
136
+ expect(render({ $type: "DatePicker", name: "dob" })).toContain(
137
+ 'name="dob"',
138
+ );
139
+ });
140
+
141
+ it("Checkbox renders a label wrapping the input and a checkbox-label span", () => {
142
+ const html = render({ $type: "Checkbox", label: "Accept terms" });
143
+ expect(html).toContain('data-aui="checkbox"');
144
+ expect(html).toContain('type="checkbox"');
145
+ expect(html).toContain('data-aui="checkbox-label"');
146
+ expect(html).toContain("Accept terms");
147
+ });
148
+
149
+ it("Checkbox renders name and defaultChecked when provided", () => {
150
+ const html = render({
151
+ $type: "Checkbox",
152
+ label: "Accept",
153
+ name: "agree",
154
+ defaultChecked: true,
155
+ });
156
+ expect(html).toContain('name="agree"');
157
+ expect(html).toContain('checked=""');
158
+ });
159
+
160
+ it("RadioGroup renders a fieldset with one radio per option", () => {
161
+ const html = render({
162
+ $type: "RadioGroup",
163
+ options: [
164
+ { label: "Small", value: "sm" },
165
+ { label: "Large", value: "lg" },
166
+ ],
167
+ });
168
+ expect(html).toContain('data-aui="radiogroup"');
169
+ expect((html.match(/type="radio"/g) ?? []).length).toBe(2);
170
+ expect(html).toContain('data-aui="radiogroup-option"');
171
+ expect(html).toContain("Small");
172
+ expect(html).toContain("Large");
173
+ });
174
+
175
+ it("RadioGroup renders an aria-label from the label prop", () => {
176
+ const html = render({
177
+ $type: "RadioGroup",
178
+ label: "Size",
179
+ options: [{ label: "Small", value: "sm" }],
180
+ });
181
+ expect(html).toContain('aria-label="Size"');
182
+ });
183
+
184
+ it("RadioGroup radios share the given name", () => {
185
+ const html = render({
186
+ $type: "RadioGroup",
187
+ name: "size",
188
+ options: [
189
+ { label: "Small", value: "sm" },
190
+ { label: "Large", value: "lg" },
191
+ ],
192
+ });
193
+ expect((html.match(/name="size"/g) ?? []).length).toBe(2);
194
+ });
195
+
196
+ it("RadioGroup falls back to a generated shared name when name is omitted", () => {
197
+ const html = render({
198
+ $type: "RadioGroup",
199
+ options: [
200
+ { label: "Small", value: "sm" },
201
+ { label: "Large", value: "lg" },
202
+ ],
203
+ });
204
+ const names = [...html.matchAll(/name="([^"]*)"/g)].map((m) => m[1]);
205
+ expect(names.length).toBe(2);
206
+ expect(names[0]).toBe(names[1]);
207
+ expect(names[0]).toBeTruthy();
208
+ });
209
+
210
+ it("RadioGroup marks the option matching defaultValue as checked", () => {
211
+ const html = render({
212
+ $type: "RadioGroup",
213
+ defaultValue: "lg",
214
+ options: [
215
+ { label: "Small", value: "sm" },
216
+ { label: "Large", value: "lg" },
217
+ ],
218
+ });
219
+ expect(html.match(/checked=""/g)?.length).toBe(1);
220
+ });
221
+
222
+ it("RadioGroup renders nothing for malformed entries without throwing", () => {
223
+ expect(() =>
224
+ render({
225
+ $type: "RadioGroup",
226
+ options: [
227
+ { label: "Small", value: "sm" },
228
+ null,
229
+ { value: "no-label" },
230
+ { label: "no-value" },
231
+ "not-an-object",
232
+ ],
233
+ }),
234
+ ).not.toThrow();
235
+ const html = render({
236
+ $type: "RadioGroup",
237
+ options: [{ label: "Small", value: "sm" }, null],
238
+ });
239
+ expect((html.match(/type="radio"/g) ?? []).length).toBe(1);
240
+ });
241
+
242
+ it("RadioGroup with missing options renders an empty fieldset without throwing", () => {
243
+ expect(() => render({ $type: "RadioGroup" })).not.toThrow();
244
+ expect(render({ $type: "RadioGroup" })).toBe(
245
+ '<fieldset data-aui="radiogroup"></fieldset>',
246
+ );
247
+ });
108
248
  });