@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,58 @@
1
+ import type { FormEvent } from "react";
2
+
3
+ /**
4
+ * The subset of `HTMLInputElement`/`HTMLSelectElement`/`HTMLTextAreaElement` that {@link collectFormValues} reads. A structural type rather than the DOM interfaces themselves, so a plain object can stand in for a form control in tests.
5
+ */
6
+ export type FormControlElementLike = {
7
+ readonly name: string;
8
+ readonly type: string;
9
+ readonly value: string;
10
+ readonly checked?: boolean;
11
+ readonly disabled: boolean;
12
+ };
13
+
14
+ /**
15
+ * Collects a submitted form's named control values into a plain object, keyed by `name`, in document order. Reads each control's live DOM state rather than `FormData`, so a checkbox resolves to its `checked` boolean instead of an on/off string. A radio group resolves to its checked option's `value`, or `undefined` if none is checked. Any other repeated `name` resolves to an array of its controls' values, in document order. Controls without a `name`, or that are `disabled`, are skipped entirely.
16
+ */
17
+ export function collectFormValues(
18
+ elements: ArrayLike<FormControlElementLike>,
19
+ ): Record<string, unknown> {
20
+ const values: Record<string, unknown> = Object.create(null);
21
+
22
+ for (const element of Array.from(elements)) {
23
+ const { name, disabled } = element;
24
+ if (!name || disabled) continue;
25
+
26
+ if (element.type === "radio") {
27
+ if (element.checked) values[name] = element.value;
28
+ else if (!Object.hasOwn(values, name)) values[name] = undefined;
29
+ continue;
30
+ }
31
+
32
+ const value: string | boolean =
33
+ element.type === "checkbox" ? (element.checked ?? false) : element.value;
34
+
35
+ if (Object.hasOwn(values, name)) {
36
+ const existing = values[name];
37
+ values[name] = Array.isArray(existing)
38
+ ? [...existing, value]
39
+ : [existing, value];
40
+ } else {
41
+ values[name] = value;
42
+ }
43
+ }
44
+
45
+ return values;
46
+ }
47
+
48
+ /**
49
+ * Convenience wrapper around {@link collectFormValues} for a native form submit event; the cast from `HTMLFormControlsCollection` to `ArrayLike<FormControlElementLike>` lives here once instead of at every call site.
50
+ */
51
+ export function collectFormValuesFromEvent(
52
+ event: FormEvent<HTMLFormElement>,
53
+ ): Record<string, unknown> {
54
+ return collectFormValues(
55
+ event.currentTarget
56
+ .elements as unknown as ArrayLike<FormControlElementLike>,
57
+ );
58
+ }
@@ -52,16 +52,516 @@ describe("dataVocabulary", () => {
52
52
  ).toBe('<div data-aui="markdown">partial</div>');
53
53
  });
54
54
 
55
- it("Chart renders a placeholder div with variant/color/data hooks", () => {
55
+ it("Chart bar variant renders one rect per data point", () => {
56
56
  const html = render({
57
57
  $type: "Chart",
58
58
  variant: "bar",
59
- data: [{ label: "a", value: 1 }],
59
+ data: [{ value: 1 }, { value: 2 }, { value: 3 }],
60
60
  color: "#f00",
61
61
  });
62
62
  expect(html).toContain('data-aui="chart"');
63
63
  expect(html).toContain('data-aui-variant="bar"');
64
64
  expect(html).toContain('data-aui-color="#f00"');
65
- expect(html).toContain("&quot;value&quot;:1");
65
+ expect((html.match(/<rect/g) ?? []).length).toBe(3);
66
+ expect(html).not.toContain("<polyline");
67
+ expect(html).not.toContain("data-aui-data");
68
+ });
69
+
70
+ it("Chart line variant renders a single polyline through all points", () => {
71
+ const html = render({
72
+ $type: "Chart",
73
+ variant: "line",
74
+ data: [{ value: 0 }, { value: 20 }, { value: 40 }],
75
+ });
76
+ expect((html.match(/<polyline/g) ?? []).length).toBe(1);
77
+ expect(html).not.toContain("<rect");
78
+ expect(html).toContain('points="0,40 50,20 100,0"');
79
+ });
80
+
81
+ it("Chart line variant with a single data point renders a circle, not a polyline", () => {
82
+ const html = render({
83
+ $type: "Chart",
84
+ variant: "line",
85
+ data: [{ value: 20 }],
86
+ });
87
+ expect(html).not.toContain("<polyline");
88
+ expect((html.match(/<circle/g) ?? []).length).toBe(1);
89
+ expect(html).toContain('cx="50"');
90
+ expect(html).toContain('cy="0"');
91
+ expect(html).toContain('r="2"');
92
+ });
93
+
94
+ it("Chart sparkline variant is structurally identical to line, differentiated by the variant attribute", () => {
95
+ const html = render({
96
+ $type: "Chart",
97
+ variant: "sparkline",
98
+ data: [{ value: 0 }, { value: 20 }, { value: 40 }],
99
+ });
100
+ expect((html.match(/<polyline/g) ?? []).length).toBe(1);
101
+ expect(html).toContain('data-aui-variant="sparkline"');
102
+ expect(html).toContain('points="0,40 50,20 100,0"');
103
+ });
104
+
105
+ it("Chart renders an aria-label describing the variant and point count", () => {
106
+ const html = render({
107
+ $type: "Chart",
108
+ variant: "bar",
109
+ data: [{ value: 1 }, { value: 2 }],
110
+ });
111
+ expect(html).toContain('aria-label="bar chart with 2 data points"');
112
+ });
113
+
114
+ it("Chart with empty data renders an empty svg without throwing", () => {
115
+ expect(() =>
116
+ render({ $type: "Chart", variant: "bar", data: [] }),
117
+ ).not.toThrow();
118
+ const html = render({ $type: "Chart", variant: "line", data: [] });
119
+ expect(html).toContain('data-aui="chart"');
120
+ expect(html).not.toContain("<rect");
121
+ expect(html).not.toContain("<polyline");
122
+ expect(html).toContain('aria-label="line chart with 0 data points"');
123
+ });
124
+
125
+ it("Chart with missing data renders an empty svg without throwing", () => {
126
+ expect(() => render({ $type: "Chart", variant: "bar" })).not.toThrow();
127
+ const html = render({ $type: "Chart", variant: "bar" });
128
+ expect(html).toContain('data-aui="chart"');
129
+ expect(html).not.toContain("<rect");
130
+ });
131
+
132
+ it("Chart clamps negative and non-finite values to 0", () => {
133
+ const html = render({
134
+ $type: "Chart",
135
+ variant: "bar",
136
+ data: [{ value: -5 }, { value: Number.NaN }, { value: 10 }],
137
+ });
138
+ expect((html.match(/<rect/g) ?? []).length).toBe(3);
139
+ expect(html).toContain('height="0"');
140
+ expect(html).toContain('height="40"');
141
+ });
142
+
143
+ it("Chart with all-zero data renders flat baseline marks", () => {
144
+ const barHtml = render({
145
+ $type: "Chart",
146
+ variant: "bar",
147
+ data: [{ value: 0 }, { value: 0 }],
148
+ });
149
+ expect((barHtml.match(/height="0"/g) ?? []).length).toBe(2);
150
+
151
+ const lineHtml = render({
152
+ $type: "Chart",
153
+ variant: "line",
154
+ data: [{ value: 0 }, { value: 0 }, { value: 0 }],
155
+ });
156
+ expect(lineHtml).toContain('points="0,40 50,40 100,40"');
157
+ });
158
+ });
159
+
160
+ describe("dataVocabulary Chart multi-series", () => {
161
+ it("wraps each series' marks in its own data-aui=chart-series group, indexed", () => {
162
+ const html = render({
163
+ $type: "Chart",
164
+ variant: "line",
165
+ series: [
166
+ { label: "A", data: [{ value: 1 }, { value: 2 }] },
167
+ { label: "B", data: [{ value: 3 }, { value: 4 }] },
168
+ { label: "C", data: [{ value: 5 }, { value: 6 }] },
169
+ ],
170
+ });
171
+ expect((html.match(/data-aui="chart-series"/g) ?? []).length).toBe(3);
172
+ expect(html).toContain('data-aui-series="0"');
173
+ expect(html).toContain('data-aui-series="1"');
174
+ expect(html).toContain('data-aui-series="2"');
175
+ expect((html.match(/<polyline/g) ?? []).length).toBe(3);
176
+ });
177
+
178
+ it("falls back to the data prop when series is an empty array", () => {
179
+ const withEmptySeries = render({
180
+ $type: "Chart",
181
+ variant: "bar",
182
+ series: [],
183
+ data: [{ value: 1 }, { value: 2 }],
184
+ });
185
+ const withDataOnly = render({
186
+ $type: "Chart",
187
+ variant: "bar",
188
+ data: [{ value: 1 }, { value: 2 }],
189
+ });
190
+ expect((withEmptySeries.match(/<rect/g) ?? []).length).toBe(2);
191
+ expect(withDataOnly).toContain('data-aui-variant="bar"');
192
+ });
193
+
194
+ it("series takes precedence over data when both are present", () => {
195
+ const html = render({
196
+ $type: "Chart",
197
+ variant: "bar",
198
+ data: [{ value: 999 }],
199
+ series: [{ data: [{ value: 1 }, { value: 2 }] }],
200
+ });
201
+ expect((html.match(/<rect/g) ?? []).length).toBe(2);
202
+ });
203
+
204
+ it("pads a shorter series with 0-valued points instead of throwing or misaligning", () => {
205
+ expect(() =>
206
+ render({
207
+ $type: "Chart",
208
+ variant: "bar",
209
+ series: [
210
+ { data: [{ value: 10 }, { value: 20 }, { value: 30 }] },
211
+ { data: [{ value: 5 }] },
212
+ ],
213
+ }),
214
+ ).not.toThrow();
215
+ const html = render({
216
+ $type: "Chart",
217
+ variant: "bar",
218
+ series: [
219
+ { data: [{ value: 10 }, { value: 20 }, { value: 30 }] },
220
+ { data: [{ value: 5 }] },
221
+ ],
222
+ });
223
+ expect((html.match(/<rect/g) ?? []).length).toBe(6);
224
+ expect(html).toContain('aria-label="bar chart with 3 data points"');
225
+ });
226
+
227
+ it("treats a missing series data array as empty rather than throwing", () => {
228
+ expect(() =>
229
+ render({
230
+ $type: "Chart",
231
+ variant: "bar",
232
+ series: [{ label: "A" }, { data: [{ value: 1 }] }] as never,
233
+ }),
234
+ ).not.toThrow();
235
+ });
236
+ });
237
+
238
+ describe("dataVocabulary Chart stacking", () => {
239
+ it("stacked bars accumulate per x across series (known input)", () => {
240
+ const html = render({
241
+ $type: "Chart",
242
+ variant: "bar",
243
+ stacked: true,
244
+ series: [
245
+ { data: [{ value: 10 }] },
246
+ { data: [{ value: 20 }] },
247
+ { data: [{ value: 30 }] },
248
+ ],
249
+ });
250
+ // total at the single x is 60 -> scaleMax (unstacked axis, plain max) = 60.
251
+ // series 0: height (10/60)*40 ≈ 6.667 at y = 40 - 0 - 6.667 ≈ 33.333
252
+ // series 1: height (20/60)*40 ≈ 13.333 at y = 40 - 6.667 - 13.333 ≈ 20
253
+ // series 2: height (30/60)*40 = 20 at y = 40 - 20 - 20 = 0
254
+ expect(html).toContain('y="33.333333333333336"');
255
+ expect(html).toContain('height="6.666666666666666"');
256
+ expect(html).toContain('y="20.000000000000004"');
257
+ expect(html).toContain('height="13.333333333333332"');
258
+ expect(html).toContain('y="0" width="80" height="20"');
259
+ });
260
+
261
+ it("unstacked (overlaid) bars all start from the baseline regardless of series order", () => {
262
+ const html = render({
263
+ $type: "Chart",
264
+ variant: "bar",
265
+ series: [{ data: [{ value: 10 }] }, { data: [{ value: 20 }] }],
266
+ });
267
+ expect((html.match(/y="0"/g) ?? []).length).toBe(1); // only the tallest bar touches y=0
268
+ });
269
+
270
+ it("stacked areas build cumulative top/bottom polygons per series", () => {
271
+ const html = render({
272
+ $type: "Chart",
273
+ variant: "area",
274
+ stacked: true,
275
+ series: [
276
+ { data: [{ value: 10 }, { value: 10 }] },
277
+ { data: [{ value: 10 }, { value: 10 }] },
278
+ ],
279
+ });
280
+ expect((html.match(/<polygon/g) ?? []).length).toBe(2);
281
+ // series 0 (bottom band): top=10/20*40=20 -> y=20, bottom=0 -> y=40
282
+ expect(html).toContain('points="0,20 100,20 100,40 0,40"');
283
+ // series 1 (top band): top=20/20*40=40 -> y=0, bottom=10/20*40=20 -> y=20
284
+ expect(html).toContain('points="0,0 100,0 100,20 0,20"');
285
+ });
286
+
287
+ it("stacked bars with showAxis keep the bottom segment on the baseline, abut exactly, and share one denominator with the ticks (totals crossing a tick boundary)", () => {
288
+ const html = render({
289
+ $type: "Chart",
290
+ variant: "bar",
291
+ stacked: true,
292
+ showAxis: true,
293
+ series: [
294
+ { data: [{ value: 50 }] },
295
+ { data: [{ value: 30 }] },
296
+ { data: [{ value: 25 }] },
297
+ ],
298
+ });
299
+ // total 105 crosses the 50*10^n boundary, so niceMax must fall back to the
300
+ // next power of ten (200) instead of resolving to an under-sized 100.
301
+ expect(html).toContain("<div>200</div><div>150</div>");
302
+ expect(html).toContain("<div>100</div><div>50</div><div>0</div>");
303
+
304
+ // series 0 (bottom): height=(50/200)*40=10 at y=40-0-10=30; bottom edge
305
+ // y+height=40, the chart baseline.
306
+ expect(html).toContain('y="30" width="80" height="10"');
307
+ // series 1: height=(30/200)*40=6 at y=40-10-6=24; abuts series 0's y=30
308
+ // exactly (24+6=30, no gap or overlap).
309
+ expect(html).toContain('y="24" width="80" height="6"');
310
+ // series 2 (top): height=(25/200)*40=5 at y=40-16-5=19; abuts series 1's
311
+ // y=24 exactly (19+5=24). The stack top (y=19) equals
312
+ // 40-(105/200)*40=19, the same denominator (200) the ticks above use.
313
+ expect(html).toContain('y="19" width="80" height="5"');
314
+ });
315
+
316
+ it("stacked areas keep the bottom band's baseline at the chart bottom regardless of scale", () => {
317
+ const html = render({
318
+ $type: "Chart",
319
+ variant: "area",
320
+ stacked: true,
321
+ showAxis: true,
322
+ series: [
323
+ { data: [{ value: 60 }, { value: 60 }] },
324
+ { data: [{ value: 60 }, { value: 60 }] },
325
+ ],
326
+ });
327
+ // total 120 crosses the same tick boundary (niceMax -> 200); the bottom
328
+ // band's own bottom edge must still sit at the chart baseline (y=40)
329
+ // regardless of scale, since it is built from an all-zero `bottoms` array.
330
+ expect(html).toContain("<div>200</div>");
331
+ expect(html).toContain('points="0,28 100,28 100,40 0,40"');
332
+ });
333
+ });
334
+
335
+ describe("dataVocabulary Chart area variant", () => {
336
+ it("renders a closed filled polygon with fill-opacity and a stroke on top", () => {
337
+ const html = render({
338
+ $type: "Chart",
339
+ variant: "area",
340
+ data: [{ value: 0 }, { value: 40 }],
341
+ });
342
+ expect(html).toContain("<polygon");
343
+ expect(html).toContain('fill="currentColor"');
344
+ expect(html).toContain('fill-opacity="0.25"');
345
+ expect(html).toContain('stroke="currentColor"');
346
+ // top line 0,40 -> 100,0 then closed back down to the baseline in reverse.
347
+ expect(html).toContain('points="0,40 100,0 100,40 0,40"');
348
+ });
349
+
350
+ it("renders a circle instead of a degenerate polygon for a single point", () => {
351
+ const html = render({
352
+ $type: "Chart",
353
+ variant: "area",
354
+ data: [{ value: 20 }],
355
+ });
356
+ expect(html).not.toContain("<polygon");
357
+ expect((html.match(/<circle/g) ?? []).length).toBe(1);
358
+ });
359
+
360
+ it("renders an empty series group without throwing when data is empty", () => {
361
+ expect(() =>
362
+ render({ $type: "Chart", variant: "area", data: [] }),
363
+ ).not.toThrow();
364
+ const html = render({ $type: "Chart", variant: "area", data: [] });
365
+ expect(html).not.toContain("<polygon");
366
+ expect(html).not.toContain("<circle");
367
+ });
368
+ });
369
+
370
+ describe("dataVocabulary Chart showAxis niceMax ticks", () => {
371
+ it("derives a round niceMax and renders 5 ticks from it down to 0", () => {
372
+ const html = render({
373
+ $type: "Chart",
374
+ variant: "bar",
375
+ showAxis: true,
376
+ data: [{ value: 42 }],
377
+ });
378
+ // niceMax(42): exponent=1, candidates 10,20,50 -> 50 is the first >= 42.
379
+ expect(html).toContain('data-aui="chart-frame"');
380
+ expect(html).toContain('data-aui="chart-ticks"');
381
+ const ticksMatch = html.match(
382
+ /<div data-aui="chart-ticks">(.*?)<\/div><svg/,
383
+ );
384
+ expect(ticksMatch).not.toBeNull();
385
+ const ticksHtml = ticksMatch![1]!;
386
+ expect((ticksHtml.match(/<div>/g) ?? []).length).toBe(5);
387
+ expect(ticksHtml).toContain("<div>50</div>");
388
+ expect(ticksHtml).toContain("<div>37.5</div>");
389
+ expect(ticksHtml).toContain("<div>25</div>");
390
+ expect(ticksHtml).toContain("<div>12.5</div>");
391
+ expect(ticksHtml).toContain("<div>0</div>");
392
+ });
393
+
394
+ it("scales marks against niceMax (not the raw max) when the axis is shown", () => {
395
+ const html = render({
396
+ $type: "Chart",
397
+ variant: "bar",
398
+ showAxis: true,
399
+ data: [{ value: 42 }],
400
+ });
401
+ // niceMax = 50, so height = (42/50)*40 = 33.6, not (42/42)*40 = 40.
402
+ expect(html).toContain('height="33.6"');
403
+ });
404
+
405
+ it("stays a bare svg (no frame) when neither showAxis nor showLegend is set, even with new props", () => {
406
+ const html = render({
407
+ $type: "Chart",
408
+ variant: "bar",
409
+ stacked: true,
410
+ series: [{ data: [{ value: 1 }] }],
411
+ });
412
+ expect(html).not.toContain("chart-frame");
413
+ expect(html.startsWith("<svg")).toBe(true);
414
+ });
415
+ });
416
+
417
+ describe("dataVocabulary Chart legend and x-labels", () => {
418
+ it("renders an x-label row from the first series' point labels", () => {
419
+ const html = render({
420
+ $type: "Chart",
421
+ variant: "line",
422
+ showAxis: true,
423
+ data: [
424
+ { label: "Mon", value: 1 },
425
+ { label: "Tue", value: 2 },
426
+ ],
427
+ });
428
+ expect(html).toContain('data-aui="chart-xlabels">');
429
+ expect(html).toContain("<div>Mon</div>");
430
+ expect(html).toContain("<div>Tue</div>");
431
+ });
432
+
433
+ it("omits the legend row when showLegend is not set", () => {
434
+ const html = render({
435
+ $type: "Chart",
436
+ variant: "line",
437
+ showAxis: true,
438
+ series: [{ label: "A", data: [{ value: 1 }] }],
439
+ });
440
+ expect(html).not.toContain("chart-legend");
441
+ });
442
+
443
+ it("renders a legend item per series with an indexed swatch when showLegend is set", () => {
444
+ const html = render({
445
+ $type: "Chart",
446
+ variant: "line",
447
+ showLegend: true,
448
+ series: [
449
+ { label: "Revenue", data: [{ value: 1 }] },
450
+ { label: "Cost", data: [{ value: 2 }] },
451
+ ],
452
+ });
453
+ expect((html.match(/data-aui="chart-legend-item"/g) ?? []).length).toBe(2);
454
+ expect(html).toContain(
455
+ '<span data-aui="chart-legend-item" data-aui-series="0">',
456
+ );
457
+ expect(html).toContain(
458
+ '<span data-aui="chart-legend-item" data-aui-series="1">',
459
+ );
460
+ expect(html).toContain("Revenue");
461
+ expect(html).toContain("Cost");
462
+ expect((html.match(/data-aui="chart-legend-swatch"/g) ?? []).length).toBe(
463
+ 2,
464
+ );
465
+ });
466
+
467
+ it("falls back to a positional label when a series has no label", () => {
468
+ const html = render({
469
+ $type: "Chart",
470
+ variant: "line",
471
+ showLegend: true,
472
+ series: [{ data: [{ value: 1 }] }],
473
+ });
474
+ expect(html).toContain("Series 1");
475
+ });
476
+
477
+ it("area series overlay from the baseline when not stacked", () => {
478
+ const html = render({
479
+ $type: "Chart",
480
+ variant: "area",
481
+ series: [
482
+ { data: [{ value: 10 }, { value: 20 }] },
483
+ { data: [{ value: 30 }, { value: 40 }] },
484
+ ],
485
+ });
486
+ const points = [...html.matchAll(/points="([^"]+)"/g)].map((m) => m[1]!);
487
+ expect(points).toHaveLength(2);
488
+ for (const polygon of points) {
489
+ expect(polygon).toContain("100,40");
490
+ expect(polygon).not.toContain("-");
491
+ }
492
+ expect(points[1]).toContain("100,0");
493
+ });
494
+
495
+ it("area series accumulate when stacked", () => {
496
+ const html = render({
497
+ $type: "Chart",
498
+ variant: "area",
499
+ stacked: true,
500
+ series: [
501
+ { data: [{ value: 10 }, { value: 20 }] },
502
+ { data: [{ value: 30 }, { value: 40 }] },
503
+ ],
504
+ });
505
+ const points = [...html.matchAll(/points="([^"]+)"/g)].map((m) => m[1]!);
506
+ expect(points).toHaveLength(2);
507
+ expect(points[1]).toContain("100,0");
508
+ expect(points[1]).not.toContain("100,40");
509
+ });
510
+
511
+ it("line charts ignore stacked for the scale", () => {
512
+ const html = render({
513
+ $type: "Chart",
514
+ variant: "line",
515
+ stacked: true,
516
+ series: [{ data: [{ value: 100 }] }, { data: [{ value: 100 }] }],
517
+ });
518
+ expect((html.match(/cy="0"/g) ?? []).length).toBe(2);
519
+ });
520
+
521
+ it("cycles series colors past the palette size", () => {
522
+ const html = render({
523
+ $type: "Chart",
524
+ variant: "bar",
525
+ showLegend: true,
526
+ series: Array.from({ length: 6 }, (_, i) => ({
527
+ label: `S${i + 1}`,
528
+ data: [{ value: i + 1 }],
529
+ })),
530
+ });
531
+ expect(html).toContain('<g data-aui="chart-series" data-aui-series="4">');
532
+ expect(
533
+ (html.match(/<g data-aui="chart-series" data-aui-series="0">/g) ?? [])
534
+ .length,
535
+ ).toBe(2);
536
+ expect(
537
+ (
538
+ html.match(
539
+ /<span data-aui="chart-legend-item" data-aui-series="0">/g,
540
+ ) ?? []
541
+ ).length,
542
+ ).toBe(2);
543
+ });
544
+
545
+ it("legend without axis renders no ticks or x labels", () => {
546
+ const html = render({
547
+ $type: "Chart",
548
+ variant: "bar",
549
+ showLegend: true,
550
+ series: [{ label: "A", data: [{ value: 1, label: "Jan" }] }],
551
+ });
552
+ expect(html).toContain('data-aui="chart-legend"');
553
+ expect(html).not.toContain('data-aui="chart-ticks"');
554
+ expect(html).not.toContain('data-aui="chart-xlabels"');
555
+ });
556
+
557
+ it("axis renders ticks and x labels", () => {
558
+ const html = render({
559
+ $type: "Chart",
560
+ variant: "bar",
561
+ showAxis: true,
562
+ series: [{ label: "A", data: [{ value: 1, label: "Jan" }] }],
563
+ });
564
+ expect(html).toContain('data-aui="chart-ticks"');
565
+ expect(html).toContain('data-aui="chart-xlabels"');
66
566
  });
67
567
  });