cortena-ui 1.1.0 → 1.2.0
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/README.md +51 -0
- package/dist/index.d.ts +437 -16
- package/dist/index.js +4327 -2066
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/a2ui/catalogue-doc.ts +218 -0
- package/src/a2ui/catalogue.ts +950 -0
- package/src/a2ui/index.ts +73 -0
- package/src/a2ui/message.ts +590 -0
- package/src/a2ui/renderer.tsx +438 -0
- package/src/a2ui/views.tsx +1113 -0
- package/src/hooks/use-cortena-theme.ts +12 -4
- package/src/index.ts +2 -0
|
@@ -0,0 +1,1113 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The cortena-ui side of the A2UI catalogue.
|
|
5
|
+
*
|
|
6
|
+
* One React view per catalogue entry. Each is a real component, not a render
|
|
7
|
+
* function, so an interactive entry can hold hooks (draft input state, the
|
|
8
|
+
* debounce timer) without the renderer having to care which entries are
|
|
9
|
+
* stateful.
|
|
10
|
+
*
|
|
11
|
+
* Views receive already-resolved props: the renderer has read the data
|
|
12
|
+
* bindings, validated against the entry's zod schema and normalised actions
|
|
13
|
+
* before any of this runs. A view therefore never parses A2UI, and never sees
|
|
14
|
+
* an unvalidated value.
|
|
15
|
+
*
|
|
16
|
+
* Every visual value is a cortena-design token, as everywhere else in the
|
|
17
|
+
* package. The Tailwind scale maps below exist because Tailwind only emits a
|
|
18
|
+
* class it can see in the source, so `gap-${n}` would silently produce nothing.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { AlertTriangle, CheckCircle2, Info, OctagonAlert, Terminal } from "lucide-react";
|
|
22
|
+
import * as React from "react";
|
|
23
|
+
import { Alert, AlertDescription, AlertIcon, AlertTitle } from "@/components/alert";
|
|
24
|
+
import { Badge } from "@/components/badge";
|
|
25
|
+
import { Button } from "@/components/button";
|
|
26
|
+
import { ButtonLink } from "@/components/button-link";
|
|
27
|
+
import { Card, CardContent } from "@/components/card";
|
|
28
|
+
import { Chart } from "@/components/chart";
|
|
29
|
+
import type { ChartDataFor, ChartType } from "@/components/chart";
|
|
30
|
+
import { Checkbox } from "@/components/checkbox";
|
|
31
|
+
import { DataTable } from "@/components/data-table";
|
|
32
|
+
import type { DataSource, DataTableColumnDef } from "@/components/data-table";
|
|
33
|
+
import { DateField, formatDateISO, parseDateISO } from "@/components/date-field";
|
|
34
|
+
import { EmptyState } from "@/components/empty-state";
|
|
35
|
+
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/field";
|
|
36
|
+
import { Input } from "@/components/input";
|
|
37
|
+
import { Markdown } from "@/components/markdown";
|
|
38
|
+
import { Progress, ProgressLabel, ProgressValue } from "@/components/progress";
|
|
39
|
+
import { RadioGroup, RadioGroupItem } from "@/components/radio-group";
|
|
40
|
+
import { SectionCard } from "@/components/section-card";
|
|
41
|
+
import {
|
|
42
|
+
Select,
|
|
43
|
+
SelectContent,
|
|
44
|
+
SelectItem,
|
|
45
|
+
SelectTrigger,
|
|
46
|
+
SelectValue,
|
|
47
|
+
} from "@/components/select";
|
|
48
|
+
import { Separator } from "@/components/separator";
|
|
49
|
+
import { StatusDot } from "@/components/status-dot";
|
|
50
|
+
import { Switch } from "@/components/switch";
|
|
51
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/tabs";
|
|
52
|
+
import { Textarea } from "@/components/textarea";
|
|
53
|
+
import { cn } from "@/lib/cn";
|
|
54
|
+
import type { A2UIAction } from "./message";
|
|
55
|
+
|
|
56
|
+
/** A row in an agent-drawn table. Every value is opaque until a column reads it. */
|
|
57
|
+
export type A2UIRow = Record<string, unknown>;
|
|
58
|
+
|
|
59
|
+
/** Where a node sits, carried into every action the view emits. */
|
|
60
|
+
export interface A2UINodeInfo {
|
|
61
|
+
surfaceId: string;
|
|
62
|
+
componentId: string;
|
|
63
|
+
/** JSON pointer this node's relative bindings resolve against. */
|
|
64
|
+
dataContextPath: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A normalised action attached to an interactive property. */
|
|
68
|
+
export interface A2UIResolvedAction {
|
|
69
|
+
name: string;
|
|
70
|
+
context?: Record<string, unknown>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** What every view receives. */
|
|
74
|
+
export interface A2UIViewProps<P> {
|
|
75
|
+
props: P;
|
|
76
|
+
node: A2UINodeInfo;
|
|
77
|
+
/** Already-rendered child nodes, in the order the component listed them. */
|
|
78
|
+
children: React.ReactNode[];
|
|
79
|
+
/** The surface data model, for actions that report the whole form. */
|
|
80
|
+
dataModel: Record<string, unknown>;
|
|
81
|
+
/**
|
|
82
|
+
* Send an action. `action` is what the agent attached to the property; when
|
|
83
|
+
* it is absent the event still fires under `fallbackName` so a host can
|
|
84
|
+
* observe interactions the agent did not ask for.
|
|
85
|
+
*/
|
|
86
|
+
emit: (
|
|
87
|
+
action: A2UIResolvedAction | undefined,
|
|
88
|
+
fallbackName: string,
|
|
89
|
+
payload?: Record<string, unknown>,
|
|
90
|
+
) => void;
|
|
91
|
+
/** Write a value back to the data model where this prop was bound. No-op for a literal. */
|
|
92
|
+
writeBack: (property: string, value: unknown) => void;
|
|
93
|
+
/** Resolve a `{ kind: "server", sourceId }` handle into a real data source. */
|
|
94
|
+
resolveDataSource: (sourceId: string) => DataSource<A2UIRow> | undefined;
|
|
95
|
+
/** Debounce applied to continuous inputs before they become actions. */
|
|
96
|
+
debounceMs: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type A2UIView<P> = React.FC<A2UIViewProps<P>>;
|
|
100
|
+
|
|
101
|
+
/* ------------------------------------------------------------------ */
|
|
102
|
+
/* Shared scales */
|
|
103
|
+
/* ------------------------------------------------------------------ */
|
|
104
|
+
|
|
105
|
+
/** Tailwind spacing steps an agent may pick. Written out so Tailwind emits them. */
|
|
106
|
+
const GAP: Record<number, string> = {
|
|
107
|
+
0: "gap-0",
|
|
108
|
+
1: "gap-1",
|
|
109
|
+
2: "gap-2",
|
|
110
|
+
3: "gap-3",
|
|
111
|
+
4: "gap-4",
|
|
112
|
+
6: "gap-6",
|
|
113
|
+
8: "gap-8",
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const PADDING: Record<number, string> = {
|
|
117
|
+
0: "p-0",
|
|
118
|
+
1: "p-1",
|
|
119
|
+
2: "p-2",
|
|
120
|
+
3: "p-3",
|
|
121
|
+
4: "p-4",
|
|
122
|
+
6: "p-6",
|
|
123
|
+
8: "p-8",
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const HEIGHT: Record<number, string> = {
|
|
127
|
+
0: "h-0",
|
|
128
|
+
1: "h-1",
|
|
129
|
+
2: "h-2",
|
|
130
|
+
3: "h-3",
|
|
131
|
+
4: "h-4",
|
|
132
|
+
6: "h-6",
|
|
133
|
+
8: "h-8",
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const ALIGN: Record<string, string> = {
|
|
137
|
+
start: "items-start",
|
|
138
|
+
center: "items-center",
|
|
139
|
+
end: "items-end",
|
|
140
|
+
stretch: "items-stretch",
|
|
141
|
+
baseline: "items-baseline",
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const JUSTIFY: Record<string, string> = {
|
|
145
|
+
start: "justify-start",
|
|
146
|
+
center: "justify-center",
|
|
147
|
+
end: "justify-end",
|
|
148
|
+
between: "justify-between",
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const TEXT_ALIGN: Record<string, string> = {
|
|
152
|
+
start: "text-left",
|
|
153
|
+
center: "text-center",
|
|
154
|
+
end: "text-right",
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const TEXT_ROLE: Record<string, string> = {
|
|
158
|
+
heading:
|
|
159
|
+
"font-display text-[length:var(--ds-text-h3)] leading-[var(--ds-text-h3--line-height)] font-semibold",
|
|
160
|
+
subheading:
|
|
161
|
+
"text-[length:var(--ds-text-body-md)] leading-[var(--ds-text-body-md--line-height)] font-semibold",
|
|
162
|
+
body: "text-[length:var(--ds-text-body-sm)] leading-[var(--ds-text-body-sm--line-height)]",
|
|
163
|
+
caption: "text-[length:var(--ds-text-caption)] leading-[var(--ds-text-caption--line-height)]",
|
|
164
|
+
eyebrow:
|
|
165
|
+
"text-[length:var(--ds-text-eyebrow)] leading-[var(--ds-text-eyebrow--line-height)] font-medium uppercase tracking-[var(--ds-text-eyebrow--letter-spacing)]",
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const TEXT_TONE: Record<string, string> = {
|
|
169
|
+
default: "text-[color:var(--ds-foreground)]",
|
|
170
|
+
muted: "text-[color:var(--ds-muted-foreground)]",
|
|
171
|
+
success: "text-[color:var(--ds-success)]",
|
|
172
|
+
warning: "text-[color:var(--ds-warning)]",
|
|
173
|
+
danger: "text-[color:var(--ds-destructive)]",
|
|
174
|
+
info: "text-[color:var(--ds-info)]",
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Components that are inline by nature.
|
|
179
|
+
*
|
|
180
|
+
* A Column stretches its children on the cross axis, which is right for a field
|
|
181
|
+
* or a table and wrong for a badge — one would span the whole surface. `w-fit`
|
|
182
|
+
* gives the item an explicit cross size, which is what disables the stretch,
|
|
183
|
+
* without overriding a Row's own alignment.
|
|
184
|
+
*/
|
|
185
|
+
const INLINE = "w-fit";
|
|
186
|
+
|
|
187
|
+
const ALERT_ICON: Record<string, React.ReactNode> = {
|
|
188
|
+
default: <Terminal />,
|
|
189
|
+
success: <CheckCircle2 />,
|
|
190
|
+
warn: <AlertTriangle />,
|
|
191
|
+
danger: <OctagonAlert />,
|
|
192
|
+
info: <Info />,
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Debounce a value. Used for continuous inputs so an agent receives one action
|
|
197
|
+
* per pause rather than one per keystroke; the visible input stays immediate.
|
|
198
|
+
*/
|
|
199
|
+
function useDebouncedEmit(
|
|
200
|
+
emit: (value: unknown) => void,
|
|
201
|
+
delay: number,
|
|
202
|
+
): (value: unknown) => void {
|
|
203
|
+
const latest = React.useRef(emit);
|
|
204
|
+
latest.current = emit;
|
|
205
|
+
const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
206
|
+
React.useEffect(
|
|
207
|
+
() => () => {
|
|
208
|
+
if (timer.current !== undefined) {
|
|
209
|
+
clearTimeout(timer.current);
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
[],
|
|
213
|
+
);
|
|
214
|
+
return React.useCallback(
|
|
215
|
+
(value: unknown) => {
|
|
216
|
+
if (timer.current !== undefined) {
|
|
217
|
+
clearTimeout(timer.current);
|
|
218
|
+
}
|
|
219
|
+
timer.current = setTimeout(() => latest.current(value), delay);
|
|
220
|
+
},
|
|
221
|
+
[delay],
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* A value that follows the data model but can be typed over.
|
|
227
|
+
*
|
|
228
|
+
* The bound value wins whenever the agent changes it; between agent updates the
|
|
229
|
+
* local draft wins, so typing is not swallowed by a re-render.
|
|
230
|
+
*
|
|
231
|
+
* `key` is what identity is judged on, and defaults to the value itself. It
|
|
232
|
+
* matters for a value the renderer rebuilds each pass — a Date parsed from an
|
|
233
|
+
* ISO string is a new object every render, so comparing the objects would reset
|
|
234
|
+
* the draft forever. Compare the string instead.
|
|
235
|
+
*/
|
|
236
|
+
function useDraft<T>(bound: T, key: unknown = bound): [T, (next: T) => void] {
|
|
237
|
+
const [draft, setDraft] = React.useState<T>(bound);
|
|
238
|
+
const previous = React.useRef(key);
|
|
239
|
+
if (!Object.is(previous.current, key)) {
|
|
240
|
+
previous.current = key;
|
|
241
|
+
setDraft(bound);
|
|
242
|
+
}
|
|
243
|
+
return [draft, setDraft];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/* ------------------------------------------------------------------ */
|
|
247
|
+
/* Layout */
|
|
248
|
+
/* ------------------------------------------------------------------ */
|
|
249
|
+
|
|
250
|
+
export interface ColumnProps {
|
|
251
|
+
gap?: number;
|
|
252
|
+
padding?: number;
|
|
253
|
+
align?: string;
|
|
254
|
+
justify?: string;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export const ColumnView: A2UIView<ColumnProps> = ({ props, children }) => (
|
|
258
|
+
<div
|
|
259
|
+
data-slot="a2ui-column"
|
|
260
|
+
className={cn(
|
|
261
|
+
"flex min-w-0 flex-col",
|
|
262
|
+
GAP[props.gap ?? 3],
|
|
263
|
+
props.padding !== undefined ? PADDING[props.padding] : undefined,
|
|
264
|
+
props.align ? ALIGN[props.align] : undefined,
|
|
265
|
+
props.justify ? JUSTIFY[props.justify] : undefined,
|
|
266
|
+
)}
|
|
267
|
+
>
|
|
268
|
+
{children}
|
|
269
|
+
</div>
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
export interface RowProps extends ColumnProps {
|
|
273
|
+
wrap?: boolean;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export const RowView: A2UIView<RowProps> = ({ props, children }) => (
|
|
277
|
+
<div
|
|
278
|
+
data-slot="a2ui-row"
|
|
279
|
+
className={cn(
|
|
280
|
+
"flex min-w-0 flex-row",
|
|
281
|
+
props.wrap === false ? "flex-nowrap" : "flex-wrap",
|
|
282
|
+
GAP[props.gap ?? 2],
|
|
283
|
+
props.padding !== undefined ? PADDING[props.padding] : undefined,
|
|
284
|
+
ALIGN[props.align ?? "center"],
|
|
285
|
+
props.justify ? JUSTIFY[props.justify] : undefined,
|
|
286
|
+
)}
|
|
287
|
+
>
|
|
288
|
+
{children}
|
|
289
|
+
</div>
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
export interface CardViewProps {
|
|
293
|
+
accent?: boolean;
|
|
294
|
+
flush?: boolean;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export const CardView: A2UIView<CardViewProps> = ({ props, children }) => (
|
|
298
|
+
<Card data-slot="a2ui-card" accent={props.accent}>
|
|
299
|
+
{props.flush ? (
|
|
300
|
+
<div className="flex flex-col gap-3">{children}</div>
|
|
301
|
+
) : (
|
|
302
|
+
<CardContent className="flex flex-col gap-3 p-5">{children}</CardContent>
|
|
303
|
+
)}
|
|
304
|
+
</Card>
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
export interface SectionCardViewProps {
|
|
308
|
+
title: string;
|
|
309
|
+
description?: string;
|
|
310
|
+
flush?: boolean;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export const SectionCardView: A2UIView<SectionCardViewProps> = ({ props, children }) => (
|
|
314
|
+
<SectionCard
|
|
315
|
+
data-slot="a2ui-section-card"
|
|
316
|
+
title={props.title}
|
|
317
|
+
description={props.description}
|
|
318
|
+
flush={props.flush}
|
|
319
|
+
>
|
|
320
|
+
<div className="flex flex-col gap-3">{children}</div>
|
|
321
|
+
</SectionCard>
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
export interface DividerProps {
|
|
325
|
+
orientation?: "horizontal" | "vertical";
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export const DividerView: A2UIView<DividerProps> = ({ props }) => (
|
|
329
|
+
<Separator data-slot="a2ui-divider" orientation={props.orientation ?? "horizontal"} />
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
export interface SpacerProps {
|
|
333
|
+
size?: number;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export const SpacerView: A2UIView<SpacerProps> = ({ props }) => (
|
|
337
|
+
<div data-slot="a2ui-spacer" aria-hidden className={cn("w-full", HEIGHT[props.size ?? 4])} />
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
/* ------------------------------------------------------------------ */
|
|
341
|
+
/* Text */
|
|
342
|
+
/* ------------------------------------------------------------------ */
|
|
343
|
+
|
|
344
|
+
export interface TextProps {
|
|
345
|
+
text: string;
|
|
346
|
+
role?: string;
|
|
347
|
+
tone?: string;
|
|
348
|
+
align?: string;
|
|
349
|
+
mono?: boolean;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export const TextView: A2UIView<TextProps> = ({ props }) => (
|
|
353
|
+
<p
|
|
354
|
+
data-slot="a2ui-text"
|
|
355
|
+
data-role={props.role ?? "body"}
|
|
356
|
+
className={cn(
|
|
357
|
+
"min-w-0 break-words",
|
|
358
|
+
TEXT_ROLE[props.role ?? "body"],
|
|
359
|
+
TEXT_TONE[props.tone ?? "default"],
|
|
360
|
+
props.align ? TEXT_ALIGN[props.align] : undefined,
|
|
361
|
+
props.mono ? "font-[family-name:var(--ds-font-mono)]" : undefined,
|
|
362
|
+
)}
|
|
363
|
+
>
|
|
364
|
+
{props.text}
|
|
365
|
+
</p>
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
export interface MarkdownViewProps {
|
|
369
|
+
text: string;
|
|
370
|
+
compact?: boolean;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export const MarkdownView: A2UIView<MarkdownViewProps> = ({ props }) => (
|
|
374
|
+
<Markdown data-slot="a2ui-markdown" compact={props.compact}>
|
|
375
|
+
{props.text}
|
|
376
|
+
</Markdown>
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
/* ------------------------------------------------------------------ */
|
|
380
|
+
/* Inputs */
|
|
381
|
+
/* ------------------------------------------------------------------ */
|
|
382
|
+
|
|
383
|
+
interface FieldShellProps {
|
|
384
|
+
label?: string;
|
|
385
|
+
description?: string;
|
|
386
|
+
error?: string;
|
|
387
|
+
children: React.ReactNode;
|
|
388
|
+
slot: string;
|
|
389
|
+
orientation?: "vertical" | "horizontal";
|
|
390
|
+
/**
|
|
391
|
+
* Ties the label to the control. Base UI's Field associates its own
|
|
392
|
+
* `Field.Control`; these views render plain cortena-ui controls, so the pair
|
|
393
|
+
* is wired explicitly rather than left to produce a label with no `for`.
|
|
394
|
+
*/
|
|
395
|
+
controlId?: string;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function FieldShell({
|
|
399
|
+
label,
|
|
400
|
+
description,
|
|
401
|
+
error,
|
|
402
|
+
children,
|
|
403
|
+
slot,
|
|
404
|
+
orientation,
|
|
405
|
+
controlId,
|
|
406
|
+
}: FieldShellProps) {
|
|
407
|
+
return (
|
|
408
|
+
<Field data-slot={slot} orientation={orientation ?? "vertical"} invalid={Boolean(error)}>
|
|
409
|
+
{label ? <FieldLabel htmlFor={controlId}>{label}</FieldLabel> : null}
|
|
410
|
+
{children}
|
|
411
|
+
{description ? <FieldDescription>{description}</FieldDescription> : null}
|
|
412
|
+
{error ? <FieldError>{error}</FieldError> : null}
|
|
413
|
+
</Field>
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export interface TextFieldProps {
|
|
418
|
+
label?: string;
|
|
419
|
+
value?: string;
|
|
420
|
+
placeholder?: string;
|
|
421
|
+
description?: string;
|
|
422
|
+
error?: string;
|
|
423
|
+
type?: "text" | "email" | "url" | "tel" | "number" | "password" | "search";
|
|
424
|
+
disabled?: boolean;
|
|
425
|
+
required?: boolean;
|
|
426
|
+
onChange?: A2UIResolvedAction;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export const TextFieldView: A2UIView<TextFieldProps> = ({
|
|
430
|
+
props,
|
|
431
|
+
emit,
|
|
432
|
+
writeBack,
|
|
433
|
+
debounceMs,
|
|
434
|
+
}) => {
|
|
435
|
+
const id = React.useId();
|
|
436
|
+
const [draft, setDraft] = useDraft(props.value ?? "");
|
|
437
|
+
const send = useDebouncedEmit(
|
|
438
|
+
(value) => emit(props.onChange, "change", { value }),
|
|
439
|
+
debounceMs,
|
|
440
|
+
);
|
|
441
|
+
return (
|
|
442
|
+
<FieldShell
|
|
443
|
+
slot="a2ui-text-field"
|
|
444
|
+
label={props.label}
|
|
445
|
+
description={props.description}
|
|
446
|
+
error={props.error}
|
|
447
|
+
controlId={id}
|
|
448
|
+
>
|
|
449
|
+
<Input
|
|
450
|
+
id={id}
|
|
451
|
+
type={props.type ?? "text"}
|
|
452
|
+
value={draft}
|
|
453
|
+
placeholder={props.placeholder}
|
|
454
|
+
disabled={props.disabled}
|
|
455
|
+
required={props.required}
|
|
456
|
+
aria-label={props.label ? undefined : (props.placeholder ?? "Text field")}
|
|
457
|
+
onChange={(event) => {
|
|
458
|
+
const next = event.target.value;
|
|
459
|
+
setDraft(next);
|
|
460
|
+
writeBack("value", next);
|
|
461
|
+
send(next);
|
|
462
|
+
}}
|
|
463
|
+
/>
|
|
464
|
+
</FieldShell>
|
|
465
|
+
);
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
export interface TextAreaProps {
|
|
469
|
+
label?: string;
|
|
470
|
+
value?: string;
|
|
471
|
+
placeholder?: string;
|
|
472
|
+
description?: string;
|
|
473
|
+
error?: string;
|
|
474
|
+
rows?: number;
|
|
475
|
+
disabled?: boolean;
|
|
476
|
+
autoGrow?: boolean;
|
|
477
|
+
onChange?: A2UIResolvedAction;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export const TextAreaView: A2UIView<TextAreaProps> = ({ props, emit, writeBack, debounceMs }) => {
|
|
481
|
+
const id = React.useId();
|
|
482
|
+
const [draft, setDraft] = useDraft(props.value ?? "");
|
|
483
|
+
const send = useDebouncedEmit((value) => emit(props.onChange, "change", { value }), debounceMs);
|
|
484
|
+
return (
|
|
485
|
+
<FieldShell
|
|
486
|
+
slot="a2ui-text-area"
|
|
487
|
+
label={props.label}
|
|
488
|
+
description={props.description}
|
|
489
|
+
error={props.error}
|
|
490
|
+
controlId={id}
|
|
491
|
+
>
|
|
492
|
+
<Textarea
|
|
493
|
+
id={id}
|
|
494
|
+
value={draft}
|
|
495
|
+
rows={props.rows ?? 4}
|
|
496
|
+
placeholder={props.placeholder}
|
|
497
|
+
disabled={props.disabled}
|
|
498
|
+
autoGrow={props.autoGrow}
|
|
499
|
+
aria-label={props.label ? undefined : (props.placeholder ?? "Text area")}
|
|
500
|
+
onChange={(event) => {
|
|
501
|
+
const next = event.target.value;
|
|
502
|
+
setDraft(next);
|
|
503
|
+
writeBack("value", next);
|
|
504
|
+
send(next);
|
|
505
|
+
}}
|
|
506
|
+
/>
|
|
507
|
+
</FieldShell>
|
|
508
|
+
);
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
export interface SelectOption {
|
|
512
|
+
value: string;
|
|
513
|
+
label?: string;
|
|
514
|
+
disabled?: boolean;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export interface SelectViewProps {
|
|
518
|
+
label?: string;
|
|
519
|
+
value?: string;
|
|
520
|
+
placeholder?: string;
|
|
521
|
+
description?: string;
|
|
522
|
+
error?: string;
|
|
523
|
+
options: SelectOption[];
|
|
524
|
+
disabled?: boolean;
|
|
525
|
+
onChange?: A2UIResolvedAction;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
export const SelectView: A2UIView<SelectViewProps> = ({ props, emit, writeBack }) => {
|
|
529
|
+
const [draft, setDraft] = useDraft(props.value ?? null);
|
|
530
|
+
const items = React.useMemo(
|
|
531
|
+
() => props.options.map((option) => ({ value: option.value, label: option.label ?? option.value })),
|
|
532
|
+
[props.options],
|
|
533
|
+
);
|
|
534
|
+
return (
|
|
535
|
+
<FieldShell
|
|
536
|
+
slot="a2ui-select"
|
|
537
|
+
label={props.label}
|
|
538
|
+
description={props.description}
|
|
539
|
+
error={props.error}
|
|
540
|
+
>
|
|
541
|
+
<Select
|
|
542
|
+
items={items}
|
|
543
|
+
value={draft}
|
|
544
|
+
disabled={props.disabled}
|
|
545
|
+
onValueChange={(next) => {
|
|
546
|
+
const value = next === null ? "" : String(next);
|
|
547
|
+
setDraft(value);
|
|
548
|
+
writeBack("value", value);
|
|
549
|
+
emit(props.onChange, "change", { value });
|
|
550
|
+
}}
|
|
551
|
+
>
|
|
552
|
+
<SelectTrigger aria-label={props.label ?? props.placeholder ?? "Select"}>
|
|
553
|
+
<SelectValue placeholder={props.placeholder ?? "Select…"} />
|
|
554
|
+
</SelectTrigger>
|
|
555
|
+
<SelectContent>
|
|
556
|
+
{props.options.map((option) => (
|
|
557
|
+
<SelectItem key={option.value} value={option.value} disabled={option.disabled}>
|
|
558
|
+
{option.label ?? option.value}
|
|
559
|
+
</SelectItem>
|
|
560
|
+
))}
|
|
561
|
+
</SelectContent>
|
|
562
|
+
</Select>
|
|
563
|
+
</FieldShell>
|
|
564
|
+
);
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
export interface CheckboxViewProps {
|
|
568
|
+
label?: string;
|
|
569
|
+
checked?: boolean;
|
|
570
|
+
description?: string;
|
|
571
|
+
disabled?: boolean;
|
|
572
|
+
onChange?: A2UIResolvedAction;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export const CheckboxView: A2UIView<CheckboxViewProps> = ({ props, emit, writeBack }) => {
|
|
576
|
+
const [draft, setDraft] = useDraft(props.checked ?? false);
|
|
577
|
+
return (
|
|
578
|
+
<FieldShell
|
|
579
|
+
slot="a2ui-checkbox"
|
|
580
|
+
description={props.description}
|
|
581
|
+
orientation="horizontal"
|
|
582
|
+
>
|
|
583
|
+
<label className="flex items-start gap-3 text-[length:var(--ds-text-caption-lg)] text-[color:var(--ds-foreground)]">
|
|
584
|
+
<span className="flex h-5 items-center">
|
|
585
|
+
<Checkbox
|
|
586
|
+
checked={draft}
|
|
587
|
+
disabled={props.disabled}
|
|
588
|
+
aria-label={props.label ?? "Checkbox"}
|
|
589
|
+
onCheckedChange={(checked) => {
|
|
590
|
+
setDraft(checked);
|
|
591
|
+
writeBack("checked", checked);
|
|
592
|
+
emit(props.onChange, "change", { checked });
|
|
593
|
+
}}
|
|
594
|
+
/>
|
|
595
|
+
</span>
|
|
596
|
+
<span>{props.label}</span>
|
|
597
|
+
</label>
|
|
598
|
+
</FieldShell>
|
|
599
|
+
);
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
export interface SwitchViewProps extends CheckboxViewProps {}
|
|
603
|
+
|
|
604
|
+
export const SwitchView: A2UIView<SwitchViewProps> = ({ props, emit, writeBack }) => {
|
|
605
|
+
const [draft, setDraft] = useDraft(props.checked ?? false);
|
|
606
|
+
return (
|
|
607
|
+
<FieldShell slot="a2ui-switch" description={props.description} orientation="horizontal">
|
|
608
|
+
<label className="flex items-center gap-3 text-[length:var(--ds-text-caption-lg)] text-[color:var(--ds-foreground)]">
|
|
609
|
+
<Switch
|
|
610
|
+
checked={draft}
|
|
611
|
+
disabled={props.disabled}
|
|
612
|
+
aria-label={props.label ?? "Switch"}
|
|
613
|
+
onCheckedChange={(checked) => {
|
|
614
|
+
setDraft(checked);
|
|
615
|
+
writeBack("checked", checked);
|
|
616
|
+
emit(props.onChange, "change", { checked });
|
|
617
|
+
}}
|
|
618
|
+
/>
|
|
619
|
+
<span>{props.label}</span>
|
|
620
|
+
</label>
|
|
621
|
+
</FieldShell>
|
|
622
|
+
);
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
export interface RadioGroupViewProps {
|
|
626
|
+
label?: string;
|
|
627
|
+
value?: string;
|
|
628
|
+
description?: string;
|
|
629
|
+
options: SelectOption[];
|
|
630
|
+
disabled?: boolean;
|
|
631
|
+
onChange?: A2UIResolvedAction;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
export const RadioGroupView: A2UIView<RadioGroupViewProps> = ({ props, emit, writeBack }) => {
|
|
635
|
+
const [draft, setDraft] = useDraft(props.value ?? "");
|
|
636
|
+
return (
|
|
637
|
+
<FieldShell
|
|
638
|
+
slot="a2ui-radio-group"
|
|
639
|
+
label={props.label}
|
|
640
|
+
description={props.description}
|
|
641
|
+
>
|
|
642
|
+
<RadioGroup
|
|
643
|
+
aria-label={props.label ?? "Options"}
|
|
644
|
+
value={draft}
|
|
645
|
+
disabled={props.disabled}
|
|
646
|
+
onValueChange={(next) => {
|
|
647
|
+
const value = String(next);
|
|
648
|
+
setDraft(value);
|
|
649
|
+
writeBack("value", value);
|
|
650
|
+
emit(props.onChange, "change", { value });
|
|
651
|
+
}}
|
|
652
|
+
>
|
|
653
|
+
{props.options.map((option) => (
|
|
654
|
+
<label
|
|
655
|
+
key={option.value}
|
|
656
|
+
className="flex items-start gap-3 text-[length:var(--ds-text-caption-lg)] text-[color:var(--ds-foreground)]"
|
|
657
|
+
>
|
|
658
|
+
<span className="flex h-5 items-center">
|
|
659
|
+
<RadioGroupItem value={option.value} disabled={option.disabled} />
|
|
660
|
+
</span>
|
|
661
|
+
<span>{option.label ?? option.value}</span>
|
|
662
|
+
</label>
|
|
663
|
+
))}
|
|
664
|
+
</RadioGroup>
|
|
665
|
+
</FieldShell>
|
|
666
|
+
);
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
export interface DateFieldViewProps {
|
|
670
|
+
label?: string;
|
|
671
|
+
/** ISO `yyyy-mm-dd`; the wire format for a date in A2UI. */
|
|
672
|
+
value?: string;
|
|
673
|
+
min?: string;
|
|
674
|
+
max?: string;
|
|
675
|
+
description?: string;
|
|
676
|
+
error?: string;
|
|
677
|
+
disabled?: boolean;
|
|
678
|
+
onChange?: A2UIResolvedAction;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
export const DateFieldView: A2UIView<DateFieldViewProps> = ({ props, emit, writeBack }) => {
|
|
682
|
+
const id = React.useId();
|
|
683
|
+
const bound = props.value ? parseDateISO(props.value) : undefined;
|
|
684
|
+
const [draft, setDraft] = useDraft<Date | undefined>(bound, props.value ?? "");
|
|
685
|
+
return (
|
|
686
|
+
<FieldShell
|
|
687
|
+
slot="a2ui-date-field"
|
|
688
|
+
label={props.label}
|
|
689
|
+
description={props.description}
|
|
690
|
+
error={props.error}
|
|
691
|
+
controlId={id}
|
|
692
|
+
>
|
|
693
|
+
<DateField
|
|
694
|
+
id={id}
|
|
695
|
+
value={draft}
|
|
696
|
+
min={props.min ? parseDateISO(props.min) : undefined}
|
|
697
|
+
max={props.max ? parseDateISO(props.max) : undefined}
|
|
698
|
+
disabled={props.disabled}
|
|
699
|
+
invalid={Boolean(props.error)}
|
|
700
|
+
aria-label={props.label ?? "Date"}
|
|
701
|
+
onValueChange={(date) => {
|
|
702
|
+
setDraft(date);
|
|
703
|
+
const value = date ? formatDateISO(date) : "";
|
|
704
|
+
writeBack("value", value);
|
|
705
|
+
emit(props.onChange, "change", { value });
|
|
706
|
+
}}
|
|
707
|
+
/>
|
|
708
|
+
</FieldShell>
|
|
709
|
+
);
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
export interface ButtonViewProps {
|
|
713
|
+
label: string;
|
|
714
|
+
variant?: "primary" | "secondary" | "outline" | "ghost" | "soft" | "destructive" | "link";
|
|
715
|
+
size?: "sm" | "md" | "lg";
|
|
716
|
+
disabled?: boolean;
|
|
717
|
+
/** Report the whole surface data model with the action, for a form submit. */
|
|
718
|
+
submit?: boolean;
|
|
719
|
+
onPress?: A2UIResolvedAction;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
export const ButtonView: A2UIView<ButtonViewProps> = ({ props, emit, dataModel }) => (
|
|
723
|
+
<Button
|
|
724
|
+
data-slot="a2ui-button"
|
|
725
|
+
className={INLINE}
|
|
726
|
+
variant={props.variant ?? "primary"}
|
|
727
|
+
size={props.size ?? "md"}
|
|
728
|
+
disabled={props.disabled}
|
|
729
|
+
onClick={() =>
|
|
730
|
+
emit(
|
|
731
|
+
props.onPress,
|
|
732
|
+
props.submit ? "submit" : "press",
|
|
733
|
+
props.submit ? { data: dataModel } : {},
|
|
734
|
+
)
|
|
735
|
+
}
|
|
736
|
+
>
|
|
737
|
+
{props.label}
|
|
738
|
+
</Button>
|
|
739
|
+
);
|
|
740
|
+
|
|
741
|
+
export interface ButtonLinkViewProps {
|
|
742
|
+
label: string;
|
|
743
|
+
href: string;
|
|
744
|
+
variant?: ButtonViewProps["variant"];
|
|
745
|
+
size?: ButtonViewProps["size"];
|
|
746
|
+
target?: "_self" | "_blank";
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// ButtonLink sets its own `data-slot` after spreading props, so the A2UI marker
|
|
750
|
+
// goes on a wrapper rather than being silently dropped.
|
|
751
|
+
export const ButtonLinkView: A2UIView<ButtonLinkViewProps> = ({ props }) => (
|
|
752
|
+
<span data-slot="a2ui-button-link" className={cn("inline-flex", INLINE)}>
|
|
753
|
+
<ButtonLink
|
|
754
|
+
variant={props.variant ?? "secondary"}
|
|
755
|
+
size={props.size ?? "md"}
|
|
756
|
+
href={props.href}
|
|
757
|
+
target={props.target}
|
|
758
|
+
rel={props.target === "_blank" ? "noopener noreferrer" : undefined}
|
|
759
|
+
>
|
|
760
|
+
{props.label}
|
|
761
|
+
</ButtonLink>
|
|
762
|
+
</span>
|
|
763
|
+
);
|
|
764
|
+
|
|
765
|
+
export interface LinkViewProps {
|
|
766
|
+
label: string;
|
|
767
|
+
href: string;
|
|
768
|
+
target?: "_self" | "_blank";
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
export const LinkView: A2UIView<LinkViewProps> = ({ props }) => (
|
|
772
|
+
<a
|
|
773
|
+
data-slot="a2ui-link"
|
|
774
|
+
href={props.href}
|
|
775
|
+
target={props.target}
|
|
776
|
+
rel={props.target === "_blank" ? "noopener noreferrer" : undefined}
|
|
777
|
+
className={cn(
|
|
778
|
+
INLINE,
|
|
779
|
+
"text-[length:var(--ds-text-body-sm)] text-[color:var(--ds-primary)] underline underline-offset-4",
|
|
780
|
+
"hover:text-[color:var(--ds-primary-hover)]",
|
|
781
|
+
)}
|
|
782
|
+
>
|
|
783
|
+
{props.label}
|
|
784
|
+
</a>
|
|
785
|
+
);
|
|
786
|
+
|
|
787
|
+
/* ------------------------------------------------------------------ */
|
|
788
|
+
/* Feedback */
|
|
789
|
+
/* ------------------------------------------------------------------ */
|
|
790
|
+
|
|
791
|
+
export interface BadgeViewProps {
|
|
792
|
+
label: string;
|
|
793
|
+
variant?: "default" | "success" | "warning" | "danger" | "info" | "neutral" | "outline";
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
export const BadgeView: A2UIView<BadgeViewProps> = ({ props }) => (
|
|
797
|
+
<Badge data-slot="a2ui-badge" className={INLINE} variant={props.variant ?? "default"}>
|
|
798
|
+
{props.label}
|
|
799
|
+
</Badge>
|
|
800
|
+
);
|
|
801
|
+
|
|
802
|
+
export interface AlertViewProps {
|
|
803
|
+
title?: string;
|
|
804
|
+
description?: string;
|
|
805
|
+
variant?: "default" | "success" | "warn" | "danger" | "info";
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
export const AlertView: A2UIView<AlertViewProps> = ({ props }) => {
|
|
809
|
+
const variant = props.variant ?? "default";
|
|
810
|
+
return (
|
|
811
|
+
<Alert data-slot="a2ui-alert" variant={variant}>
|
|
812
|
+
<AlertIcon>{ALERT_ICON[variant]}</AlertIcon>
|
|
813
|
+
<div>
|
|
814
|
+
{props.title ? <AlertTitle>{props.title}</AlertTitle> : null}
|
|
815
|
+
{props.description ? <AlertDescription>{props.description}</AlertDescription> : null}
|
|
816
|
+
</div>
|
|
817
|
+
</Alert>
|
|
818
|
+
);
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
export interface ProgressViewProps {
|
|
822
|
+
/** `null` renders the indeterminate bar. */
|
|
823
|
+
value?: number | null;
|
|
824
|
+
max?: number;
|
|
825
|
+
label?: string;
|
|
826
|
+
showValue?: boolean;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
export const ProgressViewComponent: A2UIView<ProgressViewProps> = ({ props }) => (
|
|
830
|
+
<Progress
|
|
831
|
+
data-slot="a2ui-progress"
|
|
832
|
+
value={props.value === undefined ? null : props.value}
|
|
833
|
+
max={props.max}
|
|
834
|
+
aria-label={props.label ?? "Progress"}
|
|
835
|
+
>
|
|
836
|
+
{props.label ? <ProgressLabel>{props.label}</ProgressLabel> : null}
|
|
837
|
+
{props.showValue === false ? null : <ProgressValue />}
|
|
838
|
+
</Progress>
|
|
839
|
+
);
|
|
840
|
+
|
|
841
|
+
export interface StatusDotViewProps {
|
|
842
|
+
label?: string;
|
|
843
|
+
tone?: "accent" | "success" | "warning" | "danger" | "info" | "neutral";
|
|
844
|
+
live?: boolean;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
export const StatusDotView: A2UIView<StatusDotViewProps> = ({ props }) => (
|
|
848
|
+
<StatusDot
|
|
849
|
+
data-slot="a2ui-status-dot"
|
|
850
|
+
className={INLINE}
|
|
851
|
+
tone={props.tone ?? "accent"}
|
|
852
|
+
live={props.live}
|
|
853
|
+
label={props.label}
|
|
854
|
+
/>
|
|
855
|
+
);
|
|
856
|
+
|
|
857
|
+
export interface EmptyStateViewProps {
|
|
858
|
+
title: string;
|
|
859
|
+
description?: string;
|
|
860
|
+
size?: "default" | "compact";
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
export const EmptyStateView: A2UIView<EmptyStateViewProps> = ({ props, children }) => (
|
|
864
|
+
<EmptyState
|
|
865
|
+
data-slot="a2ui-empty-state"
|
|
866
|
+
title={props.title}
|
|
867
|
+
description={props.description}
|
|
868
|
+
size={props.size ?? "compact"}
|
|
869
|
+
action={children.length > 0 ? <div className="flex gap-2">{children}</div> : undefined}
|
|
870
|
+
/>
|
|
871
|
+
);
|
|
872
|
+
|
|
873
|
+
/* ------------------------------------------------------------------ */
|
|
874
|
+
/* Data */
|
|
875
|
+
/* ------------------------------------------------------------------ */
|
|
876
|
+
|
|
877
|
+
export interface A2UIColumn {
|
|
878
|
+
id: string;
|
|
879
|
+
label?: string;
|
|
880
|
+
align?: "start" | "center" | "end";
|
|
881
|
+
filter?: "faceted" | "text";
|
|
882
|
+
width?: number;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Where a table's rows come from.
|
|
887
|
+
*
|
|
888
|
+
* `client` carries the rows inline, which suits a short result an agent
|
|
889
|
+
* already has. `server` carries a handle instead: the agent obtains a
|
|
890
|
+
* `sourceId` from a tool and the host resolves it through
|
|
891
|
+
* `resolveDataSource`, so search, sort and paging run on the backend and the
|
|
892
|
+
* agent never has to put ten thousand rows in a message.
|
|
893
|
+
*/
|
|
894
|
+
export type A2UITableSource =
|
|
895
|
+
| { kind: "client"; rows: A2UIRow[] }
|
|
896
|
+
| { kind: "server"; sourceId: string };
|
|
897
|
+
|
|
898
|
+
export interface DataTableViewProps {
|
|
899
|
+
columns: A2UIColumn[];
|
|
900
|
+
dataSource: A2UITableSource;
|
|
901
|
+
pageSize?: number;
|
|
902
|
+
density?: "default" | "compact";
|
|
903
|
+
searchPlaceholder?: string;
|
|
904
|
+
emptyMessage?: string;
|
|
905
|
+
maxHeight?: number;
|
|
906
|
+
onRowClick?: A2UIResolvedAction;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
export const DataTableView: A2UIView<DataTableViewProps> = ({
|
|
910
|
+
props,
|
|
911
|
+
emit,
|
|
912
|
+
resolveDataSource,
|
|
913
|
+
}) => {
|
|
914
|
+
const columns = React.useMemo<DataTableColumnDef<A2UIRow>[]>(
|
|
915
|
+
() =>
|
|
916
|
+
props.columns.map((column) => ({
|
|
917
|
+
id: column.id,
|
|
918
|
+
accessorKey: column.id,
|
|
919
|
+
header: column.label ?? column.id,
|
|
920
|
+
size: column.width,
|
|
921
|
+
meta: {
|
|
922
|
+
align: column.align,
|
|
923
|
+
filter: column.filter,
|
|
924
|
+
label: column.label ?? column.id,
|
|
925
|
+
},
|
|
926
|
+
})) as DataTableColumnDef<A2UIRow>[],
|
|
927
|
+
[props.columns],
|
|
928
|
+
);
|
|
929
|
+
|
|
930
|
+
const source = props.dataSource;
|
|
931
|
+
const resolved: DataSource<A2UIRow> | undefined =
|
|
932
|
+
source.kind === "client" ? { kind: "client", rows: source.rows } : resolveDataSource(source.sourceId);
|
|
933
|
+
|
|
934
|
+
if (!resolved) {
|
|
935
|
+
return (
|
|
936
|
+
<A2UIErrorCard
|
|
937
|
+
title="Unknown data source"
|
|
938
|
+
detail={`No data source is registered for "${(source as { sourceId: string }).sourceId}". The host resolves a server table through resolveDataSource.`}
|
|
939
|
+
/>
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
return (
|
|
944
|
+
<div data-slot="a2ui-data-table">
|
|
945
|
+
<DataTable<A2UIRow>
|
|
946
|
+
columns={columns}
|
|
947
|
+
dataSource={resolved}
|
|
948
|
+
pageSize={props.pageSize ?? 10}
|
|
949
|
+
density={props.density ?? "compact"}
|
|
950
|
+
maxHeight={props.maxHeight}
|
|
951
|
+
searchPlaceholder={props.searchPlaceholder}
|
|
952
|
+
emptyMessage={props.emptyMessage}
|
|
953
|
+
onRowClick={(row) => emit(props.onRowClick, "rowClick", { row })}
|
|
954
|
+
/>
|
|
955
|
+
</div>
|
|
956
|
+
);
|
|
957
|
+
};
|
|
958
|
+
|
|
959
|
+
export interface ChartViewProps {
|
|
960
|
+
type: ChartType;
|
|
961
|
+
data: unknown;
|
|
962
|
+
title?: string;
|
|
963
|
+
height?: number;
|
|
964
|
+
stacked?: boolean;
|
|
965
|
+
legend?: boolean;
|
|
966
|
+
grid?: boolean;
|
|
967
|
+
scale?: "sequential" | "diverging";
|
|
968
|
+
indexBy?: string;
|
|
969
|
+
keys?: string[];
|
|
970
|
+
from?: string;
|
|
971
|
+
to?: string;
|
|
972
|
+
emptyMessage?: string;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
export const ChartView: A2UIView<ChartViewProps> = ({ props }) => (
|
|
976
|
+
<div data-slot="a2ui-chart" className="flex flex-col gap-2">
|
|
977
|
+
{props.title ? (
|
|
978
|
+
<p className="text-[length:var(--ds-text-caption-lg)] font-medium text-[color:var(--ds-foreground)]">
|
|
979
|
+
{props.title}
|
|
980
|
+
</p>
|
|
981
|
+
) : null}
|
|
982
|
+
<Chart
|
|
983
|
+
type={props.type}
|
|
984
|
+
data={props.data as ChartDataFor<ChartType>}
|
|
985
|
+
height={props.height ?? 260}
|
|
986
|
+
stacked={props.stacked}
|
|
987
|
+
legend={props.legend}
|
|
988
|
+
grid={props.grid}
|
|
989
|
+
scale={props.scale}
|
|
990
|
+
indexBy={props.indexBy}
|
|
991
|
+
keys={props.keys}
|
|
992
|
+
from={props.from}
|
|
993
|
+
to={props.to}
|
|
994
|
+
emptyMessage={props.emptyMessage}
|
|
995
|
+
/>
|
|
996
|
+
</div>
|
|
997
|
+
);
|
|
998
|
+
|
|
999
|
+
/* ------------------------------------------------------------------ */
|
|
1000
|
+
/* Tabs and media */
|
|
1001
|
+
/* ------------------------------------------------------------------ */
|
|
1002
|
+
|
|
1003
|
+
export interface TabDescriptor {
|
|
1004
|
+
value: string;
|
|
1005
|
+
label: string;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
export interface TabsViewProps {
|
|
1009
|
+
tabs: TabDescriptor[];
|
|
1010
|
+
value?: string;
|
|
1011
|
+
onChange?: A2UIResolvedAction;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
export const TabsView: A2UIView<TabsViewProps> = ({ props, children, emit, writeBack }) => {
|
|
1015
|
+
const first = props.tabs[0]?.value ?? "";
|
|
1016
|
+
const [draft, setDraft] = useDraft(props.value ?? first);
|
|
1017
|
+
return (
|
|
1018
|
+
<Tabs
|
|
1019
|
+
data-slot="a2ui-tabs"
|
|
1020
|
+
value={draft}
|
|
1021
|
+
onValueChange={(next) => {
|
|
1022
|
+
const value = String(next);
|
|
1023
|
+
setDraft(value);
|
|
1024
|
+
writeBack("value", value);
|
|
1025
|
+
emit(props.onChange, "tabChange", { value });
|
|
1026
|
+
}}
|
|
1027
|
+
>
|
|
1028
|
+
<TabsList>
|
|
1029
|
+
{props.tabs.map((tab) => (
|
|
1030
|
+
<TabsTrigger key={tab.value} value={tab.value}>
|
|
1031
|
+
{tab.label}
|
|
1032
|
+
</TabsTrigger>
|
|
1033
|
+
))}
|
|
1034
|
+
</TabsList>
|
|
1035
|
+
{props.tabs.map((tab, index) => (
|
|
1036
|
+
<TabsContent key={tab.value} value={tab.value}>
|
|
1037
|
+
{children[index] ?? null}
|
|
1038
|
+
</TabsContent>
|
|
1039
|
+
))}
|
|
1040
|
+
</Tabs>
|
|
1041
|
+
);
|
|
1042
|
+
};
|
|
1043
|
+
|
|
1044
|
+
export interface ImageViewProps {
|
|
1045
|
+
src: string;
|
|
1046
|
+
alt: string;
|
|
1047
|
+
width?: number;
|
|
1048
|
+
height?: number;
|
|
1049
|
+
rounded?: boolean;
|
|
1050
|
+
fit?: "cover" | "contain";
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
export const ImageView: A2UIView<ImageViewProps> = ({ props }) => (
|
|
1054
|
+
<img
|
|
1055
|
+
data-slot="a2ui-image"
|
|
1056
|
+
src={props.src}
|
|
1057
|
+
alt={props.alt}
|
|
1058
|
+
width={props.width}
|
|
1059
|
+
height={props.height}
|
|
1060
|
+
className={cn(
|
|
1061
|
+
INLINE,
|
|
1062
|
+
"max-w-full",
|
|
1063
|
+
props.fit === "cover" ? "object-cover" : "object-contain",
|
|
1064
|
+
props.rounded === false ? undefined : "rounded-[var(--ds-radius-md)]",
|
|
1065
|
+
"border border-[var(--ds-border-subtle)]",
|
|
1066
|
+
)}
|
|
1067
|
+
/>
|
|
1068
|
+
);
|
|
1069
|
+
|
|
1070
|
+
/* ------------------------------------------------------------------ */
|
|
1071
|
+
/* Error card */
|
|
1072
|
+
/* ------------------------------------------------------------------ */
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* What the renderer draws instead of a component it cannot draw.
|
|
1076
|
+
*
|
|
1077
|
+
* Never raw markup and never the offending payload as HTML: the detail is set
|
|
1078
|
+
* as text, so a malformed message cannot inject anything. This is the
|
|
1079
|
+
* "fallback: an error card on malformed payloads" of `05-agent-ui.md`.
|
|
1080
|
+
*/
|
|
1081
|
+
export function A2UIErrorCard({
|
|
1082
|
+
title,
|
|
1083
|
+
detail,
|
|
1084
|
+
componentId,
|
|
1085
|
+
}: {
|
|
1086
|
+
title: string;
|
|
1087
|
+
detail?: string;
|
|
1088
|
+
componentId?: string;
|
|
1089
|
+
}) {
|
|
1090
|
+
return (
|
|
1091
|
+
<Alert data-slot="a2ui-error" variant="danger" role="alert">
|
|
1092
|
+
<AlertIcon>
|
|
1093
|
+
<OctagonAlert />
|
|
1094
|
+
</AlertIcon>
|
|
1095
|
+
<div className="min-w-0">
|
|
1096
|
+
<AlertTitle>{title}</AlertTitle>
|
|
1097
|
+
{detail ? (
|
|
1098
|
+
<AlertDescription className="font-[family-name:var(--ds-font-mono)] text-[length:var(--ds-text-caption)] break-words">
|
|
1099
|
+
{detail}
|
|
1100
|
+
</AlertDescription>
|
|
1101
|
+
) : null}
|
|
1102
|
+
{componentId ? (
|
|
1103
|
+
<AlertDescription className="text-[length:var(--ds-text-caption-sm)] text-[color:var(--ds-muted-foreground)]">
|
|
1104
|
+
Component id: {componentId}
|
|
1105
|
+
</AlertDescription>
|
|
1106
|
+
) : null}
|
|
1107
|
+
</div>
|
|
1108
|
+
</Alert>
|
|
1109
|
+
);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/** Re-exported so consumers can type an action handler without the message module. */
|
|
1113
|
+
export type { A2UIAction };
|