@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.
- package/dist/JSONGenerativeUI.client.d.ts +4 -2
- package/dist/JSONGenerativeUI.client.d.ts.map +1 -1
- package/dist/JSONGenerativeUI.client.js +9 -2
- package/dist/JSONGenerativeUI.client.js.map +1 -1
- package/dist/JSONGenerativeUI.server.d.ts +0 -1
- package/dist/JSONGenerativeUI.server.d.ts.map +1 -1
- package/dist/JSONGenerativeUI.shared.d.ts +8 -1
- package/dist/JSONGenerativeUI.shared.d.ts.map +1 -1
- package/dist/JSONGenerativeUI.shared.js.map +1 -1
- package/dist/actionRegistry.d.ts +32 -0
- package/dist/actionRegistry.d.ts.map +1 -0
- package/dist/actionRegistry.js +41 -0
- package/dist/actionRegistry.js.map +1 -0
- package/dist/buildPresentParameters.d.ts +5 -5
- package/dist/buildPresentParameters.d.ts.map +1 -1
- package/dist/buildPresentParameters.js +20 -6
- package/dist/buildPresentParameters.js.map +1 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/defineGenerativeComponents.d.ts +0 -1
- package/dist/defineGenerativeComponents.d.ts.map +1 -1
- package/dist/generativeUIToJSX.d.ts +15 -10
- package/dist/generativeUIToJSX.d.ts.map +1 -1
- package/dist/generativeUIToJSX.js +47 -17
- package/dist/generativeUIToJSX.js.map +1 -1
- package/dist/index.d.ts +8 -4
- package/dist/index.js +5 -2
- package/dist/index.server.d.ts +13 -0
- package/dist/index.server.js +10 -0
- package/dist/index.shared.d.ts +11 -0
- package/dist/index.shared.js +9 -0
- package/dist/ir.d.ts +115 -0
- package/dist/ir.d.ts.map +1 -0
- package/dist/ir.js +175 -0
- package/dist/ir.js.map +1 -0
- package/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts +3 -3
- package/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts.map +1 -1
- package/dist/renderGenerativeUI.d.ts +5 -5
- package/dist/renderGenerativeUI.d.ts.map +1 -1
- package/dist/renderGenerativeUI.js +18 -45
- package/dist/renderGenerativeUI.js.map +1 -1
- package/dist/slack/constants.d.ts +111 -0
- package/dist/slack/constants.d.ts.map +1 -0
- package/dist/slack/constants.js +168 -0
- package/dist/slack/constants.js.map +1 -0
- package/dist/slack/decodeBlockAction.d.ts +11 -0
- package/dist/slack/decodeBlockAction.d.ts.map +1 -0
- package/dist/slack/decodeBlockAction.js +40 -0
- package/dist/slack/decodeBlockAction.js.map +1 -0
- package/dist/slack/fromSlackBlocks.d.ts +30 -0
- package/dist/slack/fromSlackBlocks.d.ts.map +1 -0
- package/dist/slack/fromSlackBlocks.js +447 -0
- package/dist/slack/fromSlackBlocks.js.map +1 -0
- package/dist/slack/toSlackBlocks.d.ts +7 -0
- package/dist/slack/toSlackBlocks.d.ts.map +1 -0
- package/dist/slack/toSlackBlocks.js +649 -0
- package/dist/slack/toSlackBlocks.js.map +1 -0
- package/dist/slack/types.d.ts +178 -0
- package/dist/slack/types.d.ts.map +1 -0
- package/dist/slack/types.js +0 -0
- package/dist/slack.d.ts +5 -0
- package/dist/slack.js +4 -0
- package/dist/teams/boundSpec.d.ts +20 -0
- package/dist/teams/boundSpec.d.ts.map +1 -0
- package/dist/teams/boundSpec.js +67 -0
- package/dist/teams/boundSpec.js.map +1 -0
- package/dist/teams/constants.d.ts +57 -0
- package/dist/teams/constants.d.ts.map +1 -0
- package/dist/teams/constants.js +92 -0
- package/dist/teams/constants.js.map +1 -0
- package/dist/teams/decodeSubmitData.d.ts +25 -0
- package/dist/teams/decodeSubmitData.d.ts.map +1 -0
- package/dist/teams/decodeSubmitData.js +44 -0
- package/dist/teams/decodeSubmitData.js.map +1 -0
- package/dist/teams/toAdaptiveCard.d.ts +39 -0
- package/dist/teams/toAdaptiveCard.d.ts.map +1 -0
- package/dist/teams/toAdaptiveCard.js +496 -0
- package/dist/teams/toAdaptiveCard.js.map +1 -0
- package/dist/teams/toTeamsAttachments.d.ts +13 -0
- package/dist/teams/toTeamsAttachments.d.ts.map +1 -0
- package/dist/teams/toTeamsAttachments.js +95 -0
- package/dist/teams/toTeamsAttachments.js.map +1 -0
- package/dist/teams/types.d.ts +196 -0
- package/dist/teams/types.d.ts.map +1 -0
- package/dist/teams/types.js +0 -0
- package/dist/teams.d.ts +5 -0
- package/dist/teams.js +4 -0
- package/dist/types.d.ts +41 -34
- package/dist/types.d.ts.map +1 -1
- package/dist/vocabulary/alert.d.ts +28 -0
- package/dist/vocabulary/alert.d.ts.map +1 -0
- package/dist/vocabulary/alert.js +58 -0
- package/dist/vocabulary/alert.js.map +1 -0
- package/dist/vocabulary/collectFormValues.d.ts +23 -0
- package/dist/vocabulary/collectFormValues.d.ts.map +1 -0
- package/dist/vocabulary/collectFormValues.js +32 -0
- package/dist/vocabulary/collectFormValues.js.map +1 -0
- package/dist/vocabulary/data.d.ts +52 -0
- package/dist/vocabulary/data.d.ts.map +1 -0
- package/dist/vocabulary/data.js +294 -0
- package/dist/vocabulary/data.js.map +1 -0
- package/dist/vocabulary/dispatch.d.ts +11 -0
- package/dist/vocabulary/dispatch.d.ts.map +1 -0
- package/dist/vocabulary/dispatch.js +21 -0
- package/dist/vocabulary/dispatch.js.map +1 -0
- package/dist/vocabulary/fact.d.ts +15 -0
- package/dist/vocabulary/fact.d.ts.map +1 -0
- package/dist/vocabulary/fact.js +24 -0
- package/dist/vocabulary/fact.js.map +1 -0
- package/dist/vocabulary/form.d.ts +14 -0
- package/dist/vocabulary/form.d.ts.map +1 -0
- package/dist/vocabulary/form.js +23 -0
- package/dist/vocabulary/form.js.map +1 -0
- package/dist/vocabulary/icon.d.ts +44 -0
- package/dist/vocabulary/icon.d.ts.map +1 -0
- package/dist/vocabulary/icon.js +272 -0
- package/dist/vocabulary/icon.js.map +1 -0
- package/dist/vocabulary/index.d.ts +15 -0
- package/dist/vocabulary/index.d.ts.map +1 -0
- package/dist/vocabulary/index.js +36 -0
- package/dist/vocabulary/index.js.map +1 -0
- package/dist/vocabulary/interactive.d.ts +93 -0
- package/dist/vocabulary/interactive.d.ts.map +1 -0
- package/dist/vocabulary/interactive.js +172 -0
- package/dist/vocabulary/interactive.js.map +1 -0
- package/dist/vocabulary/layout.d.ts +82 -0
- package/dist/vocabulary/layout.d.ts.map +1 -0
- package/dist/vocabulary/layout.js +136 -0
- package/dist/vocabulary/layout.js.map +1 -0
- package/dist/vocabulary/list.d.ts +17 -0
- package/dist/vocabulary/list.d.ts.map +1 -0
- package/dist/vocabulary/list.js +48 -0
- package/dist/vocabulary/list.js.map +1 -0
- package/dist/vocabulary/media.d.ts +28 -0
- package/dist/vocabulary/media.d.ts.map +1 -0
- package/dist/vocabulary/media.js +42 -0
- package/dist/vocabulary/media.js.map +1 -0
- package/dist/vocabulary/text.d.ts +60 -0
- package/dist/vocabulary/text.d.ts.map +1 -0
- package/dist/vocabulary/text.js +48 -0
- package/dist/vocabulary/text.js.map +1 -0
- package/package.json +21 -13
- package/src/JSONGenerativeUI.client.tsx +10 -2
- package/src/JSONGenerativeUI.shared.ts +8 -0
- package/src/actionRegistry.test.ts +90 -0
- package/src/actionRegistry.ts +88 -0
- package/src/buildPresentParameters.ts +38 -16
- package/src/generativeUIToJSX.test.ts +186 -0
- package/src/generativeUIToJSX.ts +112 -21
- package/src/index.server.ts +2 -0
- package/src/index.shared.ts +61 -0
- package/src/index.ts +2 -26
- package/src/ir.test.ts +203 -0
- package/src/ir.ts +279 -0
- package/src/renderGenerativeUI.test.tsx +69 -2
- package/src/renderGenerativeUI.tsx +49 -82
- package/src/slack/constants.ts +217 -0
- package/src/slack/decodeBlockAction.test.ts +135 -0
- package/src/slack/decodeBlockAction.ts +66 -0
- package/src/slack/fromSlackBlocks.test.ts +1145 -0
- package/src/slack/fromSlackBlocks.ts +709 -0
- package/src/slack/toSlackBlocks.test.ts +1936 -0
- package/src/slack/toSlackBlocks.ts +1319 -0
- package/src/slack/types.ts +230 -0
- package/src/slack.ts +37 -0
- package/src/teams/boundSpec.ts +102 -0
- package/src/teams/constants.ts +127 -0
- package/src/teams/decodeSubmitData.test.ts +148 -0
- package/src/teams/decodeSubmitData.ts +57 -0
- package/src/teams/toAdaptiveCard.test.ts +1410 -0
- package/src/teams/toAdaptiveCard.ts +762 -0
- package/src/teams/toTeamsAttachments.test.ts +29 -0
- package/src/teams/toTeamsAttachments.ts +137 -0
- package/src/teams/types.ts +237 -0
- package/src/teams.ts +34 -0
- package/src/types.ts +57 -42
- package/src/vocabulary/alert.test.tsx +136 -0
- package/src/vocabulary/alert.tsx +66 -0
- package/src/vocabulary/collectFormValues.test.ts +127 -0
- package/src/vocabulary/collectFormValues.ts +58 -0
- package/src/vocabulary/data.test.tsx +567 -0
- package/src/vocabulary/data.tsx +494 -0
- package/src/vocabulary/dispatch.ts +22 -0
- package/src/vocabulary/fact.test.tsx +15 -0
- package/src/vocabulary/fact.tsx +22 -0
- package/src/vocabulary/form.test.tsx +112 -0
- package/src/vocabulary/form.tsx +34 -0
- package/src/vocabulary/icon.test.tsx +64 -0
- package/src/vocabulary/icon.tsx +174 -0
- package/src/vocabulary/index.test.ts +63 -0
- package/src/vocabulary/index.ts +33 -0
- package/src/vocabulary/interactive.dispatch.test.tsx +352 -0
- package/src/vocabulary/interactive.test.tsx +248 -0
- package/src/vocabulary/interactive.tsx +269 -0
- package/src/vocabulary/layout.test.tsx +289 -0
- package/src/vocabulary/layout.tsx +229 -0
- package/src/vocabulary/list.test.tsx +195 -0
- package/src/vocabulary/list.tsx +48 -0
- package/src/vocabulary/media.test.tsx +85 -0
- package/src/vocabulary/media.tsx +56 -0
- package/src/vocabulary/text.test.tsx +73 -0
- package/src/vocabulary/text.tsx +59 -0
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { GenerativeUILibrary } from "../types";
|
|
3
|
+
|
|
4
|
+
const columnSchema = z.object({
|
|
5
|
+
label: z.string().describe("Column header label."),
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
const cellSchema = z
|
|
9
|
+
.union([z.string(), z.number(), z.boolean()])
|
|
10
|
+
.describe("A cell value.");
|
|
11
|
+
|
|
12
|
+
const CHART_HEIGHT = 40;
|
|
13
|
+
const CHART_WIDTH = 100;
|
|
14
|
+
|
|
15
|
+
const clampValue = (value: unknown): number =>
|
|
16
|
+
typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : 0;
|
|
17
|
+
|
|
18
|
+
const yFor = (value: number, max: number): number =>
|
|
19
|
+
max > 0 ? CHART_HEIGHT - (value / max) * CHART_HEIGHT : CHART_HEIGHT;
|
|
20
|
+
|
|
21
|
+
const pointSchema = z.object({
|
|
22
|
+
label: z.string().optional().describe("Point label."),
|
|
23
|
+
value: z.number().describe("Point value."),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const seriesSchema = z.object({
|
|
27
|
+
label: z.string().optional().describe("Series label, shown in the legend."),
|
|
28
|
+
data: z.array(pointSchema).describe("Data points for this series."),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
type ChartPoint = { label?: string; value: number };
|
|
32
|
+
type ChartSeriesInput = { label?: string; data: ChartPoint[] };
|
|
33
|
+
type NormalizedChartSeries = {
|
|
34
|
+
label: string | undefined;
|
|
35
|
+
values: number[];
|
|
36
|
+
labels: (string | undefined)[];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Pads every series to the longest series' length, treating a shorter series' missing points as `{ value: 0 }` so mismatched series never misalign or throw. */
|
|
40
|
+
function normalizeSeries(
|
|
41
|
+
seriesProp: unknown,
|
|
42
|
+
dataProp: unknown,
|
|
43
|
+
): { count: number; series: NormalizedChartSeries[] } {
|
|
44
|
+
const rawSeries: ChartSeriesInput[] =
|
|
45
|
+
Array.isArray(seriesProp) && seriesProp.length > 0
|
|
46
|
+
? (seriesProp as ChartSeriesInput[])
|
|
47
|
+
: [{ data: Array.isArray(dataProp) ? (dataProp as ChartPoint[]) : [] }];
|
|
48
|
+
|
|
49
|
+
const count = rawSeries.reduce((max, s) => {
|
|
50
|
+
const len = Array.isArray(s?.data) ? s.data.length : 0;
|
|
51
|
+
return Math.max(max, len);
|
|
52
|
+
}, 0);
|
|
53
|
+
|
|
54
|
+
const series = rawSeries.map((s) => {
|
|
55
|
+
const points = Array.isArray(s?.data) ? s.data : [];
|
|
56
|
+
const values: number[] = [];
|
|
57
|
+
const labels: (string | undefined)[] = [];
|
|
58
|
+
for (let i = 0; i < count; i++) {
|
|
59
|
+
const point = points[i];
|
|
60
|
+
values.push(clampValue(point?.value));
|
|
61
|
+
labels.push(typeof point?.label === "string" ? point.label : undefined);
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
label: typeof s?.label === "string" ? s.label : undefined,
|
|
65
|
+
values,
|
|
66
|
+
labels,
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return { count, series };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function computeMax(
|
|
74
|
+
series: NormalizedChartSeries[],
|
|
75
|
+
count: number,
|
|
76
|
+
stacked: boolean,
|
|
77
|
+
): number {
|
|
78
|
+
if (stacked) {
|
|
79
|
+
let max = 0;
|
|
80
|
+
for (let i = 0; i < count; i++) {
|
|
81
|
+
let sum = 0;
|
|
82
|
+
for (const s of series) sum += s.values[i] ?? 0;
|
|
83
|
+
max = Math.max(max, sum);
|
|
84
|
+
}
|
|
85
|
+
return max;
|
|
86
|
+
}
|
|
87
|
+
let max = 0;
|
|
88
|
+
for (const s of series) for (const v of s.values) max = Math.max(max, v);
|
|
89
|
+
return max;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const NICE_MULTIPLES = [1, 2, 5];
|
|
93
|
+
|
|
94
|
+
/** The smallest of `{1, 2, 5} × 10^n` that is `>= max`, so axis ticks land on round numbers. */
|
|
95
|
+
function niceMax(max: number): number {
|
|
96
|
+
if (max <= 0) return 0;
|
|
97
|
+
const exponent = Math.floor(Math.log10(max));
|
|
98
|
+
for (const multiple of NICE_MULTIPLES) {
|
|
99
|
+
const candidate = multiple * 10 ** exponent;
|
|
100
|
+
if (candidate >= max) return candidate;
|
|
101
|
+
}
|
|
102
|
+
return 10 ** (exponent + 1);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const TICK_COUNT = 5;
|
|
106
|
+
|
|
107
|
+
/** `TICK_COUNT` evenly spaced ticks from `max` down to `0`, for a top-to-bottom y-axis column. */
|
|
108
|
+
function tickValues(max: number): number[] {
|
|
109
|
+
const ticks: number[] = [];
|
|
110
|
+
for (let i = 0; i < TICK_COUNT; i++) {
|
|
111
|
+
ticks.push((max * (TICK_COUNT - 1 - i)) / (TICK_COUNT - 1));
|
|
112
|
+
}
|
|
113
|
+
return ticks;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function formatTick(value: number): string {
|
|
117
|
+
return (Math.round(value * 100) / 100).toLocaleString("en-US");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
type ChartVariant = "bar" | "line" | "sparkline" | "area";
|
|
121
|
+
|
|
122
|
+
const SERIES_PALETTE_SIZE = 5;
|
|
123
|
+
|
|
124
|
+
function renderSeriesMarks(
|
|
125
|
+
variant: ChartVariant,
|
|
126
|
+
series: NormalizedChartSeries[],
|
|
127
|
+
count: number,
|
|
128
|
+
scaleMax: number,
|
|
129
|
+
stacked: boolean,
|
|
130
|
+
) {
|
|
131
|
+
if (variant === "bar") {
|
|
132
|
+
const slot = count > 0 ? CHART_WIDTH / count : 0;
|
|
133
|
+
const gap = slot * 0.2;
|
|
134
|
+
const groupWidth = slot - gap;
|
|
135
|
+
const seriesCount = series.length;
|
|
136
|
+
const perSeriesWidth =
|
|
137
|
+
stacked || seriesCount <= 1 ? groupWidth : groupWidth / seriesCount;
|
|
138
|
+
const cumulative = new Array(count).fill(0) as number[];
|
|
139
|
+
|
|
140
|
+
return series.map((s, seriesIndex) => {
|
|
141
|
+
const marks = s.values.map((value, i) => {
|
|
142
|
+
const height = scaleMax > 0 ? (value / scaleMax) * CHART_HEIGHT : 0;
|
|
143
|
+
const belowHeight =
|
|
144
|
+
stacked && scaleMax > 0
|
|
145
|
+
? ((cumulative[i] ?? 0) / scaleMax) * CHART_HEIGHT
|
|
146
|
+
: 0;
|
|
147
|
+
const x =
|
|
148
|
+
stacked || seriesCount <= 1
|
|
149
|
+
? i * slot + gap / 2
|
|
150
|
+
: i * slot + gap / 2 + seriesIndex * perSeriesWidth;
|
|
151
|
+
return (
|
|
152
|
+
<rect
|
|
153
|
+
key={i}
|
|
154
|
+
x={x}
|
|
155
|
+
y={CHART_HEIGHT - belowHeight - height}
|
|
156
|
+
width={perSeriesWidth}
|
|
157
|
+
height={height}
|
|
158
|
+
fill="currentColor"
|
|
159
|
+
/>
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
if (stacked) {
|
|
163
|
+
for (let i = 0; i < count; i++) {
|
|
164
|
+
cumulative[i] = (cumulative[i] ?? 0) + (s.values[i] ?? 0);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return (
|
|
168
|
+
<g
|
|
169
|
+
key={seriesIndex}
|
|
170
|
+
data-aui="chart-series"
|
|
171
|
+
data-aui-series={seriesIndex % SERIES_PALETTE_SIZE}
|
|
172
|
+
>
|
|
173
|
+
{marks}
|
|
174
|
+
</g>
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (variant === "area") {
|
|
180
|
+
const cumulative = new Array(count).fill(0) as number[];
|
|
181
|
+
|
|
182
|
+
return series.map((s, seriesIndex) => {
|
|
183
|
+
const bottoms = stacked
|
|
184
|
+
? cumulative.slice()
|
|
185
|
+
: (new Array(count).fill(0) as number[]);
|
|
186
|
+
if (stacked) {
|
|
187
|
+
for (let i = 0; i < count; i++) {
|
|
188
|
+
cumulative[i] = (cumulative[i] ?? 0) + (s.values[i] ?? 0);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const tops = stacked ? cumulative.slice() : s.values.slice();
|
|
192
|
+
|
|
193
|
+
if (count === 1) {
|
|
194
|
+
const y = yFor(tops[0] ?? 0, scaleMax);
|
|
195
|
+
return (
|
|
196
|
+
<g
|
|
197
|
+
key={seriesIndex}
|
|
198
|
+
data-aui="chart-series"
|
|
199
|
+
data-aui-series={seriesIndex % SERIES_PALETTE_SIZE}
|
|
200
|
+
>
|
|
201
|
+
<circle cx={CHART_WIDTH / 2} cy={y} r={2} fill="currentColor" />
|
|
202
|
+
</g>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
if (count === 0) {
|
|
206
|
+
return (
|
|
207
|
+
<g
|
|
208
|
+
key={seriesIndex}
|
|
209
|
+
data-aui="chart-series"
|
|
210
|
+
data-aui-series={seriesIndex % SERIES_PALETTE_SIZE}
|
|
211
|
+
/>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const topPoints = tops.map(
|
|
216
|
+
(v, i) => `${(i / (count - 1)) * CHART_WIDTH},${yFor(v, scaleMax)}`,
|
|
217
|
+
);
|
|
218
|
+
const bottomPoints = bottoms
|
|
219
|
+
.map(
|
|
220
|
+
(v, i) => `${(i / (count - 1)) * CHART_WIDTH},${yFor(v, scaleMax)}`,
|
|
221
|
+
)
|
|
222
|
+
.reverse();
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<g
|
|
226
|
+
key={seriesIndex}
|
|
227
|
+
data-aui="chart-series"
|
|
228
|
+
data-aui-series={seriesIndex % SERIES_PALETTE_SIZE}
|
|
229
|
+
>
|
|
230
|
+
<polygon
|
|
231
|
+
points={[...topPoints, ...bottomPoints].join(" ")}
|
|
232
|
+
fill="currentColor"
|
|
233
|
+
fillOpacity="0.25"
|
|
234
|
+
stroke="currentColor"
|
|
235
|
+
/>
|
|
236
|
+
</g>
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// line / sparkline: each series is always drawn independently. Stacking a line has no single standard meaning, so `stacked` is ignored (the caller never passes it for these variants).
|
|
242
|
+
return series.map((s, seriesIndex) => (
|
|
243
|
+
<g
|
|
244
|
+
key={seriesIndex}
|
|
245
|
+
data-aui="chart-series"
|
|
246
|
+
data-aui-series={seriesIndex % SERIES_PALETTE_SIZE}
|
|
247
|
+
>
|
|
248
|
+
{count === 1 ? (
|
|
249
|
+
<circle
|
|
250
|
+
cx={CHART_WIDTH / 2}
|
|
251
|
+
cy={yFor(s.values[0] ?? 0, scaleMax)}
|
|
252
|
+
r={2}
|
|
253
|
+
fill="currentColor"
|
|
254
|
+
/>
|
|
255
|
+
) : count > 1 ? (
|
|
256
|
+
<polyline
|
|
257
|
+
points={s.values
|
|
258
|
+
.map(
|
|
259
|
+
(v, i) =>
|
|
260
|
+
`${(i / (count - 1)) * CHART_WIDTH},${yFor(v, scaleMax)}`,
|
|
261
|
+
)
|
|
262
|
+
.join(" ")}
|
|
263
|
+
fill="none"
|
|
264
|
+
stroke="currentColor"
|
|
265
|
+
/>
|
|
266
|
+
) : null}
|
|
267
|
+
</g>
|
|
268
|
+
));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export const dataVocabulary = {
|
|
272
|
+
Table: {
|
|
273
|
+
description:
|
|
274
|
+
"Tabular data. Provide `columns` and `rows`; each row is an array of cells matching the columns.",
|
|
275
|
+
properties: z.object({
|
|
276
|
+
columns: z.array(columnSchema).optional().describe("Column definitions."),
|
|
277
|
+
rows: z.array(z.array(cellSchema)).optional().describe("Rows of cells."),
|
|
278
|
+
}),
|
|
279
|
+
render: ({ columns, rows, children }) => (
|
|
280
|
+
<table data-aui="table">
|
|
281
|
+
{columns?.length ? (
|
|
282
|
+
<thead>
|
|
283
|
+
<tr>
|
|
284
|
+
{columns.map((c: { label: string }, i: number) => (
|
|
285
|
+
<th key={i} data-aui="table-col">
|
|
286
|
+
{c.label}
|
|
287
|
+
</th>
|
|
288
|
+
))}
|
|
289
|
+
</tr>
|
|
290
|
+
</thead>
|
|
291
|
+
) : null}
|
|
292
|
+
{rows?.length ? (
|
|
293
|
+
<tbody>
|
|
294
|
+
{rows.map((row: (string | number | boolean)[], r: number) => (
|
|
295
|
+
<tr key={r}>
|
|
296
|
+
{row.map((cell: string | number | boolean, c: number) => (
|
|
297
|
+
<td key={c}>{String(cell)}</td>
|
|
298
|
+
))}
|
|
299
|
+
</tr>
|
|
300
|
+
))}
|
|
301
|
+
</tbody>
|
|
302
|
+
) : null}
|
|
303
|
+
{children}
|
|
304
|
+
</table>
|
|
305
|
+
),
|
|
306
|
+
},
|
|
307
|
+
Markdown: {
|
|
308
|
+
description:
|
|
309
|
+
"A markdown string. Rendered as-is by default; override this component for a full markdown renderer.",
|
|
310
|
+
properties: z.object({
|
|
311
|
+
value: z.string().describe("Markdown source."),
|
|
312
|
+
}),
|
|
313
|
+
streamProperties: true,
|
|
314
|
+
render: ({ value, children }) => (
|
|
315
|
+
<div data-aui="markdown">
|
|
316
|
+
{value}
|
|
317
|
+
{children}
|
|
318
|
+
</div>
|
|
319
|
+
),
|
|
320
|
+
},
|
|
321
|
+
Chart: {
|
|
322
|
+
description:
|
|
323
|
+
"A chart. `variant` selects bar, line, sparkline, or area rendering. Provide `data` for a single series, or `series` for multiple named series (`series` takes precedence over `data` when both are present). Set `stacked` to accumulate series values instead of overlaying them, and `showAxis`/`showLegend` to add y-axis ticks, x-axis point labels, and a legend.",
|
|
324
|
+
properties: z.object({
|
|
325
|
+
variant: z
|
|
326
|
+
.enum(["bar", "line", "sparkline", "area"])
|
|
327
|
+
.describe("Chart variant."),
|
|
328
|
+
data: z
|
|
329
|
+
.array(pointSchema)
|
|
330
|
+
.optional()
|
|
331
|
+
.describe("Data points for a single series."),
|
|
332
|
+
series: z
|
|
333
|
+
.array(seriesSchema)
|
|
334
|
+
.optional()
|
|
335
|
+
.describe(
|
|
336
|
+
"Multiple named series; takes precedence over `data` when present.",
|
|
337
|
+
),
|
|
338
|
+
stacked: z
|
|
339
|
+
.boolean()
|
|
340
|
+
.optional()
|
|
341
|
+
.describe(
|
|
342
|
+
"Accumulate series values instead of overlaying them (bar and area variants; ignored for line and sparkline).",
|
|
343
|
+
),
|
|
344
|
+
showAxis: z
|
|
345
|
+
.boolean()
|
|
346
|
+
.optional()
|
|
347
|
+
.describe("Show y-axis ticks and x-axis point labels."),
|
|
348
|
+
showLegend: z
|
|
349
|
+
.boolean()
|
|
350
|
+
.optional()
|
|
351
|
+
.describe("Show a legend mapping each series to its color."),
|
|
352
|
+
color: z
|
|
353
|
+
.string()
|
|
354
|
+
.optional()
|
|
355
|
+
.describe(
|
|
356
|
+
"Series color (single-series mode only). One of `emphasis`, `secondary`, `alpha-70`, `white`, `white-70`, or `white-50`, matching Text's `color` tokens; other values have no visual effect.",
|
|
357
|
+
),
|
|
358
|
+
}),
|
|
359
|
+
render: ({
|
|
360
|
+
variant,
|
|
361
|
+
data,
|
|
362
|
+
series,
|
|
363
|
+
stacked,
|
|
364
|
+
showAxis,
|
|
365
|
+
showLegend,
|
|
366
|
+
color,
|
|
367
|
+
}) => {
|
|
368
|
+
const usesExtendedFeatures =
|
|
369
|
+
variant === "area" ||
|
|
370
|
+
!!stacked ||
|
|
371
|
+
!!showAxis ||
|
|
372
|
+
!!showLegend ||
|
|
373
|
+
(Array.isArray(series) && series.length > 0);
|
|
374
|
+
|
|
375
|
+
if (!usesExtendedFeatures) {
|
|
376
|
+
const points = Array.isArray(data) ? data : [];
|
|
377
|
+
const n = points.length;
|
|
378
|
+
const values = points.map((d) => clampValue(d?.value));
|
|
379
|
+
const max = values.reduce((m, v) => Math.max(m, v), 0);
|
|
380
|
+
const slot = n > 0 ? CHART_WIDTH / n : 0;
|
|
381
|
+
const gap = slot * 0.2;
|
|
382
|
+
const barWidth = slot - gap;
|
|
383
|
+
|
|
384
|
+
return (
|
|
385
|
+
<svg
|
|
386
|
+
data-aui="chart"
|
|
387
|
+
data-aui-variant={variant}
|
|
388
|
+
data-aui-color={color}
|
|
389
|
+
role="img"
|
|
390
|
+
aria-label={`${variant} chart with ${n} data points`}
|
|
391
|
+
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
|
|
392
|
+
preserveAspectRatio="none"
|
|
393
|
+
>
|
|
394
|
+
{variant === "bar" ? (
|
|
395
|
+
values.map((v, i) => {
|
|
396
|
+
const height = max > 0 ? (v / max) * CHART_HEIGHT : 0;
|
|
397
|
+
return (
|
|
398
|
+
<rect
|
|
399
|
+
key={i}
|
|
400
|
+
x={i * slot + gap / 2}
|
|
401
|
+
y={CHART_HEIGHT - height}
|
|
402
|
+
width={barWidth}
|
|
403
|
+
height={height}
|
|
404
|
+
fill="currentColor"
|
|
405
|
+
/>
|
|
406
|
+
);
|
|
407
|
+
})
|
|
408
|
+
) : n === 1 ? (
|
|
409
|
+
<circle
|
|
410
|
+
cx={CHART_WIDTH / 2}
|
|
411
|
+
cy={yFor(values[0] ?? 0, max)}
|
|
412
|
+
r={2}
|
|
413
|
+
fill="currentColor"
|
|
414
|
+
/>
|
|
415
|
+
) : n > 1 ? (
|
|
416
|
+
<polyline
|
|
417
|
+
points={values
|
|
418
|
+
.map(
|
|
419
|
+
(v, i) => `${(i / (n - 1)) * CHART_WIDTH},${yFor(v, max)}`,
|
|
420
|
+
)
|
|
421
|
+
.join(" ")}
|
|
422
|
+
fill="none"
|
|
423
|
+
stroke="currentColor"
|
|
424
|
+
/>
|
|
425
|
+
) : null}
|
|
426
|
+
</svg>
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const { count, series: normalized } = normalizeSeries(series, data);
|
|
431
|
+
const stackedScale =
|
|
432
|
+
!!stacked && (variant === "bar" || variant === "area");
|
|
433
|
+
const rawMax = computeMax(normalized, count, stackedScale);
|
|
434
|
+
const scaleMax = showAxis ? niceMax(rawMax) : rawMax;
|
|
435
|
+
const marks = renderSeriesMarks(
|
|
436
|
+
variant,
|
|
437
|
+
normalized,
|
|
438
|
+
count,
|
|
439
|
+
scaleMax,
|
|
440
|
+
stackedScale,
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
const svg = (
|
|
444
|
+
<svg
|
|
445
|
+
data-aui="chart"
|
|
446
|
+
data-aui-variant={variant}
|
|
447
|
+
data-aui-color={color}
|
|
448
|
+
role="img"
|
|
449
|
+
aria-label={`${variant} chart with ${count} data points`}
|
|
450
|
+
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
|
|
451
|
+
preserveAspectRatio="none"
|
|
452
|
+
>
|
|
453
|
+
{marks}
|
|
454
|
+
</svg>
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
if (!showAxis && !showLegend) return svg;
|
|
458
|
+
|
|
459
|
+
return (
|
|
460
|
+
<div data-aui="chart-frame">
|
|
461
|
+
{showAxis ? (
|
|
462
|
+
<div data-aui="chart-ticks">
|
|
463
|
+
{tickValues(scaleMax).map((tick, i) => (
|
|
464
|
+
<div key={i}>{formatTick(tick)}</div>
|
|
465
|
+
))}
|
|
466
|
+
</div>
|
|
467
|
+
) : null}
|
|
468
|
+
{svg}
|
|
469
|
+
{showAxis ? (
|
|
470
|
+
<div data-aui="chart-xlabels">
|
|
471
|
+
{(normalized[0]?.labels ?? []).map((label, i) => (
|
|
472
|
+
<div key={i}>{label ?? ""}</div>
|
|
473
|
+
))}
|
|
474
|
+
</div>
|
|
475
|
+
) : null}
|
|
476
|
+
{showLegend ? (
|
|
477
|
+
<div data-aui="chart-legend">
|
|
478
|
+
{normalized.map((s, i) => (
|
|
479
|
+
<span
|
|
480
|
+
key={i}
|
|
481
|
+
data-aui="chart-legend-item"
|
|
482
|
+
data-aui-series={i % SERIES_PALETTE_SIZE}
|
|
483
|
+
>
|
|
484
|
+
<span data-aui="chart-legend-swatch" />
|
|
485
|
+
{s.label ?? `Series ${i + 1}`}
|
|
486
|
+
</span>
|
|
487
|
+
))}
|
|
488
|
+
</div>
|
|
489
|
+
) : null}
|
|
490
|
+
</div>
|
|
491
|
+
);
|
|
492
|
+
},
|
|
493
|
+
},
|
|
494
|
+
} satisfies GenerativeUILibrary;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Action } from "../ir";
|
|
2
|
+
import type { GenerativeUIDispatch } from "../types";
|
|
3
|
+
|
|
4
|
+
export const actionAttr = (a: Action | undefined): string | undefined =>
|
|
5
|
+
a ? JSON.stringify(a) : undefined;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Fires `$action` through `$dispatch` when both are present, merging a runtime value into the payload under the reserved `$input` key (not `value`) so the user's input never clobbers a model-supplied `value` field. No-op when no registry is wired. The returned promise from an async handler is caught and re-thrown on a microtask so rejections surface rather than going unhandled.
|
|
9
|
+
*/
|
|
10
|
+
export const fire = (
|
|
11
|
+
$action: Action | undefined,
|
|
12
|
+
$dispatch: GenerativeUIDispatch | undefined,
|
|
13
|
+
input?: unknown,
|
|
14
|
+
) => {
|
|
15
|
+
if (!$action || !$dispatch) return;
|
|
16
|
+
const payload = input === undefined ? $action : { ...$action, $input: input };
|
|
17
|
+
void Promise.resolve($dispatch(payload)).catch((error) => {
|
|
18
|
+
queueMicrotask(() => {
|
|
19
|
+
throw error;
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { renderGenerativeUI } from "../renderGenerativeUI";
|
|
4
|
+
import { factVocabulary } from "./fact";
|
|
5
|
+
|
|
6
|
+
const render = (node: unknown) =>
|
|
7
|
+
renderToStaticMarkup(<>{renderGenerativeUI(node, factVocabulary)}</>);
|
|
8
|
+
|
|
9
|
+
describe("factVocabulary", () => {
|
|
10
|
+
it("Fact renders a label/value pair inside a dl", () => {
|
|
11
|
+
expect(render({ $type: "Fact", label: "Status", value: "open" })).toBe(
|
|
12
|
+
'<dl data-aui="fact"><dt data-aui="fact-label">Status</dt><dd data-aui="fact-value">open</dd></dl>',
|
|
13
|
+
);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { GenerativeUILibrary } from "../types";
|
|
3
|
+
|
|
4
|
+
export const factVocabulary = {
|
|
5
|
+
Fact: {
|
|
6
|
+
description:
|
|
7
|
+
"A label/value pair, rendered as a key followed by its value. Use for compact metadata.",
|
|
8
|
+
properties: z.object({
|
|
9
|
+
label: z.string().describe("The fact label (key)."),
|
|
10
|
+
value: z.string().describe("The fact value."),
|
|
11
|
+
}),
|
|
12
|
+
render: ({ label, value, children }) => (
|
|
13
|
+
<dl data-aui="fact">
|
|
14
|
+
<dt data-aui="fact-label">{label}</dt>
|
|
15
|
+
<dd data-aui="fact-value">
|
|
16
|
+
{value}
|
|
17
|
+
{children}
|
|
18
|
+
</dd>
|
|
19
|
+
</dl>
|
|
20
|
+
),
|
|
21
|
+
},
|
|
22
|
+
} satisfies GenerativeUILibrary;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import type { ReactElement } from "react";
|
|
3
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
4
|
+
import { renderGenerativeUI } from "../renderGenerativeUI";
|
|
5
|
+
import { createActionRegistry } from "../actionRegistry";
|
|
6
|
+
import { formVocabulary } from "./form";
|
|
7
|
+
import type { FormControlElementLike } from "./collectFormValues";
|
|
8
|
+
|
|
9
|
+
const render = (node: unknown) =>
|
|
10
|
+
renderToStaticMarkup(<>{renderGenerativeUI(node, formVocabulary)}</>);
|
|
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 fakeSubmitEvent = (elements: FormControlElementLike[]) => ({
|
|
23
|
+
preventDefault: vi.fn(),
|
|
24
|
+
currentTarget: { elements },
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("formVocabulary", () => {
|
|
28
|
+
it("Form renders a form element with children and a gap hook", () => {
|
|
29
|
+
const html = render({
|
|
30
|
+
$type: "Form",
|
|
31
|
+
gap: 2,
|
|
32
|
+
children: { $type: "Fact", label: "a", value: "b" } as never,
|
|
33
|
+
});
|
|
34
|
+
expect(html).toContain('<form data-aui="form" data-aui-gap="2">');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("Form without gap or $action renders bare", () => {
|
|
38
|
+
expect(render({ $type: "Form" })).toBe('<form data-aui="form"></form>');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("Form onSubmit prevents default and dispatches collected values keyed by name", () => {
|
|
42
|
+
const handler = vi.fn();
|
|
43
|
+
const registry = createActionRegistry({ save: handler });
|
|
44
|
+
const out = formVocabulary.Form.render({
|
|
45
|
+
$status: "done",
|
|
46
|
+
$action: { type: "save" },
|
|
47
|
+
$dispatch: registry.dispatch,
|
|
48
|
+
}) as ReactElement;
|
|
49
|
+
const onSubmit = (out.props as { onSubmit: (e: unknown) => void }).onSubmit;
|
|
50
|
+
const event = fakeSubmitEvent([
|
|
51
|
+
el({ name: "email", type: "email", value: "a@example.com" }),
|
|
52
|
+
el({ name: "agree", type: "checkbox", checked: true }),
|
|
53
|
+
el({ name: "size", type: "radio", value: "sm", checked: false }),
|
|
54
|
+
el({ name: "size", type: "radio", value: "lg", checked: true }),
|
|
55
|
+
el({ name: "", type: "text", value: "ignored" }),
|
|
56
|
+
el({ name: "off", type: "text", value: "ignored", disabled: true }),
|
|
57
|
+
]);
|
|
58
|
+
onSubmit(event);
|
|
59
|
+
expect(event.preventDefault).toHaveBeenCalled();
|
|
60
|
+
expect(handler).toHaveBeenCalledWith({
|
|
61
|
+
payload: {
|
|
62
|
+
type: "save",
|
|
63
|
+
$input: { email: "a@example.com", agree: true, size: "lg" },
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("Form onSubmit collects repeated non-radio names into an array", () => {
|
|
69
|
+
const handler = vi.fn();
|
|
70
|
+
const registry = createActionRegistry({ save: handler });
|
|
71
|
+
const out = formVocabulary.Form.render({
|
|
72
|
+
$status: "done",
|
|
73
|
+
$action: { type: "save" },
|
|
74
|
+
$dispatch: registry.dispatch,
|
|
75
|
+
}) as ReactElement;
|
|
76
|
+
const onSubmit = (out.props as { onSubmit: (e: unknown) => void }).onSubmit;
|
|
77
|
+
onSubmit(
|
|
78
|
+
fakeSubmitEvent([
|
|
79
|
+
el({ name: "tag", type: "text", value: "a" }),
|
|
80
|
+
el({ name: "tag", type: "text", value: "b" }),
|
|
81
|
+
]),
|
|
82
|
+
);
|
|
83
|
+
expect(handler).toHaveBeenCalledWith({
|
|
84
|
+
payload: { type: "save", $input: { tag: ["a", "b"] } },
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("Form onSubmit is silent (but still prevents default) when no $action is wired", () => {
|
|
89
|
+
const out = formVocabulary.Form.render({
|
|
90
|
+
$status: "done",
|
|
91
|
+
}) as ReactElement;
|
|
92
|
+
const onSubmit = (out.props as { onSubmit: (e: unknown) => void }).onSubmit;
|
|
93
|
+
const event = fakeSubmitEvent([
|
|
94
|
+
el({ name: "x", type: "text", value: "y" }),
|
|
95
|
+
]);
|
|
96
|
+
expect(() => onSubmit(event)).not.toThrow();
|
|
97
|
+
expect(event.preventDefault).toHaveBeenCalled();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("Form onSubmit is silent (but still prevents default) when $action is present but no $dispatch is wired", () => {
|
|
101
|
+
const out = formVocabulary.Form.render({
|
|
102
|
+
$status: "done",
|
|
103
|
+
$action: { type: "save" },
|
|
104
|
+
}) as ReactElement;
|
|
105
|
+
const onSubmit = (out.props as { onSubmit: (e: unknown) => void }).onSubmit;
|
|
106
|
+
const event = fakeSubmitEvent([
|
|
107
|
+
el({ name: "x", type: "text", value: "y" }),
|
|
108
|
+
]);
|
|
109
|
+
expect(() => onSubmit(event)).not.toThrow();
|
|
110
|
+
expect(event.preventDefault).toHaveBeenCalled();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { GenerativeUILibrary } from "../types";
|
|
3
|
+
import { actionAttr, fire } from "./dispatch";
|
|
4
|
+
import { collectFormValuesFromEvent } from "./collectFormValues";
|
|
5
|
+
|
|
6
|
+
export const formVocabulary = {
|
|
7
|
+
Form: {
|
|
8
|
+
description:
|
|
9
|
+
"Wraps named child controls (Select/Input/Checkbox/RadioGroup/DatePicker with a `name`). Carries `$action`; on submit it fires with every named control's value, keyed by `name`.",
|
|
10
|
+
properties: z.object({
|
|
11
|
+
gap: z
|
|
12
|
+
.number()
|
|
13
|
+
.min(0)
|
|
14
|
+
.max(8)
|
|
15
|
+
.optional()
|
|
16
|
+
.describe(
|
|
17
|
+
"Gap between children in 4px units. 0 to 8 is the supported range.",
|
|
18
|
+
),
|
|
19
|
+
}),
|
|
20
|
+
render: ({ gap, $action, $dispatch, children }) => (
|
|
21
|
+
<form
|
|
22
|
+
data-aui="form"
|
|
23
|
+
data-aui-gap={gap}
|
|
24
|
+
data-aui-action={actionAttr($action)}
|
|
25
|
+
onSubmit={(event) => {
|
|
26
|
+
event.preventDefault();
|
|
27
|
+
fire($action, $dispatch, collectFormValuesFromEvent(event));
|
|
28
|
+
}}
|
|
29
|
+
>
|
|
30
|
+
{children}
|
|
31
|
+
</form>
|
|
32
|
+
),
|
|
33
|
+
},
|
|
34
|
+
} satisfies GenerativeUILibrary;
|