tool-ui
blockdata-tablesource: ok · hit
Data Table
Sortable, responsive data tables for tool call results.
npx shadcn add "https://www.tool-ui.com/r/data-table.json"free
Where it goes
data-table
Use cases
dashboard
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightformszod
Registry dependencies
accordionbadgebuttondropdown-menutabletooltip
Files17 · source cached 10/14/2026, 12:28:39 PM
components/tool-ui/data-table/_adapter.tsx
/**
* Adapter: UI and utility re-exports for copy-standalone portability.
*
* When copying this component to another project, update these imports
* to match your project's paths:
*
* cn → Your Tailwind merge utility (e.g., "@/lib/utils", "~/lib/cn")
* Button → shadcn/ui Button
* DropdownMenu → shadcn/ui DropdownMenu
* Accordion → shadcn/ui Accordion
* Tooltip → shadcn/ui Tooltip
* Badge → shadcn/ui Badge
* Table → shadcn/ui Table
*/
export { cn } from "@/lib/utils";
export { Button } from "@/components/ui/button";
export {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
export {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
export { Badge } from "@/components/ui/badge";
export {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from "@/components/ui/table";
components/tool-ui/data-table/data-table.tsx
"use client";
import * as React from "react";
import {
cn,
Table,
TableBody,
TableRow,
TableCell,
TableHeader,
TableHead,
Button,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "./_adapter";
import {
sortData,
createDataTableRowKeys,
getDataTableMobileDescriptionId,
} from "./utilities";
import { renderFormattedValue } from "./formatters";
import type {
DataTableProps,
DataTableContextValue,
RowData,
DataTableRowData,
ColumnKey,
Column,
} from "./types";
import type { FormatConfig } from "./formatters";
export const DEFAULT_LOCALE = "en-US" as const;
function isNumericFormat(format?: FormatConfig): boolean {
const kind = format?.kind;
return (
kind === "number" ||
kind === "currency" ||
kind === "percent" ||
kind === "delta"
);
}
function getAlignmentClass(
align?: "left" | "right" | "center",
): string | undefined {
if (align === "right") return "text-right";
if (align === "center") return "text-center";
return undefined;
}
const DataTableContext = React.createContext<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
DataTableContextValue<any> | undefined
>(undefined);
export function useDataTable<T extends object = RowData>() {
const context = React.use(DataTableContext) as
| DataTableContextValue<T>
| undefined;
if (!context) {
throw new Error("useDataTable must be used within <DataTable.Provider />");
}
return context;
}
type DataTableLayout = "auto" | "table" | "cards";
type DataTableBaseProps<T extends object = RowData> = DataTableProps<T> & {
layout: DataTableLayout;
};
type DataTableProviderProps<T extends object = RowData> = Pick<
DataTableProps<T>,
| "columns"
| "data"
| "rowIdKey"
| "defaultSort"
| "sort"
| "onSortChange"
| "id"
| "locale"
> & {
children: React.ReactNode;
};
function DataTableProvider<T extends object = RowData>({
columns,
data: rawData,
rowIdKey,
defaultSort,
sort: controlledSort,
id,
onSortChange,
locale,
children,
}: DataTableProviderProps<T>) {
// Default locale avoids SSR/client formatting mismatches.
const resolvedLocale = locale ?? DEFAULT_LOCALE;
const [internalSortBy, setInternalSortBy] = React.useState<
ColumnKey<T> | undefined
>(defaultSort?.by);
const [internalSortDirection, setInternalSortDirection] = React.useState<
"asc" | "desc" | undefined
>(defaultSort?.direction);
const sortBy = controlledSort?.by ?? internalSortBy;
const sortDirection = controlledSort?.direction ?? internalSortDirection;
const data = React.useMemo(() => {
if (!sortBy || !sortDirection) return rawData;
return sortData(rawData, sortBy, sortDirection, resolvedLocale);
}, [rawData, sortBy, sortDirection, resolvedLocale]);
const handleSort = React.useCallback(
(key: ColumnKey<T>) => {
let newDirection: "asc" | "desc" | undefined;
if (sortBy === key) {
if (sortDirection === "asc") {
newDirection = "desc";
} else if (sortDirection === "desc") {
newDirection = undefined;
} else {
newDirection = "asc";
}
} else {
newDirection = "asc";
}
const next = {
by: newDirection ? key : undefined,
direction: newDirection,
} as const;
if (controlledSort) {
onSortChange?.(next);
} else {
setInternalSortBy(next.by);
setInternalSortDirection(next.direction);
}
},
[sortBy, sortDirection, controlledSort, onSortChange],
);
const contextValue: DataTableContextValue<T> = {
columns,
data,
rowIdKey,
sortBy,
sortDirection,
toggleSort: handleSort,
id,
locale: resolvedLocale,
};
return (
<DataTableContext.Provider value={contextValue}>
{children}
</DataTableContext.Provider>
);
}
interface DataTableLayoutProps {
layout: DataTableLayout;
emptyMessage: string;
maxHeight?: string;
className?: string;
}
function DataTableLayout({
layout,
emptyMessage,
maxHeight,
className,
}: DataTableLayoutProps) {
const { columns, data, rowIdKey, sortBy, sortDirection, id } = useDataTable();
const rowKeys = React.useMemo(
() =>
createDataTableRowKeys(
data as Array<Record<string, unknown>>,
rowIdKey ? String(rowIdKey) : undefined,
),
[data, rowIdKey],
);
const mobileDescriptionId = React.useMemo(
() => getDataTableMobileDescriptionId(String(id ?? "data-table")),
[id],
);
const sortAnnouncement = React.useMemo(() => {
const col = columns.find((c) => c.key === sortBy);
const label = col?.label ?? sortBy;
return sortBy && sortDirection
? `Sorted by ${label}, ${sortDirection === "asc" ? "ascending" : "descending"}`
: "";
}, [columns, sortBy, sortDirection]);
return (
<div
className={cn("@container w-full min-w-80", className)}
data-tool-ui-id={id}
data-slot="data-table"
data-layout={layout}
>
<div
className={cn(
layout === "table"
? "block"
: layout === "cards"
? "hidden"
: "hidden @md:block",
)}
>
<div className="relative">
<div
className={cn(
"bg-card relative w-full overflow-clip overflow-y-auto rounded-lg border",
"touch-pan-x",
maxHeight && "max-h-[--max-height]",
)}
style={
maxHeight
? ({ "--max-height": maxHeight } as React.CSSProperties)
: undefined
}
>
<Table>
{columns.length > 0 && (
<colgroup>
{columns.map((col) => (
<col
key={String(col.key)}
style={col.width ? { width: col.width } : undefined}
/>
))}
</colgroup>
)}
{data.length === 0 ? (
<DataTableEmpty message={emptyMessage} />
) : (
<DataTableContent />
)}
</Table>
</div>
</div>
</div>
<div
className={cn(
layout === "cards"
? ""
: layout === "table"
? "hidden"
: "@md:hidden",
)}
role="list"
aria-label="Data table (mobile card view)"
aria-describedby={mobileDescriptionId}
>
<div id={mobileDescriptionId} className="sr-only">
Table data shown as expandable cards. Each card represents one row.
{columns.length > 0 &&
` Columns: ${columns.map((c) => c.label).join(", ")}.`}
</div>
{data.length === 0 ? (
<div className="text-muted-foreground py-8 text-center">
{emptyMessage}
</div>
) : (
<div className="bg-card flex flex-col overflow-hidden rounded-2xl border shadow-xs">
{data.map((row, i) => {
const rowKey = rowKeys[i];
return (
<DataTableAccordionCard
key={rowKey}
row={row as unknown as DataTableRowData}
index={i}
rowKey={rowKey}
isFirst={i === 0}
/>
);
})}
</div>
)}
</div>
{sortAnnouncement && (
<div className="sr-only" aria-live="polite">
{sortAnnouncement}
</div>
)}
</div>
);
}
function DataTableBase<T extends object = RowData>(
props: DataTableBaseProps<T>,
) {
const {
columns,
data,
rowIdKey,
defaultSort,
sort,
onSortChange,
id,
locale,
layout,
emptyMessage = "No data available",
maxHeight,
className,
} = props;
return (
<DataTableProvider
columns={columns}
data={data}
rowIdKey={rowIdKey}
defaultSort={defaultSort}
sort={sort}
onSortChange={onSortChange}
id={id}
locale={locale}
>
<DataTableLayout
layout={layout}
emptyMessage={emptyMessage}
maxHeight={maxHeight}
className={className}
/>
</DataTableProvider>
);
}
function DataTableRoot<T extends object = RowData>(props: DataTableProps<T>) {
return <DataTableBase {...props} layout="auto" />;
}
function DataTableTable<T extends object = RowData>(props: DataTableProps<T>) {
return <DataTableBase {...props} layout="table" />;
}
function DataTableCards<T extends object = RowData>(props: DataTableProps<T>) {
return <DataTableBase {...props} layout="cards" />;
}
type DataTableComponent = {
<T extends object = RowData>(props: DataTableProps<T>): React.ReactElement;
Table: typeof DataTableTable;
Cards: typeof DataTableCards;
Provider: typeof DataTableProvider;
};
export const DataTable = Object.assign(DataTableRoot, {
Table: DataTableTable,
Cards: DataTableCards,
Provider: DataTableProvider,
}) as DataTableComponent;
function DataTableContent() {
return (
<>
<DataTableHeader />
<DataTableBody />
</>
);
}
function DataTableEmpty({ message }: { message: string }) {
const { columns } = useDataTable();
return (
<TableBody>
<TableRow className="bg-card h-24 text-center">
<TableCell colSpan={columns.length} role="status" aria-live="polite">
{message}
</TableCell>
</TableRow>
</TableBody>
);
}
function SortIcon({ state }: { state?: "asc" | "desc" }) {
let char = "⇅";
let className = "opacity-20";
if (state === "asc") {
char = "↑";
className = "";
}
if (state === "desc") {
char = "↓";
className = "";
}
return (
<span aria-hidden className={cn("min-w-4 shrink-0 text-center", className)}>
{char}
</span>
);
}
function DataTableHeader() {
const { columns } = useDataTable();
return (
<TooltipProvider delayDuration={300}>
<TableHeader>
<TableRow className="hover:bg-transparent">
{columns.map((column, columnIndex) => (
<DataTableHead
key={column.key}
column={column}
columnIndex={columnIndex}
totalColumns={columns.length}
/>
))}
</TableRow>
</TableHeader>
</TooltipProvider>
);
}
interface DataTableHeadProps {
column: Column;
columnIndex?: number;
totalColumns?: number;
}
function DataTableHead({
column,
columnIndex = 0,
totalColumns = 1,
}: DataTableHeadProps) {
const { sortBy, sortDirection, toggleSort } = useDataTable();
const isFirstColumn = columnIndex === 0;
const isLastColumn = columnIndex === totalColumns - 1;
const isSortable = column.sortable !== false;
const isSorted = sortBy === column.key;
const direction = isSorted ? sortDirection : undefined;
const isDisabled = !isSortable;
const handleClick = () => {
if (!isDisabled && toggleSort) {
toggleSort(column.key);
}
};
const displayText = column.abbr || column.label;
const shouldShowTooltip = column.abbr || displayText.length > 15;
const isNumericKind = isNumericFormat(column.format);
const align =
column.align ??
(columnIndex === 0 ? "left" : isNumericKind ? "right" : "left");
const alignClass = getAlignmentClass(align);
const buttonAlignClass = cn(
"min-w-0 gap-1 font-normal",
align === "right" && "text-right",
align === "center" && "text-center",
align === "left" && "text-left",
);
const labelAlignClass =
align === "right"
? "text-right"
: align === "center"
? "text-center"
: "text-left";
return (
<TableHead
scope="col"
className={cn(
alignClass,
isFirstColumn && "pl-1",
isLastColumn && "pr-1",
)}
style={column.width ? { width: column.width } : undefined}
aria-sort={
isSorted
? direction === "asc"
? "ascending"
: "descending"
: undefined
}
>
<Button
type="button"
size="sm"
onClick={handleClick}
onKeyDown={(e) => {
if (isDisabled) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleClick();
}
}}
disabled={isDisabled}
variant="ghost"
className={cn(
buttonAlignClass,
"w-fit min-w-10",
isFirstColumn && "pl-4",
isLastColumn && "pr-4",
)}
aria-label={
`Sort by ${column.label}` +
(isSorted && direction
? ` (${direction === "asc" ? "ascending" : "descending"})`
: "")
}
aria-disabled={isDisabled || undefined}
>
{shouldShowTooltip ? (
<Tooltip>
<TooltipTrigger asChild>
<span className={cn("truncate", labelAlignClass)}>
{column.abbr ? (
<abbr
title={column.label}
className={cn(
"cursor-help border-b border-dotted border-current no-underline",
labelAlignClass,
)}
>
{column.abbr}
</abbr>
) : (
<span className={labelAlignClass}>{column.label}</span>
)}
</span>
</TooltipTrigger>
<TooltipContent>
<p>{column.label}</p>
</TooltipContent>
</Tooltip>
) : (
<span className={cn("truncate", labelAlignClass)}>
{column.label}
</span>
)}
{isSortable && <SortIcon state={direction} />}
</Button>
</TableHead>
);
}
function DataTableBody() {
const { data, rowIdKey } = useDataTable<DataTableRowData>();
const rowKeys = React.useMemo(
() =>
createDataTableRowKeys(
data as Array<Record<string, unknown>>,
rowIdKey ? String(rowIdKey) : undefined,
),
[data, rowIdKey],
);
const hasWarnedRowKeyRef = React.useRef(false);
React.useEffect(() => {
if (hasWarnedRowKeyRef.current) return;
if (process.env.NODE_ENV !== "production" && !rowIdKey && data.length > 0) {
hasWarnedRowKeyRef.current = true;
console.warn(
"[DataTable] Missing `rowIdKey` prop. Falling back to inferred/content-derived row keys. " +
"Strongly recommended: Pass a `rowIdKey` prop that points to a unique identifier in your row data (e.g., 'id', 'uuid', 'symbol').\n" +
'Example: <DataTable rowIdKey="id" columns={...} data={...} />',
);
}
}, [rowIdKey, data.length]);
return (
<TableBody>
{data.map((row, index) => {
const rowKey = rowKeys[index];
return <DataTableRow key={rowKey} row={row} />;
})}
</TableBody>
);
}
interface DataTableRowProps {
row: DataTableRowData;
className?: string;
}
function DataTableRow({ row, className }: DataTableRowProps) {
const { columns } = useDataTable();
return (
<TableRow className={className}>
{columns.map((column, columnIndex) => (
<DataTableCell
key={column.key}
value={row[column.key]}
column={column}
row={row}
columnIndex={columnIndex}
/>
))}
</TableRow>
);
}
interface DataTableCellProps {
value:
| string
| number
| boolean
| null
| (string | number | boolean | null)[];
column: Column;
row: DataTableRowData;
className?: string;
columnIndex?: number;
}
function DataTableCell({
value,
column,
row,
className,
columnIndex = 0,
}: DataTableCellProps) {
const { locale } = useDataTable();
const isNumericKind = isNumericFormat(column.format);
const isNumericValue = typeof value === "number";
const displayValue = renderFormattedValue({ value, column, row, locale });
const align =
column.align ??
(columnIndex === 0
? "left"
: isNumericKind || isNumericValue
? "right"
: "left");
const alignClass = getAlignmentClass(align);
return (
<TableCell className={cn("px-5 py-3", alignClass, className)}>
{displayValue}
</TableCell>
);
}
function categorizeColumns(columns: Column[]) {
const primary: Column[] = [];
const secondary: Column[] = [];
let visibleColumnCount = 0;
columns.forEach((col) => {
if (col.hideOnMobile) return;
if (col.priority === "primary") {
primary.push(col);
} else if (col.priority === "secondary") {
secondary.push(col);
} else if (col.priority === "tertiary") {
return;
} else {
if (visibleColumnCount < 2) {
primary.push(col);
} else {
secondary.push(col);
}
visibleColumnCount++;
}
});
return { primary, secondary };
}
interface DataTableAccordionCardProps {
row: DataTableRowData;
index: number;
rowKey: string;
isFirst?: boolean;
}
function getDataTableRowDomId(rowKey: string): string {
return encodeURIComponent(rowKey).replace(/%/g, "_");
}
function DataTableAccordionCard({
row,
index,
rowKey,
isFirst = false,
}: DataTableAccordionCardProps) {
const { columns, locale } = useDataTable();
const { primary, secondary } = React.useMemo(
() => categorizeColumns(columns),
[columns],
);
if (secondary.length === 0) {
return (
<SimpleCard
row={row}
columns={primary}
index={index}
rowKey={rowKey}
isFirst={isFirst}
/>
);
}
const primaryColumn = primary[0];
const remainingPrimaryColumns = primary.slice(1);
const stableRowId = getDataTableRowDomId(rowKey);
const headingId = `row-${stableRowId}-heading`;
const detailsId = `row-${stableRowId}-details`;
const remainingPrimaryDataIds = remainingPrimaryColumns.map(
(col) => `row-${stableRowId}-${String(col.key)}`,
);
const primaryValue = primaryColumn
? String(row[primaryColumn.key] ?? "")
: "";
const rowLabel = `Row ${index + 1}: ${primaryValue}`;
const accordionItemId = `row-${stableRowId}`;
return (
<Accordion
type="single"
collapsible
className={cn(!isFirst && "border-t")}
role="listitem"
aria-label={rowLabel}
>
<AccordionItem value={accordionItemId} className="group border-0">
<AccordionTrigger
className="group-data-[state=closed]:hover:bg-accent/50 active:bg-accent/50 group-data-[state=open]:bg-muted w-full rounded-none px-4 py-3 hover:no-underline"
aria-controls={detailsId}
aria-label={`${rowLabel}. ${secondary.length > 0 ? "Expand for details" : ""}`}
>
<div className="flex min-w-0 flex-1 flex-col gap-2">
{primaryColumn && (
<div
id={headingId}
role="heading"
aria-level={3}
className="truncate"
aria-label={`${primaryColumn.label}: ${row[primaryColumn.key]}`}
>
{renderFormattedValue({
value: row[primaryColumn.key],
column: primaryColumn,
row,
locale,
})}
</div>
)}
{remainingPrimaryColumns.length > 0 && (
<div
className="text-muted-foreground flex w-full flex-wrap gap-x-4 gap-y-0.5"
role="group"
aria-label="Summary information"
>
{remainingPrimaryColumns.map((col, idx) => (
<span
key={col.key}
id={remainingPrimaryDataIds[idx]}
className="flex min-w-0 gap-1 font-normal"
role="cell"
aria-label={`${col.label}: ${row[col.key]}`}
>
<span className="sr-only">{col.label}:</span>
<span aria-hidden="true">{col.label}:</span>
<span className="truncate">
{renderFormattedValue({
value: row[col.key],
column: col,
row,
locale,
})}
</span>
</span>
))}
</div>
)}
</div>
</AccordionTrigger>
<AccordionContent
className={"flex flex-col gap-4 px-4 pb-4"}
id={detailsId}
role="region"
aria-labelledby={headingId}
>
{secondary.length > 0 && (
<dl
className={cn(
"flex flex-col gap-2 pt-4",
"motion-safe:group-data-[state=open]:animate-in motion-safe:group-data-[state=open]:fade-in-0",
"motion-safe:group-data-[state=open]:slide-in-from-top-1",
"motion-safe:group-data-[state=closed]:animate-out motion-safe:group-data-[state=closed]:fade-out-0",
"motion-safe:group-data-[state=closed]:slide-out-to-top-1",
"duration-150",
)}
role="list"
aria-label="Additional data"
>
{secondary.map((col) => (
<div
key={col.key}
className="flex items-start justify-between gap-4"
role="listitem"
>
<dt
className="text-muted-foreground shrink-0"
id={`row-${stableRowId}-${String(col.key)}-label`}
>
{col.label}
</dt>
<dd
className={cn(
"text-foreground min-w-0 text-pretty wrap-break-word",
col.align === "right" && "text-right",
col.align === "center" && "text-center",
)}
role="cell"
aria-labelledby={`row-${stableRowId}-${String(col.key)}-label`}
>
{renderFormattedValue({
value: row[col.key],
column: col,
row,
locale,
})}
</dd>
</div>
))}
</dl>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
);
}
/**
* Simple card with no accordion, for when there are only primary columns
*/
function SimpleCard({
row,
columns,
index,
rowKey,
isFirst = false,
}: {
row: DataTableRowData;
columns: Column[];
index: number;
rowKey: string;
isFirst?: boolean;
}) {
const { locale } = useDataTable();
const primaryColumn = columns[0];
const otherColumns = columns.slice(1);
const stableRowId = getDataTableRowDomId(rowKey);
const primaryValue = primaryColumn
? String(row[primaryColumn.key] ?? "")
: "";
const rowLabel = `Row ${index + 1}: ${primaryValue}`;
return (
<div
className={cn("flex flex-col gap-2 p-4", !isFirst && "border-t")}
role="listitem"
aria-label={rowLabel}
>
{primaryColumn && (
<div
role="heading"
aria-level={3}
aria-label={`${primaryColumn.label}: ${row[primaryColumn.key]}`}
>
{renderFormattedValue({
value: row[primaryColumn.key],
column: primaryColumn,
row,
locale,
})}
</div>
)}
{otherColumns.map((col) => (
<div
key={col.key}
className="flex items-start justify-between gap-4"
role="group"
>
<span
className="text-muted-foreground"
id={`row-${stableRowId}-${String(col.key)}-label`}
>
{col.label}:
</span>
<span
className={cn(
"min-w-0 wrap-break-word",
col.align === "right" && "text-right",
col.align === "center" && "text-center",
)}
role="cell"
aria-labelledby={`row-${stableRowId}-${String(col.key)}-label`}
>
{renderFormattedValue({
value: row[col.key],
column: col,
row,
locale,
})}
</span>
</div>
))}
</div>
);
}
components/tool-ui/data-table/formatters.tsx
"use client";
import * as React from "react";
import { cn, Badge, Tooltip, TooltipContent, TooltipTrigger } from "./_adapter";
import { resolveSafeNavigationHref } from "../shared/media";
type Tone = "success" | "warning" | "danger" | "info" | "neutral";
export type FormatConfig =
| { kind: "text" }
| {
kind: "number";
decimals?: number;
unit?: string;
compact?: boolean;
showSign?: boolean;
}
| { kind: "currency"; currency: string; decimals?: number }
| {
kind: "percent";
decimals?: number;
showSign?: boolean;
basis?: "fraction" | "unit";
}
| { kind: "date"; dateFormat?: "short" | "long" | "relative" }
| {
kind: "delta";
decimals?: number;
upIsPositive?: boolean;
showSign?: boolean;
}
| {
kind: "status";
statusMap: Record<string, { tone: Tone; label?: string }>;
}
| { kind: "boolean"; labels?: { true: string; false: string } }
| { kind: "link"; hrefKey?: string; external?: boolean }
| { kind: "badge"; colorMap?: Record<string, Tone> }
| { kind: "array"; maxVisible?: number };
interface DeltaValueProps {
value: number;
options?: Extract<FormatConfig, { kind: "delta" }>;
locale?: string;
}
export function DeltaValue({ value, options, locale }: DeltaValueProps) {
const decimals = options?.decimals ?? 2;
const upIsPositive = options?.upIsPositive ?? true;
const showSign = options?.showSign ?? true;
const isPositive = value > 0;
const isNegative = value < 0;
const isNeutral = value === 0;
const isGood = upIsPositive ? isPositive : isNegative;
const isBad = upIsPositive ? isNegative : isPositive;
const colorClass = isGood
? "text-green-700 dark:text-green-500"
: isBad
? "text-destructive"
: "text-muted-foreground";
const absValue = Math.abs(value);
const formatted = new Intl.NumberFormat(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(absValue);
const display =
showSign && !isNeutral
? isNegative
? `-${formatted}`
: `+${formatted}`
: formatted;
const arrow = isPositive ? "↑" : isNegative ? "↓" : "";
return (
<span className={cn("tabular-nums", colorClass)}>
{display}
{!isNeutral && <span className="ml-0.5">{arrow}</span>}
</span>
);
}
interface StatusBadgeProps {
value: string;
options?: Extract<FormatConfig, { kind: "status" }>;
}
export function StatusBadge({ value, options }: StatusBadgeProps) {
const config = options?.statusMap?.[value] ?? {
tone: "neutral" as Tone,
label: value,
};
const label = config.label ?? value;
const variant =
config.tone === "danger"
? "destructive"
: config.tone === "neutral"
? "outline"
: "secondary";
return (
<Badge
variant={variant}
className={cn(
"border",
config.tone === "warning" &&
"bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-100",
config.tone === "success" &&
"bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-100",
config.tone === "info" &&
"bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-100",
config.tone === "danger" &&
"bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-100",
)}
>
{label}
</Badge>
);
}
interface CurrencyValueProps {
value: number;
options?: Extract<FormatConfig, { kind: "currency" }>;
locale?: string;
}
export function CurrencyValue({ value, options, locale }: CurrencyValueProps) {
const currency = options?.currency ?? "USD";
const decimals = options?.decimals ?? 2;
const formatted = new Intl.NumberFormat(locale, {
style: "currency",
currency,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(value);
return <span className="tabular-nums">{formatted}</span>;
}
interface PercentValueProps {
value: number;
options?: Extract<FormatConfig, { kind: "percent" }>;
locale?: string;
}
export function PercentValue({ value, options, locale }: PercentValueProps) {
const decimals = options?.decimals ?? 2;
const showSign = options?.showSign ?? false;
const basis = options?.basis ?? "fraction";
const numeric = basis === "fraction" ? value : value / 100;
const formatted = new Intl.NumberFormat(locale, {
style: "percent",
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
signDisplay: showSign ? "always" : "auto",
}).format(numeric);
return <span className="tabular-nums">{formatted}</span>;
}
interface DateValueProps {
value: string;
options?: Extract<FormatConfig, { kind: "date" }>;
locale?: string;
}
export function DateValue({ value, options, locale }: DateValueProps) {
const dateFormat = options?.dateFormat ?? "short";
const date = new Date(value);
if (isNaN(date.getTime())) {
return <span className="text-muted-foreground">{value}</span>;
}
let formatted: string;
if (dateFormat === "relative") {
formatted = getRelativeTime(date, locale);
} else if (dateFormat === "long") {
formatted = new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "long",
day: "numeric",
}).format(date);
} else {
formatted = new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "short",
day: "numeric",
}).format(date);
}
const title = new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
return (
<span className="tabular-nums" title={title}>
{formatted}
</span>
);
}
function getRelativeTime(date: Date, locale?: string): string {
const now = new Date();
const diffInSeconds = Math.trunc((date.getTime() - now.getTime()) / 1000);
const absDiffInSeconds = Math.abs(diffInSeconds);
if (absDiffInSeconds < 60) return "just now";
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
if (absDiffInSeconds < 3600) {
const mins = Math.trunc(diffInSeconds / 60);
return rtf.format(mins, "minute");
}
if (absDiffInSeconds < 86400) {
const hours = Math.trunc(diffInSeconds / 3600);
return rtf.format(hours, "hour");
}
if (absDiffInSeconds < 604800) {
const days = Math.trunc(diffInSeconds / 86400);
return rtf.format(days, "day");
}
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "short",
day: "numeric",
}).format(date);
}
interface BooleanValueProps {
value: boolean;
options?: Extract<FormatConfig, { kind: "boolean" }>;
}
export function BooleanValue({ value, options }: BooleanValueProps) {
const labels = options?.labels ?? { true: "Yes", false: "No" };
const label = value ? labels.true : labels.false;
const variant = value ? "secondary" : "outline";
return <Badge variant={variant}>{label}</Badge>;
}
interface LinkValueProps {
value: string;
options?: Extract<FormatConfig, { kind: "link" }>;
row?: Record<
string,
string | number | boolean | null | (string | number | boolean | null)[]
>;
}
export function LinkValue({ value, options, row }: LinkValueProps) {
const rawHref =
options?.hrefKey && row ? String(row[options.hrefKey] ?? "") : value;
const href = resolveSafeNavigationHref(rawHref);
const external = options?.external ?? false;
if (!href) {
return <span>{value}</span>;
}
return (
<a
href={href}
target={external ? "_blank" : undefined}
rel={external ? "noopener noreferrer" : undefined}
className="text-accent-foreground inline-block max-w-full break-words underline underline-offset-2 hover:opacity-90"
aria-label={external ? `${value} (opens in a new tab)` : undefined}
onClick={(e) => e.stopPropagation()}
>
{value}
{external && (
<span className="ml-1 inline-block" aria-label="Opens in new tab">
↗
</span>
)}
</a>
);
}
interface NumberValueProps {
value: number;
options?: Extract<FormatConfig, { kind: "number" }>;
locale?: string;
}
export function NumberValue({ value, options, locale }: NumberValueProps) {
const decimals = options?.decimals ?? 0;
const unit = options?.unit ?? "";
const compact = options?.compact ?? false;
const showSign = options?.showSign ?? false;
const formatted = new Intl.NumberFormat(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
notation: compact ? "compact" : "standard",
}).format(value);
const display = showSign && value > 0 ? `+${formatted}` : formatted;
return (
<span className="tabular-nums">
{display}
{unit}
</span>
);
}
interface BadgeValueProps {
value: string;
options?: Extract<FormatConfig, { kind: "badge" }>;
}
export function BadgeValue({ value, options }: BadgeValueProps) {
const tone = options?.colorMap?.[value] ?? "neutral";
const variant =
tone === "danger"
? "destructive"
: tone === "neutral"
? "outline"
: "secondary";
return (
<Badge
variant={variant}
className={cn(
"border",
tone === "warning" &&
"bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-100",
tone === "success" &&
"bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-100",
tone === "info" &&
"bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-100",
tone === "danger" &&
"bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-100",
)}
>
{value}
</Badge>
);
}
interface ArrayValueProps {
value: (string | number | boolean | null)[] | string;
options?: Extract<FormatConfig, { kind: "array" }>;
}
export function ArrayValue({ value, options }: ArrayValueProps) {
const maxVisible = options?.maxVisible ?? 3;
const items: (string | number | boolean | null)[] = Array.isArray(value)
? value
: typeof value === "string"
? value.split(",").map((s) => s.trim())
: [];
if (items.length === 0) {
return <span className="text-muted">—</span>;
}
const visible = items.slice(0, maxVisible);
const remaining = items.length - maxVisible;
const hidden = items.slice(maxVisible);
return (
<span className="inline-flex flex-wrap items-center gap-1">
{visible.map((item, i) => (
<span
key={i}
className="bg-muted text-muted-foreground inline-flex items-center rounded-md px-2 py-0.5"
>
{item === null ? "null" : String(item)}
</span>
))}
{remaining > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground cursor-default">
+{remaining} more
</span>
</TooltipTrigger>
<TooltipContent>
{hidden
.map((item) => (item === null ? "null" : String(item)))
.join(", ")}
</TooltipContent>
</Tooltip>
)}
</span>
);
}
interface RenderFormattedValueParams {
value:
| string
| number
| boolean
| null
| (string | number | boolean | null)[];
column: { format?: FormatConfig };
row?: Record<
string,
string | number | boolean | null | (string | number | boolean | null)[]
>;
locale?: string;
}
export function renderFormattedValue({
value,
column,
row,
locale,
}: RenderFormattedValueParams): React.ReactNode {
if (value == null || value === "") {
return <span className="text-muted">—</span>;
}
const fmt = column.format;
switch (fmt?.kind) {
case "delta":
return <DeltaValue value={Number(value)} options={fmt} locale={locale} />;
case "status":
return <StatusBadge value={String(value)} options={fmt} />;
case "currency":
return (
<CurrencyValue value={Number(value)} options={fmt} locale={locale} />
);
case "percent":
return (
<PercentValue value={Number(value)} options={fmt} locale={locale} />
);
case "date":
return <DateValue value={String(value)} options={fmt} locale={locale} />;
case "boolean":
return <BooleanValue value={Boolean(value)} options={fmt} />;
case "link":
return <LinkValue value={String(value)} options={fmt} row={row} />;
case "number":
return (
<NumberValue value={Number(value)} options={fmt} locale={locale} />
);
case "badge":
return <BadgeValue value={String(value)} options={fmt} />;
case "array":
return (
<ArrayValue
value={Array.isArray(value) ? value : String(value)}
options={fmt}
/>
);
case "text":
default:
return String(value);
}
}
components/tool-ui/data-table/index.tsx
export { DataTable, useDataTable } from "./data-table";
export { renderFormattedValue } from "./formatters";
export {
NumberValue,
CurrencyValue,
PercentValue,
DeltaValue,
DateValue,
BooleanValue,
LinkValue,
BadgeValue,
StatusBadge,
ArrayValue,
} from "./formatters";
export type {
Column,
DataTableProps,
DataTableSerializableProps,
DataTableClientProps,
DataTableRowData,
RowPrimitive,
RowData,
ColumnKey,
} from "./types";
export type { FormatConfig } from "./formatters";
export { sortData, parseNumericLike } from "./utilities";
components/tool-ui/data-table/README.md
# Data Table
Implementation for the "data-table" Tool UI surface.
## Files
- public exports: components/tool-ui/data-table/index.tsx
- serializable schema + parse helpers: components/tool-ui/data-table/schema.ts
## Companion assets
- Docs page: app/docs/data-table/content.mdx
- Preset payload: lib/presets/data-table.ts
## Quick check
Run this after edits:
pnpm test
components/tool-ui/data-table/schema.ts
import { z } from "zod";
import {
ToolUIIdSchema,
ToolUIReceiptSchema,
ToolUIRoleSchema,
} from "../shared/schema";
import { defineToolUiContract } from "../shared/contract";
import type { Column, DataTableProps, RowData } from "./types";
const AlignEnum = z.enum(["left", "right", "center"]);
const PriorityEnum = z.enum(["primary", "secondary", "tertiary"]);
const formatSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("text") }),
z.object({
kind: z.literal("number"),
decimals: z.number().optional(),
unit: z.string().optional(),
compact: z.boolean().optional(),
showSign: z.boolean().optional(),
}),
z.object({
kind: z.literal("currency"),
currency: z.string(),
decimals: z.number().optional(),
}),
z.object({
kind: z.literal("percent"),
decimals: z.number().optional(),
showSign: z.boolean().optional(),
basis: z.enum(["fraction", "unit"]).optional(),
}),
z.object({
kind: z.literal("date"),
dateFormat: z.enum(["short", "long", "relative"]).optional(),
}),
z.object({
kind: z.literal("delta"),
decimals: z.number().optional(),
upIsPositive: z.boolean().optional(),
showSign: z.boolean().optional(),
}),
z.object({
kind: z.literal("status"),
statusMap: z.record(
z.string(),
z.object({
tone: z.enum(["success", "warning", "danger", "info", "neutral"]),
label: z.string().optional(),
}),
),
}),
z.object({
kind: z.literal("boolean"),
labels: z
.object({
true: z.string(),
false: z.string(),
})
.optional(),
}),
z.object({
kind: z.literal("link"),
hrefKey: z.string().optional(),
external: z.boolean().optional(),
}),
z.object({
kind: z.literal("badge"),
colorMap: z
.record(
z.string(),
z.enum(["success", "warning", "danger", "info", "neutral"]),
)
.optional(),
}),
z.object({
kind: z.literal("array"),
maxVisible: z.number().optional(),
}),
]);
export const serializableColumnSchema = z.object({
key: z.string(),
label: z.string(),
abbr: z.string().optional(),
sortable: z.boolean().optional(),
align: AlignEnum.optional(),
width: z.string().optional(),
truncate: z.boolean().optional(),
priority: PriorityEnum.optional(),
hideOnMobile: z.boolean().optional(),
format: formatSchema.optional(),
});
const JsonPrimitiveSchema = z.union([
z.string(),
z.number(),
z.boolean(),
z.null(),
]);
/**
* Schema for serializable row data.
*
* Supports:
* - Primitives: string, number, boolean, null
* - Arrays of primitives: string[], number[], boolean[], or mixed primitive arrays
*
* Does NOT support:
* - Functions
* - Class instances (Date, Map, Set, etc.)
* - Plain objects (use format configs instead)
*
* @example
* Valid row data:
* ```json
* {
* "name": "Widget",
* "price": 29.99,
* "active": true,
* "tags": ["electronics", "featured"],
* "metrics": [1.2, 3.4, 5.6],
* "flags": [true, false, true],
* "mixed": ["label", 42, true]
* }
* ```
*/
export const serializableDataSchema = z.record(
z.string(),
z.union([JsonPrimitiveSchema, z.array(JsonPrimitiveSchema)]),
);
/**
* Zod schema for validating DataTable payloads from LLM tool calls.
*
* This schema validates the serializable parts of a DataTable:
* - id: Unique identifier for this tool UI in the conversation
* - columns: Column definitions (keys, labels, formatting, etc.)
* - data: Data rows (primitives only - no functions or class instances)
* - optional presentation props: rowIdKey, sort/defaultSort, locale, etc.
*
* Non-serializable props like `onSortChange`, `className`, and sibling action surfaces
* must be provided separately in your React component.
*
* @example
* ```ts
* const result = SerializableDataTableSchema.safeParse(llmResponse)
* if (result.success) {
* // result.data contains validated id, columns, and data
* }
* ```
*/
export const SerializableDataTableSchema = z.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
receipt: ToolUIReceiptSchema.optional(),
columns: z.array(serializableColumnSchema),
data: z.array(serializableDataSchema),
rowIdKey: z.string().optional(),
defaultSort: z
.object({
by: z.string().optional(),
direction: z.enum(["asc", "desc"]).optional(),
})
.optional(),
sort: z
.object({
by: z.string().optional(),
direction: z.enum(["asc", "desc"]).optional(),
})
.optional(),
emptyMessage: z.string().optional(),
maxHeight: z.string().optional(),
locale: z.string().optional(),
});
const SerializableDataTableSchemaContract = defineToolUiContract(
"DataTable",
SerializableDataTableSchema,
);
/**
* Type representing the serializable parts of a DataTable payload.
*
* This type includes only JSON-serializable data that can come from LLM tool calls:
* - Column definitions (format configs, alignment, labels, etc.)
* - Row data (primitives: strings, numbers, booleans, null, string arrays)
*
* Excluded from this type:
* - Event handlers (`onSortChange`)
* - React-specific props (`className`)
*
* @example
* ```ts
* const payload: SerializableDataTable = {
* id: "data-table-expenses",
* columns: [
* { key: "name", label: "Name" },
* { key: "price", label: "Price", format: { kind: "currency", currency: "USD" } }
* ],
* data: [
* { name: "Widget", price: 29.99 }
* ]
* }
* ```
*/
export type SerializableDataTable = z.infer<typeof SerializableDataTableSchema>;
/**
* Validates and parses a DataTable payload from unknown data (e.g., LLM tool call result).
*
* This function:
* 1. Validates the input against the `SerializableDataTableSchema`
* 2. Throws a descriptive error if validation fails
* 3. Returns typed serializable props ready to pass to the `<DataTable>` component
*
* The returned props are **serializable only** - you must provide client-side props
* separately (onSortChange, className).
*
* @param input - Unknown data to validate (typically from an LLM tool call)
* @returns Validated and typed DataTable serializable props (id, columns, data)
* @throws Error with validation details if input is invalid
*
* @example
* ```tsx
* function MyToolUI({ result }: { result: unknown }) {
* const serializableProps = parseSerializableDataTable(result)
*
* return (
* <DataTable
* {...serializableProps}
* />
* )
* }
* ```
*/
export function parseSerializableDataTable(
input: unknown,
): Pick<
DataTableProps<RowData>,
| "id"
| "role"
| "receipt"
| "columns"
| "data"
| "rowIdKey"
| "defaultSort"
| "sort"
| "emptyMessage"
| "maxHeight"
| "locale"
> {
const {
id,
role,
receipt,
columns,
data,
rowIdKey,
defaultSort,
sort,
emptyMessage,
maxHeight,
locale,
} = SerializableDataTableSchemaContract.parse(input);
return {
id,
role,
receipt,
columns: columns as unknown as Column<RowData>[],
data: data as RowData[],
rowIdKey: rowIdKey as keyof RowData | undefined,
defaultSort: defaultSort
? {
by: defaultSort.by as keyof RowData | undefined,
direction: defaultSort.direction,
}
: undefined,
sort: sort
? {
by: sort.by as keyof RowData | undefined,
direction: sort.direction,
}
: undefined,
emptyMessage,
maxHeight,
locale,
};
}
export function safeParseSerializableDataTable(
input: unknown,
): Pick<
DataTableProps<RowData>,
| "id"
| "role"
| "receipt"
| "columns"
| "data"
| "rowIdKey"
| "defaultSort"
| "sort"
| "emptyMessage"
| "maxHeight"
| "locale"
> | null {
const res = SerializableDataTableSchemaContract.safeParse(input);
if (!res) return null;
const {
id,
role,
receipt,
columns,
data,
rowIdKey,
defaultSort,
sort,
emptyMessage,
maxHeight,
locale,
} = res;
return {
id,
role,
receipt,
columns: columns as unknown as Column<RowData>[],
data: data as RowData[],
rowIdKey: rowIdKey as keyof RowData | undefined,
defaultSort: defaultSort
? {
by: defaultSort.by as keyof RowData | undefined,
direction: defaultSort.direction,
}
: undefined,
sort: sort
? {
by: sort.by as keyof RowData | undefined,
direction: sort.direction,
}
: undefined,
emptyMessage,
maxHeight,
locale,
};
}
components/tool-ui/data-table/types.ts
import type { ToolUIId, ToolUIReceipt, ToolUIRole } from "../shared/schema";
import type { FormatConfig } from "./formatters";
/**
* JSON primitive type that can be serialized.
*/
type JsonPrimitive = string | number | boolean | null;
/**
* Valid row value types for serializable DataTable data.
*
* Supports:
* - Primitives: string, number, boolean, null
* - Arrays of primitives: string[], number[], boolean[], or mixed primitive arrays
*
* For complex data (objects with href/label, etc.), use column format configs
* instead of putting objects in row data.
*
* @example
* ```ts
* // 👍 Good: Use primitives and primitive arrays
* const row = {
* name: "Widget",
* price: 29.99,
* tags: ["electronics", "featured"],
* metrics: [1.2, 3.4, 5.6]
* }
*
* // 🚫 Bad: Don't put objects in row data
* const row = {
* link: { href: "/path", label: "Click" } // Use format: { kind: 'link' } instead
* }
* ```
*/
export type RowPrimitive = JsonPrimitive | JsonPrimitive[];
export type DataTableRowData = Record<string, RowPrimitive>;
export type RowData = Record<string, unknown>;
export type ColumnKey<T extends object> = Extract<keyof T, string>;
export type FormatFor<V> = V extends number
? Extract<FormatConfig, { kind: "number" | "currency" | "percent" | "delta" }>
: V extends boolean
? Extract<FormatConfig, { kind: "boolean" | "status" | "badge" }>
: V extends (string | number | boolean | null)[]
? Extract<FormatConfig, { kind: "array" }>
: V extends string
? Extract<
FormatConfig,
{ kind: "text" | "link" | "date" | "badge" | "status" }
>
: Extract<FormatConfig, { kind: "text" }>;
/**
* Column definition for DataTable
*
* @remarks
* **Important:** Columns are sortable by default (opt-out pattern).
* Set `sortable: false` explicitly to disable sorting for specific columns.
*/
export interface Column<
T extends object = DataTableRowData,
K extends ColumnKey<T> = ColumnKey<T>,
> {
/** Unique identifier that maps to a key in the row data */
key: K;
/** Display text for the column header */
label: string;
/** Abbreviated label for narrow viewports */
abbr?: string;
/** Whether column is sortable. Default: true (opt-out pattern) */
sortable?: boolean;
/** Text alignment for column cells */
align?: "left" | "right" | "center";
/** Optional fixed width (CSS value) */
width?: string;
/** Enable text truncation with ellipsis */
truncate?: boolean;
/** Mobile display priority (primary = always visible, secondary = expandable, tertiary = hidden) */
priority?: "primary" | "secondary" | "tertiary";
/** Completely hide column on mobile viewports */
hideOnMobile?: boolean;
/** Formatting configuration for cell values */
format?: FormatFor<T[K]>;
}
/**
* Serializable props that can come from LLM tool calls or be JSON-serialized.
*
* These props contain only primitive values, arrays, and plain objects -
* no functions, class instances, or other non-serializable values.
*
* @example
* ```tsx
* const serializableProps: DataTableSerializableProps = {
* columns: [...],
* data: [...],
* rowIdKey: "id",
* defaultSort: { by: "price", direction: "desc" }
* }
* ```
*/
export interface DataTableSerializableProps<T extends object = RowData> {
/**
* Unique identifier for this tool UI instance in the conversation.
*
* Used for:
* - Assistant referencing ("the table above")
* - Receipt generation (linking actions to their source)
* - Narration context
*
* Should be stable across re-renders, meaningful, and unique within the conversation.
*
* @example "data-table-expenses-q3", "search-results-repos"
*/
id: ToolUIId;
/** Optional surface role metadata (serializable) */
role?: ToolUIRole;
/** Optional receipt metadata for consequential outcomes (serializable) */
receipt?: ToolUIReceipt;
/** Column definitions */
columns: Column<T>[];
/** Row data (primitives only - no functions or class instances) */
data: T[];
/**
* Key in row data to use as unique identifier for React keys
*
* **Strongly recommended:** Always provide this for dynamic data to prevent
* reconciliation issues (focus traps, animation glitches, incorrect state preservation)
* when data reorders. Falls back to array index if omitted (only acceptable for static mock data).
*
* @example rowIdKey="id" or rowIdKey="uuid"
*/
rowIdKey?: ColumnKey<T>;
/**
* Uncontrolled initial sort state (table manages its own sort state internally)
*
* **Sorting cycle:** Clicking column headers cycles through tri-state:
* 1. none (unsorted) → 2. asc → 3. desc → 4. none (back to unsorted)
*
* @example
* ```tsx
* // Start with descending price sort
* <DataTable defaultSort={{ by: "price", direction: "desc" }} />
* ```
*/
defaultSort?: { by?: ColumnKey<T>; direction?: "asc" | "desc" };
/**
* Controlled sort state (use with onSortChange from client props)
*
* When provided, you must also provide `onSortChange` to handle sort updates.
* The table will cycle through: none → asc → desc → none.
*
* @example
* ```tsx
* const [sort, setSort] = useState({ by: "price", direction: "desc" })
* <DataTable sort={sort} onSortChange={setSort} />
* ```
*/
sort?: { by?: ColumnKey<T>; direction?: "asc" | "desc" };
/** Empty state message */
emptyMessage?: string;
/** Max table height with vertical scroll (CSS value) */
maxHeight?: string;
/**
* BCP47 locale for formatting and sorting (e.g., 'en-US', 'de-DE', 'ja-JP')
*
* Defaults to 'en-US' to ensure consistent server/client rendering.
* Pass explicit locale for internationalization.
*
* @example
* ```tsx
* <DataTable locale="de-DE" /> // German formatting
* <DataTable locale="ja-JP" /> // Japanese formatting
* <DataTable /> // Uses 'en-US' default
* ```
*/
locale?: string;
}
/**
* Client-side React-only props that cannot be serialized.
*
* These props contain functions, component state, or other React-specific values
* that must be provided by your React code (not from LLM tool calls).
*
* @example
* ```tsx
* const clientProps: DataTableClientProps = {
* className: "my-table",
* onSortChange: (next) => setSort(next),
* // Compose local/decision actions externally via LocalActions/DecisionActions
* }
* ```
*/
export interface DataTableClientProps<T extends object = RowData> {
/** Additional CSS classes */
className?: string;
/**
* Sort change handler for controlled mode (required if sort is provided)
*
* **Tri-state cycle behavior:**
* - Click unsorted column: `{ by: "column", direction: "asc" }`
* - Click asc column: `{ by: "column", direction: "desc" }`
* - Click desc column: `{ by: "column", direction: undefined }` (returns to unsorted)
* - Click different column: `{ by: "newColumn", direction: "asc" }`
*
* @example
* ```tsx
* const [sort, setSort] = useState<{ by?: string; direction?: "asc" | "desc" }>({})
*
* <DataTable
* sort={sort}
* onSortChange={(next) => {
* console.log("Sort changed:", next)
* setSort(next)
* }}
* />
* ```
*/
onSortChange?: (next: {
by?: ColumnKey<T>;
direction?: "asc" | "desc";
}) => void;
}
/**
* Complete props for the DataTable component.
*
* Combines serializable props (can come from LLM tool calls) with client-side
* React-only props. This separation makes the boundary explicit and prevents
* accidental serialization of non-serializable values.
*
* @see {@link DataTableSerializableProps} for props that can be JSON-serialized
* @see {@link DataTableClientProps} for React-only props
* @see {@link parseSerializableDataTable} for parsing LLM tool call results
*
* @example
* ```tsx
* // From LLM tool call
* const serializableProps = parseSerializableDataTable(llmResult)
*
* // Combine with React-specific props
* <DataTable
* {...serializableProps}
* onSortChange={setSort}
* // Render sibling LocalActions / DecisionActions where needed
* />
* ```
*/
export interface DataTableProps<T extends object = RowData>
extends DataTableSerializableProps<T>, DataTableClientProps<T> {}
export interface DataTableContextValue<T extends object = RowData> {
columns: Column<T>[];
data: T[];
rowIdKey?: ColumnKey<T>;
sortBy?: ColumnKey<T>;
sortDirection?: "asc" | "desc";
toggleSort?: (key: ColumnKey<T>) => void;
id?: string;
locale?: string;
}
components/tool-ui/data-table/utilities.ts
/**
* Sort an array of objects by a key
*/
export function sortData<T, K extends Extract<keyof T, string>>(
data: T[],
key: K,
direction: "asc" | "desc",
locale?: string,
): T[] {
const get = (obj: T, k: K): unknown => (obj as Record<string, unknown>)[k];
const collator = new Intl.Collator(locale, {
numeric: true,
sensitivity: "base",
});
return [...data].sort((a, b) => {
const aVal = get(a, key);
const bVal = get(b, key);
// Handle nulls
if (aVal == null && bVal == null) return 0;
if (aVal == null) return 1;
if (bVal == null) return -1;
// Type-specific comparison
// Numbers
if (typeof aVal === "number" && typeof bVal === "number") {
return direction === "asc" ? aVal - bVal : bVal - aVal;
}
// Dates (Date instances)
if (aVal instanceof Date && bVal instanceof Date) {
const diff = aVal.getTime() - bVal.getTime();
return direction === "asc" ? diff : -diff;
}
// Booleans: false < true
if (typeof aVal === "boolean" && typeof bVal === "boolean") {
const diff = aVal === bVal ? 0 : aVal ? 1 : -1;
return direction === "asc" ? diff : -diff;
}
// Arrays: compare length
if (Array.isArray(aVal) && Array.isArray(bVal)) {
const diff = aVal.length - bVal.length;
return direction === "asc" ? diff : -diff;
}
// Strings that look like numbers -> numeric compare
if (typeof aVal === "string" && typeof bVal === "string") {
const numA = parseNumericLike(aVal);
const numB = parseNumericLike(bVal);
if (numA != null && numB != null) {
const diff = numA - numB;
return direction === "asc" ? diff : -diff;
}
// ISO-like date strings
if (/^\d{4}-\d{2}-\d{2}/.test(aVal) && /^\d{4}-\d{2}-\d{2}/.test(bVal)) {
const da = new Date(aVal).getTime();
const db = new Date(bVal).getTime();
const diff = da - db;
return direction === "asc" ? diff : -diff;
}
}
// Fallback: locale-aware string compare with numeric collation
const aStr = String(aVal);
const bStr = String(bVal);
const comparison = collator.compare(aStr, bStr);
return direction === "asc" ? comparison : -comparison;
});
}
/**
* Return a human-friendly identifier for a row using common keys
*
* Accepts any JSON-serializable primitive or array of primitives.
* Arrays are converted to comma-separated strings.
*/
export function getRowIdentifier(
row: Record<
string,
string | number | boolean | null | (string | number | boolean | null)[]
>,
identifierKey?: string,
): string {
const candidate =
(identifierKey ? row[identifierKey] : undefined) ??
(row as Record<string, unknown>).name ??
(row as Record<string, unknown>).title ??
(row as Record<string, unknown>).id;
if (candidate == null) {
return "";
}
// Handle arrays by joining them
if (Array.isArray(candidate)) {
return candidate.map((v) => (v === null ? "null" : String(v))).join(", ");
}
return String(candidate).trim();
}
function stableStringify(value: unknown): string {
if (value == null) return "null";
if (typeof value === "string") return JSON.stringify(value);
if (
typeof value === "number" ||
typeof value === "boolean" ||
typeof value === "bigint"
) {
return String(value);
}
if (Array.isArray(value)) {
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
}
if (typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>).sort(
([a], [b]) => a.localeCompare(b),
);
return `{${entries
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
.join(",")}}`;
}
return JSON.stringify(String(value));
}
function hashString(value: string): string {
let hash = 5381;
for (let i = 0; i < value.length; i++) {
hash = (hash * 33) ^ value.charCodeAt(i);
}
return (hash >>> 0).toString(36);
}
/**
* Create deterministic, reorder-stable React keys for DataTable rows.
*
* - Uses `identifierKey` or common identifier fields as the primary base.
* - Falls back to stable content fingerprints when no identifier exists.
* - Disambiguates duplicates without relying on array index.
*/
export function createDataTableRowKeys(
rows: Array<Record<string, unknown>>,
identifierKey?: string,
): string[] {
const canonicalRows = rows.map((row) => stableStringify(row));
const baseKeys = rows.map((row, index) => {
const identifier = getRowIdentifier(
row as Record<
string,
string | number | boolean | null | (string | number | boolean | null)[]
>,
identifierKey,
);
if (identifier) {
return `id:${identifier}`;
}
return `row:${hashString(canonicalRows[index])}`;
});
const baseCounts = new Map<string, number>();
baseKeys.forEach((key) => {
baseCounts.set(key, (baseCounts.get(key) ?? 0) + 1);
});
const usedKeys = new Map<string, number>();
return rows.map((row, index) => {
const baseKey = baseKeys[index];
if ((baseCounts.get(baseKey) ?? 0) === 1) {
return baseKey;
}
const rowFingerprint = hashString(canonicalRows[index]);
let disambiguatedKey = `${baseKey}::${rowFingerprint}`;
const seenCount = usedKeys.get(disambiguatedKey) ?? 0;
usedKeys.set(disambiguatedKey, seenCount + 1);
if (seenCount > 0) {
disambiguatedKey = `${disambiguatedKey}::d${seenCount + 1}`;
}
return disambiguatedKey;
});
}
function sanitizeDomIdToken(value: string): string {
return encodeURIComponent(value).replace(/%/g, "_");
}
export function getDataTableMobileDescriptionId(surfaceId: string): string {
return `${sanitizeDomIdToken(surfaceId)}-mobile-table-description`;
}
/**
* Parse a string that represents a numeric value, handling various formats:
* - Currency symbols: $, €, £, ¥, etc.
* - Percent symbols: %
* - Accounting negatives: (1234) → -1234
* - Thousands/decimal separators: 1,234.56 or 1.234,56
* - Compact notation: 2.8T (trillion), 1.5M (million), 500K (thousand)
* - Byte suffixes: 768B (bytes), 1.5KB, 2GB, 1TB
*
* Note: Single "B" is disambiguated - integers < 1024 are bytes, otherwise billions.
*
* @param input - String to parse
* @returns Parsed number or null if unparseable
*
* @example
* parseNumericLike("$1,234.56") // 1234.56
* parseNumericLike("2.8T") // 2800000000000
* parseNumericLike("768B") // 768
* parseNumericLike("50%") // 50
* parseNumericLike("(1234)") // -1234
*/
export function parseNumericLike(input: string): number | null {
// Normalize whitespace (spaces, NBSPs, thin spaces)
let s = input.replace(/[\u00A0\u202F\s]/g, "").trim();
if (!s) return null;
// Accounting negatives: (1234) -> -1234
s = s.replace(/^\((.*)\)$/g, "-$1");
// Strip common currency and percent symbols
s = s.replace(/[%$€£¥₩₹₽₺₪₫฿₦₴₡₲₵₸]/g, "");
function hasGroupedThousands(value: string, sep: "," | "."): boolean {
const unsigned = value.replace(/^[+-]/, "");
const parts = unsigned.split(sep);
if (parts.length < 2) return false;
if (parts.some((part) => part.length === 0)) return false;
if (!/^\d{1,3}$/.test(parts[0])) return false;
if (parts[0] === "0") return false;
return parts.slice(1).every((part) => /^\d{3}$/.test(part));
}
const lastComma = s.lastIndexOf(",");
const lastDot = s.lastIndexOf(".");
if (lastComma !== -1 && lastDot !== -1) {
// Decide decimal by whichever occurs last
const decimalSep = lastComma > lastDot ? "," : ".";
const thousandSep = decimalSep === "," ? "." : ",";
s = s.split(thousandSep).join("");
s = s.replace(decimalSep, ".");
} else if (lastComma !== -1) {
// Only comma present
if (hasGroupedThousands(s, ",")) {
s = s.replace(/,/g, "");
} else {
const frac = s.length - lastComma - 1;
if (frac >= 1 && frac <= 3) s = s.replace(/,/g, ".");
else s = s.replace(/,/g, "");
}
} else if (lastDot !== -1) {
// Only dot present; normalize grouped thousands separators.
if (hasGroupedThousands(s, ".")) {
s = s.replace(/\./g, "");
} else if ((s.match(/\./g) || []).length > 1) {
s = s.replace(/\./g, "");
}
}
// Handle compact notation (K, M, B, T, P, G) and byte suffixes (KB, MB, GB, TB, PB)
const compactMatch = s.match(/^([+-]?\d+\.?\d*|\d*\.\d+)([KMBTPG]B?|B)$/i);
if (compactMatch) {
const baseNum = Number(compactMatch[1]);
if (Number.isNaN(baseNum)) return null;
const suffix = compactMatch[2].toUpperCase();
// Disambiguate single "B" (bytes vs billions)
// If whole number < 1024, treat as bytes. Otherwise, billions.
if (suffix === "B") {
const isLikelyBytes = Number.isInteger(baseNum) && baseNum < 1024;
return isLikelyBytes ? baseNum : baseNum * 1e9;
}
const multipliers: Record<string, number> = {
K: 1e3,
KB: 1024, // Kilo: metric vs binary
M: 1e6,
MB: 1024 ** 2, // Mega
G: 1e9,
GB: 1024 ** 3, // Giga
T: 1e12,
TB: 1024 ** 4, // Tera
P: 1e15,
PB: 1024 ** 5, // Peta
};
return baseNum * (multipliers[suffix] ?? 1);
}
if (/^[+-]?(?:\d+\.?\d*|\d*\.\d+)$/.test(s)) {
const n = Number(s);
return Number.isNaN(n) ? null : n;
}
return null;
}
components/tool-ui/shared/contract.ts
import { z } from "zod";
import { parseWithSchema, safeParseWithSchema } from "./parse";
export interface ToolUiContract<T> {
schema: z.ZodType<T>;
parse: (input: unknown) => T;
safeParse: (input: unknown) => T | null;
}
export function defineToolUiContract<T>(
componentName: string,
schema: z.ZodType<T>,
): ToolUiContract<T> {
return {
schema,
parse: (input: unknown) => parseWithSchema(schema, input, componentName),
safeParse: (input: unknown) => safeParseWithSchema(schema, input),
};
}
components/tool-ui/shared/media/aspect-ratio.ts
import { z } from "zod";
export const AspectRatioSchema = z
.enum(["auto", "1:1", "4:3", "16:9", "9:16"])
.default("auto");
export type AspectRatio = z.infer<typeof AspectRatioSchema>;
export const MediaFitSchema = z.enum(["cover", "contain"]).default("cover");
export type MediaFit = z.infer<typeof MediaFitSchema>;
export const RATIO_CLASS_MAP: Record<AspectRatio, string> = {
auto: "",
"1:1": "aspect-square",
"4:3": "aspect-[4/3]",
"16:9": "aspect-video",
"9:16": "aspect-[9/16]",
};
export function getRatioClass(ratio: AspectRatio): string {
return RATIO_CLASS_MAP[ratio];
}
export function getFitClass(fit: MediaFit): string {
return fit === "cover" ? "object-cover" : "object-contain";
}
components/tool-ui/shared/media/format-utils.ts
/**
* Format duration in milliseconds to human-readable string.
* @example formatDuration(128000) => "2:08"
* @example formatDuration(3661000) => "1:01:01"
*/
export function formatDuration(durationMs: number): string {
const totalSeconds = Math.round(durationMs / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds
.toString()
.padStart(2, "0")}`;
}
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
/**
* Format file size in bytes to human-readable string.
* @example formatFileSize(1024) => "1 KB"
* @example formatFileSize(1536000) => "1.5 MB"
*/
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB"];
let size = bytes / 1024;
let unit = 0;
while (size >= 1024 && unit < units.length - 1) {
size /= 1024;
unit += 1;
}
return `${size.toFixed(size >= 10 ? 0 : 1)} ${units[unit]}`;
}
components/tool-ui/shared/media/index.ts
export {
AspectRatioSchema,
MediaFitSchema,
RATIO_CLASS_MAP,
getRatioClass,
getFitClass,
type AspectRatio,
type MediaFit,
} from "./aspect-ratio";
export { OVERLAY_GRADIENT } from "./overlay-gradient";
export { formatDuration, formatFileSize } from "./format-utils";
export { sanitizeHref } from "./sanitize-href";
export {
resolveSafeNavigationHref,
openSafeNavigationHref,
} from "./safe-navigation";
components/tool-ui/shared/media/overlay-gradient.ts
/**
* Eased gradient for hover overlays on media elements.
* Creates a smooth fade from opaque black at top to transparent.
*
* @see https://larsenwork.com/easing-gradients/
*/
export const OVERLAY_GRADIENT = `linear-gradient(
to bottom,
hsl(0, 0%, 0%) 0%,
hsla(0, 0%, 0%, 0.987) 8.3%,
hsla(0, 0%, 0%, 0.951) 16.6%,
hsla(0, 0%, 0%, 0.896) 24.6%,
hsla(0, 0%, 0%, 0.825) 32.5%,
hsla(0, 0%, 0%, 0.741) 40.1%,
hsla(0, 0%, 0%, 0.648) 47.6%,
hsla(0, 0%, 0%, 0.55) 54.8%,
hsla(0, 0%, 0%, 0.45) 61.7%,
hsla(0, 0%, 0%, 0.352) 68.3%,
hsla(0, 0%, 0%, 0.259) 74.5%,
hsla(0, 0%, 0%, 0.175) 80.4%,
hsla(0, 0%, 0%, 0.104) 86%,
hsla(0, 0%, 0%, 0.049) 91.1%,
hsla(0, 0%, 0%, 0.013) 95.8%,
hsla(0, 0%, 0%, 0) 100%
)` as const;
components/tool-ui/shared/media/safe-navigation.ts
import { sanitizeHref } from "./sanitize-href";
export function resolveSafeNavigationHref(
...candidates: Array<string | null | undefined>
): string | undefined {
for (const candidate of candidates) {
const safeHref = sanitizeHref(candidate ?? undefined);
if (safeHref) {
return safeHref;
}
}
return undefined;
}
export function openSafeNavigationHref(href: string | undefined): boolean {
if (!href || typeof window === "undefined") {
return false;
}
window.open(href, "_blank", "noopener,noreferrer");
return true;
}
components/tool-ui/shared/media/sanitize-href.ts
/**
* Sanitize a URL to ensure it's safe for use in href attributes.
* Allows:
* - Absolute http(s) URLs
* - Relative URLs (/path, ./path, ../path, ?query, #hash)
*
* @returns The sanitized URL string, or undefined if invalid/unsafe
*/
export function sanitizeHref(href?: string): string | undefined {
if (!href) return undefined;
const candidate = href.trim();
if (!candidate) return undefined;
if (
candidate.startsWith("/") ||
candidate.startsWith("./") ||
candidate.startsWith("../") ||
candidate.startsWith("?") ||
candidate.startsWith("#")
) {
if (candidate.startsWith("//")) return undefined;
// eslint-disable-next-line no-control-regex -- intentionally matching control characters
if (/[\u0000-\u001F\u007F]/.test(candidate)) return undefined;
return candidate;
}
try {
const url = new URL(candidate);
if (url.protocol === "http:" || url.protocol === "https:") {
return url.toString();
}
} catch {
return undefined;
}
return undefined;
}
components/tool-ui/shared/parse.ts
import { z } from "zod";
function formatZodPath(path: Array<string | number | symbol>): string {
if (path.length === 0) return "root";
return path
.map((segment) =>
typeof segment === "number" ? `[${segment}]` : String(segment),
)
.join(".");
}
/**
* Format Zod errors into a compact `path: message` string.
*/
export function formatZodError(error: z.ZodError): string {
const parts = error.issues.map((issue) => {
const path = formatZodPath(issue.path);
return `${path}: ${issue.message}`;
});
return Array.from(new Set(parts)).join("; ");
}
/**
* Parse unknown input and throw a readable error.
*/
export function parseWithSchema<T>(
schema: z.ZodType<T>,
input: unknown,
name: string,
): T {
const res = schema.safeParse(input);
if (!res.success) {
throw new Error(`Invalid ${name} payload: ${formatZodError(res.error)}`);
}
return res.data;
}
/**
* Parse unknown input, returning `null` instead of throwing on failure.
*
* Use this in assistant-ui `render` functions where `args` stream in
* incrementally and may be incomplete until the tool call finishes.
*/
export function safeParseWithSchema<T>(
schema: z.ZodType<T>,
input: unknown,
): T | null {
const res = schema.safeParse(input);
return res.success ? res.data : null;
}
components/tool-ui/shared/schema.ts
import { z } from "zod";
import type { ReactNode } from "react";
/**
* Tool UI conventions:
* - Serializable schemas are JSON-safe (no callbacks/ReactNode/`className`).
* - Schema: `SerializableXSchema`
* - Parser: `parseSerializableX(input: unknown)` (throws on invalid)
* - Safe parser: `safeParseSerializableX(input: unknown)` (returns `null` on invalid)
* - Actions: `LocalActions` for non-receipt actions and `DecisionActions` for consequential actions
* - Root attrs: `data-tool-ui-id` + `data-slot`
*/
/**
* Schema for tool UI identity.
*
* Every tool UI should have a unique identifier that:
* - Is stable across re-renders
* - Is meaningful (not auto-generated)
* - Is unique within the conversation
*
* Format recommendation: `{component-type}-{semantic-identifier}`
* Examples: "data-table-expenses-q3", "option-list-deploy-target"
*/
export const ToolUIIdSchema = z.string().min(1);
export type ToolUIId = z.infer<typeof ToolUIIdSchema>;
/**
* Primary role of a Tool UI surface in a chat context.
*/
export const ToolUIRoleSchema = z.enum([
"information",
"decision",
"control",
"state",
"composite",
]);
export type ToolUIRole = z.infer<typeof ToolUIRoleSchema>;
export const ToolUIReceiptOutcomeSchema = z.enum([
"success",
"partial",
"failed",
"cancelled",
]);
export type ToolUIReceiptOutcome = z.infer<typeof ToolUIReceiptOutcomeSchema>;
/**
* Optional receipt metadata: a durable summary of an outcome.
*/
export const ToolUIReceiptSchema = z.object({
outcome: ToolUIReceiptOutcomeSchema,
summary: z.string().min(1),
identifiers: z.record(z.string(), z.string()).optional(),
at: z.string().datetime(),
});
export type ToolUIReceipt = z.infer<typeof ToolUIReceiptSchema>;
/**
* Base schema for Tool UI payloads (id + optional role/receipt).
*/
export const ToolUISurfaceSchema = z.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
receipt: ToolUIReceiptSchema.optional(),
});
export type ToolUISurface = z.infer<typeof ToolUISurfaceSchema>;
export const ActionSchema = z.object({
id: z.string().min(1),
label: z.string().min(1),
/**
* Canonical narration the assistant can use after this action is taken.
*
* Example: "I exported the table as CSV." / "I opened the link in a new tab."
*/
sentence: z.string().optional(),
confirmLabel: z.string().optional(),
variant: z
.enum(["default", "destructive", "secondary", "ghost", "outline"])
.optional(),
icon: z.custom<ReactNode>().optional(),
loading: z.boolean().optional(),
disabled: z.boolean().optional(),
shortcut: z.string().optional(),
});
export type Action = z.infer<typeof ActionSchema>;
export type LocalAction = Action;
export type DecisionAction = Action;
export const DecisionResultSchema = z.object({
kind: z.literal("decision"),
version: z.literal(1),
decisionId: z.string().min(1),
actionId: z.string().min(1),
actionLabel: z.string().min(1),
at: z.string().datetime(),
payload: z.record(z.string(), z.unknown()).optional(),
});
export type DecisionResult<
TPayload extends Record<string, unknown> = Record<string, unknown>,
> = Omit<z.infer<typeof DecisionResultSchema>, "payload"> & {
payload?: TPayload;
};
export function createDecisionResult<
TPayload extends Record<string, unknown> = Record<string, unknown>,
>(args: {
decisionId: string;
action: { id: string; label: string };
payload?: TPayload;
}): DecisionResult<TPayload> {
return {
kind: "decision",
version: 1,
decisionId: args.decisionId,
actionId: args.action.id,
actionLabel: args.action.label,
at: new Date().toISOString(),
payload: args.payload,
};
}
export const ActionButtonsPropsSchema = z.object({
actions: z.array(ActionSchema).min(1),
align: z.enum(["left", "center", "right"]).optional(),
confirmTimeout: z.number().positive().optional(),
className: z.string().optional(),
});
export const SerializableActionSchema = ActionSchema.omit({ icon: true });
export const SerializableActionsSchema = ActionButtonsPropsSchema.extend({
actions: z.array(SerializableActionSchema),
}).omit({ className: true });
export interface ActionsConfig {
items: Action[];
align?: "left" | "center" | "right";
confirmTimeout?: number;
}
export const SerializableActionsConfigSchema = z.object({
items: z.array(SerializableActionSchema).min(1),
align: z.enum(["left", "center", "right"]).optional(),
confirmTimeout: z.number().positive().optional(),
});
export type SerializableActionsConfig = z.infer<
typeof SerializableActionsConfigSchema
>;
export type SerializableAction = z.infer<typeof SerializableActionSchema>;