@assistant-ui/react-generative-ui 0.0.7 → 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 (179) 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 +168 -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/form.d.ts +14 -0
  102. package/dist/vocabulary/form.d.ts.map +1 -0
  103. package/dist/vocabulary/form.js +23 -0
  104. package/dist/vocabulary/form.js.map +1 -0
  105. package/dist/vocabulary/icon.d.ts +44 -0
  106. package/dist/vocabulary/icon.d.ts.map +1 -0
  107. package/dist/vocabulary/icon.js +272 -0
  108. package/dist/vocabulary/icon.js.map +1 -0
  109. package/dist/vocabulary/index.d.ts +0 -1
  110. package/dist/vocabulary/index.d.ts.map +1 -1
  111. package/dist/vocabulary/index.js +7 -1
  112. package/dist/vocabulary/index.js.map +1 -1
  113. package/dist/vocabulary/interactive.d.ts +45 -33
  114. package/dist/vocabulary/interactive.d.ts.map +1 -1
  115. package/dist/vocabulary/interactive.js +78 -29
  116. package/dist/vocabulary/interactive.js.map +1 -1
  117. package/dist/vocabulary/layout.d.ts +27 -23
  118. package/dist/vocabulary/layout.d.ts.map +1 -1
  119. package/dist/vocabulary/layout.js +78 -14
  120. package/dist/vocabulary/layout.js.map +1 -1
  121. package/dist/vocabulary/list.d.ts +17 -0
  122. package/dist/vocabulary/list.d.ts.map +1 -0
  123. package/dist/vocabulary/list.js +48 -0
  124. package/dist/vocabulary/list.js.map +1 -0
  125. package/dist/vocabulary/media.d.ts +3 -9
  126. package/dist/vocabulary/media.d.ts.map +1 -1
  127. package/dist/vocabulary/media.js +17 -7
  128. package/dist/vocabulary/media.js.map +1 -1
  129. package/dist/vocabulary/text.d.ts +3 -17
  130. package/dist/vocabulary/text.d.ts.map +1 -1
  131. package/package.json +13 -5
  132. package/src/actionRegistry.ts +2 -10
  133. package/src/buildPresentParameters.ts +15 -9
  134. package/src/generativeUIToJSX.test.ts +186 -0
  135. package/src/generativeUIToJSX.ts +112 -21
  136. package/src/index.shared.ts +3 -0
  137. package/src/ir.ts +31 -5
  138. package/src/renderGenerativeUI.test.tsx +16 -2
  139. package/src/slack/constants.ts +217 -0
  140. package/src/slack/decodeBlockAction.test.ts +135 -0
  141. package/src/slack/decodeBlockAction.ts +66 -0
  142. package/src/slack/fromSlackBlocks.test.ts +1145 -0
  143. package/src/slack/fromSlackBlocks.ts +709 -0
  144. package/src/slack/toSlackBlocks.test.ts +1936 -0
  145. package/src/slack/toSlackBlocks.ts +1319 -0
  146. package/src/slack/types.ts +230 -0
  147. package/src/slack.ts +37 -0
  148. package/src/teams/boundSpec.ts +102 -0
  149. package/src/teams/constants.ts +127 -0
  150. package/src/teams/decodeSubmitData.test.ts +148 -0
  151. package/src/teams/decodeSubmitData.ts +57 -0
  152. package/src/teams/toAdaptiveCard.test.ts +1410 -0
  153. package/src/teams/toAdaptiveCard.ts +762 -0
  154. package/src/teams/toTeamsAttachments.test.ts +29 -0
  155. package/src/teams/toTeamsAttachments.ts +137 -0
  156. package/src/teams/types.ts +237 -0
  157. package/src/teams.ts +34 -0
  158. package/src/vocabulary/alert.test.tsx +72 -2
  159. package/src/vocabulary/alert.tsx +24 -5
  160. package/src/vocabulary/collectFormValues.test.ts +127 -0
  161. package/src/vocabulary/collectFormValues.ts +58 -0
  162. package/src/vocabulary/data.test.tsx +503 -3
  163. package/src/vocabulary/data.tsx +426 -18
  164. package/src/vocabulary/dispatch.ts +22 -0
  165. package/src/vocabulary/form.test.tsx +112 -0
  166. package/src/vocabulary/form.tsx +34 -0
  167. package/src/vocabulary/icon.test.tsx +64 -0
  168. package/src/vocabulary/icon.tsx +174 -0
  169. package/src/vocabulary/index.test.ts +7 -0
  170. package/src/vocabulary/index.ts +6 -0
  171. package/src/vocabulary/interactive.dispatch.test.tsx +243 -1
  172. package/src/vocabulary/interactive.test.tsx +143 -3
  173. package/src/vocabulary/interactive.tsx +134 -30
  174. package/src/vocabulary/layout.test.tsx +231 -3
  175. package/src/vocabulary/layout.tsx +168 -15
  176. package/src/vocabulary/list.test.tsx +195 -0
  177. package/src/vocabulary/list.tsx +48 -0
  178. package/src/vocabulary/media.test.tsx +36 -4
  179. package/src/vocabulary/media.tsx +27 -8
@@ -1,39 +1,65 @@
1
+ import { useId } from "react";
1
2
  import { z } from "zod";
2
3
  import type { Action } from "../ir";
3
4
  import { BUTTON_STYLES } from "../ir";
4
5
  import type { GenerativeUIDispatch, GenerativeUILibrary } from "../types";
6
+ import { actionAttr, fire } from "./dispatch";
5
7
 
6
8
  const optionSchema = z.object({
7
9
  label: z.string(),
8
10
  value: z.string(),
9
11
  });
10
12
 
11
- const actionAttr = (a: Action | undefined): string | undefined =>
12
- a ? JSON.stringify(a) : undefined;
13
-
14
- /** Fires `$action` through `$dispatch` when both are present, merging a runtime
15
- * value into the payload under the reserved `$input` key (not `value`) so the
16
- * user's input never clobbers a model-supplied `value` field. No-op when no
17
- * registry is wired. The returned promise from an async handler is caught and
18
- * re-thrown on a microtask so rejections surface rather than going unhandled. */
19
- const fire = (
20
- $action: Action | undefined,
21
- $dispatch: GenerativeUIDispatch | undefined,
22
- input?: unknown,
23
- ) => {
24
- if (!$action || !$dispatch) return;
25
- const payload = input === undefined ? $action : { ...$action, $input: input };
26
- void Promise.resolve($dispatch(payload)).catch((error) => {
27
- queueMicrotask(() => {
28
- throw error;
29
- });
30
- });
13
+ type RadioGroupRenderProps = {
14
+ options: { label: string; value: string }[];
15
+ name?: string;
16
+ label?: string;
17
+ defaultValue?: string;
18
+ $action?: Action;
19
+ $dispatch?: GenerativeUIDispatch;
31
20
  };
32
21
 
22
+ function RadioGroupRender({
23
+ options,
24
+ name,
25
+ label,
26
+ defaultValue,
27
+ $action,
28
+ $dispatch,
29
+ }: RadioGroupRenderProps) {
30
+ const generatedName = useId();
31
+ const fieldName = name ?? generatedName;
32
+ const safeOptions = Array.isArray(options) ? options : [];
33
+ return (
34
+ <fieldset
35
+ data-aui="radiogroup"
36
+ data-aui-action={actionAttr($action)}
37
+ aria-label={label}
38
+ >
39
+ {safeOptions.map((option, i) =>
40
+ option &&
41
+ typeof option.label === "string" &&
42
+ typeof option.value === "string" ? (
43
+ <label key={i} data-aui="radiogroup-option">
44
+ <input
45
+ type="radio"
46
+ name={fieldName}
47
+ value={option.value}
48
+ defaultChecked={defaultValue === option.value}
49
+ onChange={() => fire($action, $dispatch, option.value)}
50
+ />
51
+ {option.label}
52
+ </label>
53
+ ) : null,
54
+ )}
55
+ </fieldset>
56
+ );
57
+ }
58
+
33
59
  export const interactiveVocabulary = {
34
60
  Button: {
35
61
  description:
36
- "A clickable button. Carries `$action` describing the side effect or resume value.",
62
+ "A clickable button. Carries `$action` describing the side effect or resume value. Set `submit` to submit an ancestor Form/Card instead of firing `$action` on click.",
37
63
  properties: z.object({
38
64
  label: z.string().describe("Button label."),
39
65
  buttonStyle: z.enum(BUTTON_STYLES).optional().describe("Visual style."),
@@ -41,14 +67,30 @@ export const interactiveVocabulary = {
41
67
  .boolean()
42
68
  .optional()
43
69
  .describe("Whether the button spans the full width."),
70
+ submit: z
71
+ .boolean()
72
+ .optional()
73
+ .describe(
74
+ "Render as a submit button for an ancestor Form/Card instead of a click button.",
75
+ ),
44
76
  }),
45
- render: ({ label, buttonStyle, block, $action, $dispatch, children }) => (
77
+ render: ({
78
+ label,
79
+ buttonStyle,
80
+ block,
81
+ submit,
82
+ $action,
83
+ $dispatch,
84
+ children,
85
+ }) => (
46
86
  <button
87
+ type={submit ? "submit" : "button"}
47
88
  data-aui="button"
48
89
  data-aui-style={buttonStyle}
49
90
  data-aui-block={block || undefined}
91
+ data-aui-submit={submit || undefined}
50
92
  data-aui-action={actionAttr($action)}
51
- onClick={() => fire($action, $dispatch)}
93
+ onClick={submit ? undefined : () => fire($action, $dispatch)}
52
94
  >
53
95
  {label}
54
96
  {children}
@@ -68,11 +110,21 @@ export const interactiveVocabulary = {
68
110
  .string()
69
111
  .optional()
70
112
  .describe("Accessible label for the control."),
113
+ name: z.string().optional().describe("Field name used inside a Form."),
71
114
  }),
72
- render: ({ options, placeholder, label, $action, $dispatch, children }) => (
115
+ render: ({
116
+ options,
117
+ placeholder,
118
+ label,
119
+ name,
120
+ $action,
121
+ $dispatch,
122
+ children,
123
+ }) => (
73
124
  <select
74
125
  data-aui="select"
75
126
  data-aui-action={actionAttr($action)}
127
+ name={name}
76
128
  aria-label={label}
77
129
  defaultValue=""
78
130
  onChange={(e) => fire($action, $dispatch, e.currentTarget.value)}
@@ -104,33 +156,48 @@ export const interactiveVocabulary = {
104
156
  .string()
105
157
  .optional()
106
158
  .describe("Accessible label for the control."),
159
+ name: z.string().optional().describe("Field name used inside a Form."),
107
160
  }),
108
- render: ({ placeholder, multiline, label, $action, $dispatch }) => {
161
+ render: ({ placeholder, multiline, label, name, $action, $dispatch }) => {
109
162
  const submit = (v: string) => fire($action, $dispatch, v);
110
163
  return multiline ? (
111
164
  <textarea
112
165
  data-aui="input"
113
166
  data-aui-multiline
114
167
  data-aui-action={actionAttr($action)}
168
+ name={name}
115
169
  aria-label={label}
116
170
  placeholder={placeholder}
117
171
  onKeyDown={(e) => {
118
172
  if (
119
- e.key === "Enter" &&
120
- (e.ctrlKey || e.metaKey) &&
121
- !e.nativeEvent.isComposing
173
+ e.key !== "Enter" ||
174
+ !(e.ctrlKey || e.metaKey) ||
175
+ e.nativeEvent.isComposing
122
176
  )
177
+ return;
178
+ if (e.currentTarget.form) {
179
+ e.preventDefault();
180
+ HTMLFormElement.prototype.requestSubmit.call(
181
+ e.currentTarget.form,
182
+ );
183
+ } else {
123
184
  submit(e.currentTarget.value);
185
+ }
124
186
  }}
125
187
  />
126
188
  ) : (
127
189
  <input
128
190
  data-aui="input"
129
191
  data-aui-action={actionAttr($action)}
192
+ name={name}
130
193
  aria-label={label}
131
194
  placeholder={placeholder}
132
195
  onKeyDown={(e) => {
133
- if (e.key === "Enter" && !e.nativeEvent.isComposing)
196
+ if (
197
+ e.key === "Enter" &&
198
+ !e.nativeEvent.isComposing &&
199
+ !e.currentTarget.form
200
+ )
134
201
  submit(e.currentTarget.value);
135
202
  }}
136
203
  />
@@ -148,12 +215,14 @@ export const interactiveVocabulary = {
148
215
  .string()
149
216
  .optional()
150
217
  .describe("Accessible label for the control."),
218
+ name: z.string().optional().describe("Field name used inside a Form."),
151
219
  }),
152
- render: ({ value, min, max, label, $action, $dispatch }) => (
220
+ render: ({ value, min, max, label, name, $action, $dispatch }) => (
153
221
  <input
154
222
  type="date"
155
223
  data-aui="datepicker"
156
224
  data-aui-action={actionAttr($action)}
225
+ name={name}
157
226
  aria-label={label}
158
227
  defaultValue={value}
159
228
  min={min}
@@ -162,4 +231,39 @@ export const interactiveVocabulary = {
162
231
  />
163
232
  ),
164
233
  },
234
+ Checkbox: {
235
+ description:
236
+ "A checkbox with a label. Carries `$action` describing the on-change behavior.",
237
+ properties: z.object({
238
+ label: z.string().describe("Label text next to the checkbox."),
239
+ name: z.string().optional().describe("Field name used inside a Form."),
240
+ defaultChecked: z
241
+ .boolean()
242
+ .optional()
243
+ .describe("Whether the checkbox starts checked."),
244
+ }),
245
+ render: ({ label, name, defaultChecked, $action, $dispatch }) => (
246
+ <label data-aui="checkbox">
247
+ <input
248
+ type="checkbox"
249
+ data-aui-action={actionAttr($action)}
250
+ name={name}
251
+ defaultChecked={defaultChecked}
252
+ onChange={(e) => fire($action, $dispatch, e.currentTarget.checked)}
253
+ />
254
+ <span data-aui="checkbox-label">{label}</span>
255
+ </label>
256
+ ),
257
+ },
258
+ RadioGroup: {
259
+ description:
260
+ "A group of mutually exclusive radio options. Carries `$action` describing the on-change behavior.",
261
+ properties: z.object({
262
+ options: z.array(optionSchema).describe("Selectable options."),
263
+ name: z.string().optional().describe("Field name used inside a Form."),
264
+ label: z.string().optional().describe("Accessible name for the group."),
265
+ defaultValue: z.string().optional().describe("Initially selected value."),
266
+ }),
267
+ render: RadioGroupRender,
268
+ },
165
269
  } satisfies GenerativeUILibrary;
@@ -1,11 +1,33 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import type { ReactElement } from "react";
2
3
  import { renderToStaticMarkup } from "react-dom/server";
3
4
  import { renderGenerativeUI } from "../renderGenerativeUI";
5
+ import { createActionRegistry } from "../actionRegistry";
4
6
  import { layoutVocabulary } from "./layout";
7
+ import type { FormControlElementLike } from "./collectFormValues";
5
8
 
6
9
  const render = (node: unknown) =>
7
10
  renderToStaticMarkup(<>{renderGenerativeUI(node, layoutVocabulary)}</>);
8
11
 
12
+ const el = (
13
+ partial: Partial<FormControlElementLike>,
14
+ ): FormControlElementLike => ({
15
+ name: "",
16
+ type: "text",
17
+ value: "",
18
+ disabled: false,
19
+ ...partial,
20
+ });
21
+
22
+ const getFooterButtons = (out: ReactElement) => {
23
+ const footer = (out.props as { children: unknown[] })
24
+ .children[2] as ReactElement;
25
+ const [confirmBtn, cancelBtn] = (
26
+ footer.props as { children: [ReactElement | null, ReactElement | null] }
27
+ ).children;
28
+ return { confirmBtn, cancelBtn };
29
+ };
30
+
9
31
  describe("layoutVocabulary", () => {
10
32
  it("Card renders a section with optional title and padding/background hooks", () => {
11
33
  expect(
@@ -13,13 +35,32 @@ describe("layoutVocabulary", () => {
13
35
  $type: "Card",
14
36
  title: "T",
15
37
  padding: 2,
16
- background: "muted",
38
+ background: "var(--muted)",
17
39
  }),
18
40
  ).toBe(
19
- '<section data-aui="card" data-aui-padding="2" data-aui-background="muted"><header data-aui="card-title">T</header></section>',
41
+ '<section data-aui="card" data-aui-padding="2" data-aui-background="var(--muted)" style="background:var(--muted);color:white"><header data-aui="card-title">T</header></section>',
20
42
  );
21
43
  });
22
44
 
45
+ it("Card applies background as an inline style so arbitrary CSS values (colors, gradients) render", () => {
46
+ const html = render({
47
+ $type: "Card",
48
+ background: "linear-gradient(90deg, #3b82f6, #1d4ed8)",
49
+ });
50
+ expect(html).toContain(
51
+ 'style="background:linear-gradient(90deg, #3b82f6, #1d4ed8);color:white"',
52
+ );
53
+ });
54
+
55
+ it("Card sets its text color to white whenever a background is provided", () => {
56
+ const html = render({ $type: "Card", background: "#1d4ed8" });
57
+ expect(html).toContain("color:white");
58
+ });
59
+
60
+ it("Card omits the style attribute when background is not provided", () => {
61
+ expect(render({ $type: "Card", title: "T" })).not.toContain("style=");
62
+ });
63
+
23
64
  it("Card without a title omits the header", () => {
24
65
  expect(render({ $type: "Card" })).toBe(
25
66
  '<section data-aui="card"></section>',
@@ -59,3 +100,190 @@ describe("layoutVocabulary", () => {
59
100
  );
60
101
  });
61
102
  });
103
+
104
+ describe("layoutVocabulary Box", () => {
105
+ it("renders a bare div with no style attribute when no props are given", () => {
106
+ expect(render({ $type: "Box" })).toBe('<div data-aui="box"></div>');
107
+ });
108
+
109
+ it("applies width/height as inline pixel lengths when given numbers", () => {
110
+ const html = render({ $type: "Box", width: 200, height: 8 });
111
+ expect(html).toContain('style="width:200px;height:8px"');
112
+ });
113
+
114
+ it("applies width/height verbatim when given strings", () => {
115
+ const html = render({ $type: "Box", width: "100%", height: "1.5rem" });
116
+ expect(html).toContain('style="width:100%;height:1.5rem"');
117
+ });
118
+
119
+ it("applies a numeric radius as an inline pixel border-radius", () => {
120
+ const html = render({ $type: "Box", radius: 6 });
121
+ expect(html).toContain('style="border-radius:6px"');
122
+ expect(html).not.toContain("data-aui-radius");
123
+ });
124
+
125
+ it('applies radius "full" as a 9999px inline border-radius and a data-aui-radius hook', () => {
126
+ const html = render({ $type: "Box", radius: "full" });
127
+ expect(html).toContain('data-aui-radius="full"');
128
+ expect(html).toContain('style="border-radius:9999px"');
129
+ });
130
+
131
+ it("applies background as an inline style", () => {
132
+ const html = render({ $type: "Box", background: "#3b82f6" });
133
+ expect(html).toContain('style="background:#3b82f6"');
134
+ });
135
+
136
+ it("renders a progress bar as a track Box containing a partial-width fill Box", () => {
137
+ const html = render({
138
+ $type: "Box",
139
+ width: "100%",
140
+ height: 8,
141
+ radius: "full",
142
+ background: "var(--muted)",
143
+ children: {
144
+ $type: "Box",
145
+ width: "60%",
146
+ height: 8,
147
+ radius: "full",
148
+ background: "var(--primary)",
149
+ },
150
+ });
151
+ expect(html).toBe(
152
+ '<div data-aui="box" data-aui-radius="full" style="width:100%;height:8px;background:var(--muted);border-radius:9999px">' +
153
+ '<div data-aui="box" data-aui-radius="full" style="width:60%;height:8px;background:var(--primary);border-radius:9999px"></div>' +
154
+ "</div>",
155
+ );
156
+ });
157
+
158
+ it("renders nested children alongside style props", () => {
159
+ const html = render({
160
+ $type: "Box",
161
+ background: "red",
162
+ children: "content",
163
+ });
164
+ expect(html).toBe(
165
+ '<div data-aui="box" style="background:red">content</div>',
166
+ );
167
+ });
168
+ });
169
+
170
+ describe("layoutVocabulary Card asForm/confirm/cancel", () => {
171
+ it("Card with asForm renders a form element with the data-aui-asform hook", () => {
172
+ const html = render({ $type: "Card", asForm: true, title: "T" });
173
+ expect(html).toContain("<form ");
174
+ expect(html).toContain('data-aui-asform="true"');
175
+ expect(html).not.toContain("<section");
176
+ });
177
+
178
+ it("Card without asForm renders a section with no data-aui-asform hook", () => {
179
+ const html = render({ $type: "Card" });
180
+ expect(html).toContain("<section");
181
+ expect(html).not.toContain("data-aui-asform");
182
+ });
183
+
184
+ it("Card omits the footer when neither confirm nor cancel is present", () => {
185
+ expect(render({ $type: "Card" })).not.toContain("card-footer");
186
+ });
187
+
188
+ it("Card renders a footer when only confirm is present", () => {
189
+ expect(render({ $type: "Card", confirm: { label: "OK" } })).toContain(
190
+ 'data-aui="card-footer"',
191
+ );
192
+ });
193
+
194
+ it("Card renders a footer when only cancel is present", () => {
195
+ expect(render({ $type: "Card", cancel: { label: "Cancel" } })).toContain(
196
+ 'data-aui="card-footer"',
197
+ );
198
+ });
199
+
200
+ it("Card confirm renders a submit button when asForm is set", () => {
201
+ const html = render({
202
+ $type: "Card",
203
+ asForm: true,
204
+ confirm: { label: "Save" },
205
+ });
206
+ expect(html).toContain('type="submit" data-aui="card-confirm"');
207
+ expect(html).toContain("Save");
208
+ });
209
+
210
+ it("Card confirm renders a click button when asForm is not set", () => {
211
+ const html = render({ $type: "Card", confirm: { label: "Save" } });
212
+ expect(html).toContain('type="button" data-aui="card-confirm"');
213
+ });
214
+
215
+ it("Card cancel always renders a plain button regardless of asForm", () => {
216
+ const html = render({
217
+ $type: "Card",
218
+ asForm: true,
219
+ cancel: { label: "Dismiss" },
220
+ });
221
+ expect(html).toContain('type="button" data-aui="card-cancel"');
222
+ });
223
+ });
224
+
225
+ describe("layoutVocabulary Card asForm/confirm/cancel dispatch", () => {
226
+ it("Card asForm submit collects named values and dispatches confirm.$action with $input", () => {
227
+ const handler = vi.fn();
228
+ const registry = createActionRegistry({ save: handler });
229
+ const out = layoutVocabulary.Card.render({
230
+ asForm: true,
231
+ confirm: { label: "Save", $action: { type: "save" } },
232
+ $status: "done",
233
+ $dispatch: registry.dispatch,
234
+ }) as ReactElement;
235
+ const onSubmit = (out.props as { onSubmit: (e: unknown) => void }).onSubmit;
236
+ const event = {
237
+ preventDefault: vi.fn(),
238
+ currentTarget: {
239
+ elements: [el({ name: "email", type: "email", value: "a@x.com" })],
240
+ },
241
+ };
242
+ onSubmit(event);
243
+ expect(event.preventDefault).toHaveBeenCalled();
244
+ expect(handler).toHaveBeenCalledWith({
245
+ payload: { type: "save", $input: { email: "a@x.com" } },
246
+ });
247
+ });
248
+
249
+ it("Card confirm click (non-asForm) fires confirm.$action without $input", () => {
250
+ const handler = vi.fn();
251
+ const registry = createActionRegistry({ save: handler });
252
+ const out = layoutVocabulary.Card.render({
253
+ confirm: { label: "Save", $action: { type: "save" } },
254
+ $status: "done",
255
+ $dispatch: registry.dispatch,
256
+ }) as ReactElement;
257
+ const { confirmBtn } = getFooterButtons(out);
258
+ const onClick = (confirmBtn!.props as { onClick: () => void }).onClick;
259
+ onClick();
260
+ expect(handler).toHaveBeenCalledWith({ payload: { type: "save" } });
261
+ });
262
+
263
+ it("Card confirm button has no onClick when asForm is set (the form submit handles it)", () => {
264
+ const out = layoutVocabulary.Card.render({
265
+ asForm: true,
266
+ confirm: { label: "Save", $action: { type: "save" } },
267
+ $status: "done",
268
+ }) as ReactElement;
269
+ const { confirmBtn } = getFooterButtons(out);
270
+ expect(
271
+ (confirmBtn!.props as { onClick?: unknown }).onClick,
272
+ ).toBeUndefined();
273
+ });
274
+
275
+ it("Card cancel click fires cancel.$action without $input regardless of asForm", () => {
276
+ const handler = vi.fn();
277
+ const registry = createActionRegistry({ dismiss: handler });
278
+ const out = layoutVocabulary.Card.render({
279
+ asForm: true,
280
+ cancel: { label: "Dismiss", $action: { type: "dismiss" } },
281
+ $status: "done",
282
+ $dispatch: registry.dispatch,
283
+ }) as ReactElement;
284
+ const { cancelBtn } = getFooterButtons(out);
285
+ const onClick = (cancelBtn!.props as { onClick: () => void }).onClick;
286
+ onClick();
287
+ expect(handler).toHaveBeenCalledWith({ payload: { type: "dismiss" } });
288
+ });
289
+ });