mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
feat(mcp): add MCP UI variants and TUI output (#545)
Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 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/ui/cn";
|
||||
export { Button } from "../../ui/button";
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "../../ui/dropdown-menu";
|
||||
export {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "../../ui/accordion";
|
||||
export {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "../../ui/tooltip";
|
||||
export { Badge } from "../../ui/badge";
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "../../ui/table";
|
||||
@@ -0,0 +1,869 @@
|
||||
"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, getRowIdentifier } from "./utilities";
|
||||
import { renderFormattedValue } from "./formatters";
|
||||
import type {
|
||||
DataTableProps,
|
||||
DataTableContextValue,
|
||||
RowData,
|
||||
DataTableRowData,
|
||||
ColumnKey,
|
||||
Column,
|
||||
} from "./types";
|
||||
import { ActionButtons, normalizeActionsConfig } from "../shared";
|
||||
import type { FormatConfig } from "./formatters";
|
||||
import { DataTableErrorBoundary } from "./error-boundary";
|
||||
|
||||
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.useContext(DataTableContext) as
|
||||
| DataTableContextValue<T>
|
||||
| undefined;
|
||||
if (!context) {
|
||||
throw new Error("useDataTable must be used within a DataTable");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function DataTable<T extends object = RowData>({
|
||||
columns,
|
||||
data: rawData,
|
||||
rowIdKey,
|
||||
layout = "auto",
|
||||
defaultSort,
|
||||
sort: controlledSort,
|
||||
emptyMessage = "No data available",
|
||||
isLoading = false,
|
||||
maxHeight,
|
||||
id,
|
||||
onSortChange,
|
||||
className,
|
||||
locale,
|
||||
responseActions,
|
||||
onResponseAction,
|
||||
onBeforeResponseAction,
|
||||
}: DataTableProps<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,
|
||||
isLoading,
|
||||
locale: resolvedLocale,
|
||||
};
|
||||
|
||||
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]);
|
||||
|
||||
const normalizedFooterActions = React.useMemo(
|
||||
() => normalizeActionsConfig(responseActions),
|
||||
[responseActions],
|
||||
);
|
||||
|
||||
return (
|
||||
<DataTableContext.Provider value={contextValue}>
|
||||
<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
|
||||
}
|
||||
>
|
||||
<DataTableErrorBoundary>
|
||||
<Table aria-busy={isLoading || undefined}>
|
||||
{columns.length > 0 && (
|
||||
<colgroup>
|
||||
{columns.map((col) => (
|
||||
<col
|
||||
key={String(col.key)}
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
/>
|
||||
))}
|
||||
</colgroup>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<DataTableSkeleton />
|
||||
) : data.length === 0 ? (
|
||||
<DataTableEmpty message={emptyMessage} />
|
||||
) : (
|
||||
<DataTableContent />
|
||||
)}
|
||||
</Table>
|
||||
</DataTableErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
layout === "cards"
|
||||
? ""
|
||||
: layout === "table"
|
||||
? "hidden"
|
||||
: "@md:hidden",
|
||||
)}
|
||||
role="list"
|
||||
aria-label="Data table (mobile card view)"
|
||||
aria-describedby="mobile-table-description"
|
||||
>
|
||||
<div id="mobile-table-description" 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>
|
||||
|
||||
<DataTableErrorBoundary>
|
||||
{isLoading ? (
|
||||
<DataTableSkeletonCards />
|
||||
) : 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 keyVal = rowIdKey ? row[rowIdKey] : undefined;
|
||||
const rowKey = keyVal != null ? String(keyVal) : String(i);
|
||||
return (
|
||||
<DataTableAccordionCard
|
||||
key={rowKey}
|
||||
row={row as unknown as DataTableRowData}
|
||||
index={i}
|
||||
isFirst={i === 0}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</DataTableErrorBoundary>
|
||||
</div>
|
||||
|
||||
{sortAnnouncement && (
|
||||
<div className="sr-only" aria-live="polite">
|
||||
{sortAnnouncement}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{normalizedFooterActions ? (
|
||||
<div className="@container/actions mt-4">
|
||||
<ActionButtons
|
||||
actions={normalizedFooterActions.items}
|
||||
align={normalizedFooterActions.align}
|
||||
confirmTimeout={normalizedFooterActions.confirmTimeout}
|
||||
onAction={(id) => onResponseAction?.(id)}
|
||||
onBeforeAction={onBeforeResponseAction}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</DataTableContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
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 DataTableSkeleton() {
|
||||
const { columns } = useDataTable();
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTableHeader />
|
||||
<TableBody>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
{columns.map((_, j) => (
|
||||
<TableCell key={j}>
|
||||
<div className="bg-muted/50 h-4 rounded motion-safe:animate-pulse" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DataTableSkeletonCards() {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex flex-col gap-2 rounded-lg border p-4">
|
||||
<div className="bg-muted/50 h-5 w-1/2 rounded motion-safe:animate-pulse" />
|
||||
<div className="bg-muted/50 h-4 w-3/4 rounded motion-safe:animate-pulse" />
|
||||
<div className="bg-muted/50 h-4 w-2/3 rounded motion-safe:animate-pulse" />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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, isLoading } = 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 = isLoading || !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 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. Using array index as React key can cause reconciliation issues when data reorders (focus traps, animation glitches, incorrect state preservation). " +
|
||||
"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 keyVal = rowIdKey ? row[rowIdKey] : undefined;
|
||||
const rowKey = keyVal != null ? String(keyVal) : String(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;
|
||||
isFirst?: boolean;
|
||||
}
|
||||
|
||||
function DataTableAccordionCard({
|
||||
row,
|
||||
index,
|
||||
isFirst = false,
|
||||
}: DataTableAccordionCardProps) {
|
||||
const { columns, locale, rowIdKey } = useDataTable();
|
||||
|
||||
const { primary, secondary } = React.useMemo(
|
||||
() => categorizeColumns(columns),
|
||||
[columns],
|
||||
);
|
||||
|
||||
if (secondary.length === 0) {
|
||||
return (
|
||||
<SimpleCard row={row} columns={primary} index={index} isFirst={isFirst} />
|
||||
);
|
||||
}
|
||||
|
||||
const primaryColumn = primary[0];
|
||||
const remainingPrimaryColumns = primary.slice(1);
|
||||
|
||||
const stableRowId =
|
||||
getRowIdentifier(row, rowIdKey ? String(rowIdKey) : undefined) ||
|
||||
`${index}-${primaryColumn?.key ?? "row"}`;
|
||||
|
||||
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,
|
||||
isFirst = false,
|
||||
}: {
|
||||
row: DataTableRowData;
|
||||
columns: Column[];
|
||||
index: number;
|
||||
isFirst?: boolean;
|
||||
}) {
|
||||
const { locale, rowIdKey } = useDataTable();
|
||||
const primaryColumn = columns[0];
|
||||
const otherColumns = columns.slice(1);
|
||||
|
||||
const stableRowId =
|
||||
getRowIdentifier(row, rowIdKey ? String(rowIdKey) : undefined) ||
|
||||
`${index}-${primaryColumn?.key ?? "row"}`;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
ToolUIErrorBoundary,
|
||||
type ToolUIErrorBoundaryProps,
|
||||
} from "../shared";
|
||||
|
||||
export function DataTableErrorBoundary(
|
||||
props: Omit<ToolUIErrorBoundaryProps, "componentName">,
|
||||
) {
|
||||
const { children, ...rest } = props;
|
||||
return (
|
||||
<ToolUIErrorBoundary componentName="DataTable" {...rest}>
|
||||
{children}
|
||||
</ToolUIErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn, Badge, Tooltip, TooltipContent, TooltipTrigger } from "./_adapter";
|
||||
|
||||
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" }>;
|
||||
}
|
||||
|
||||
export function DeltaValue({ value, options }: 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 formatted = value.toFixed(decimals);
|
||||
const display = showSign && !isNegative ? `+${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" }>;
|
||||
}
|
||||
|
||||
export function PercentValue({ value, options }: PercentValueProps) {
|
||||
const decimals = options?.decimals ?? 2;
|
||||
const showSign = options?.showSign ?? false;
|
||||
const basis = options?.basis ?? "fraction";
|
||||
|
||||
const numeric = basis === "fraction" ? value * 100 : value;
|
||||
const absoluteFormatted = Math.abs(numeric).toFixed(decimals);
|
||||
const signed =
|
||||
numeric > 0 && showSign
|
||||
? `+${absoluteFormatted}`
|
||||
: numeric < 0
|
||||
? `-${absoluteFormatted}`
|
||||
: absoluteFormatted;
|
||||
|
||||
return <span className="tabular-nums">{signed}%</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>Invalid date</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.floor((now.getTime() - date.getTime()) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) return "just now";
|
||||
if (diffInSeconds < 3600) {
|
||||
const mins = Math.floor(diffInSeconds / 60);
|
||||
return `${mins} ${mins === 1 ? "minute" : "minutes"} ago`;
|
||||
}
|
||||
if (diffInSeconds < 86400) {
|
||||
const hours = Math.floor(diffInSeconds / 3600);
|
||||
return `${hours} ${hours === 1 ? "hour" : "hours"} ago`;
|
||||
}
|
||||
if (diffInSeconds < 604800) {
|
||||
const days = Math.floor(diffInSeconds / 86400);
|
||||
return `${days} ${days === 1 ? "day" : "days"} ago`;
|
||||
}
|
||||
|
||||
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 href =
|
||||
options?.hrefKey && row ? String(row[options.hrefKey] ?? "") : value;
|
||||
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} />;
|
||||
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} />;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export { DataTable, useDataTable } from "./data-table";
|
||||
export { DataTableErrorBoundary } from "./error-boundary";
|
||||
|
||||
export { renderFormattedValue } from "./formatters";
|
||||
export {
|
||||
NumberValue,
|
||||
CurrencyValue,
|
||||
PercentValue,
|
||||
DeltaValue,
|
||||
DateValue,
|
||||
BooleanValue,
|
||||
LinkValue,
|
||||
BadgeValue,
|
||||
StatusBadge,
|
||||
ArrayValue,
|
||||
} from "./formatters";
|
||||
export { parseSerializableDataTable } from "./schema";
|
||||
|
||||
export type {
|
||||
Column,
|
||||
DataTableProps,
|
||||
DataTableSerializableProps,
|
||||
DataTableClientProps,
|
||||
DataTableRowData,
|
||||
RowPrimitive,
|
||||
RowData,
|
||||
ColumnKey,
|
||||
} from "./types";
|
||||
export type { FormatConfig } from "./formatters";
|
||||
|
||||
export { sortData, parseNumericLike } from "./utilities";
|
||||
@@ -0,0 +1,234 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ToolUIIdSchema,
|
||||
ToolUIReceiptSchema,
|
||||
ToolUIRoleSchema,
|
||||
parseWithSchema,
|
||||
} from "../shared";
|
||||
import type { Column, DataTableProps, RowData } from "./types";
|
||||
|
||||
const AlignEnum = z.enum(["left", "right", "center"]);
|
||||
const PriorityEnum = z.enum(["primary", "secondary", "tertiary"]);
|
||||
const LayoutEnum = z.enum(["auto", "table", "cards"]);
|
||||
|
||||
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)
|
||||
* - layout: Optional layout override ('auto' | 'table' | 'cards')
|
||||
*
|
||||
* Non-serializable props like `onSortChange`, `className`, and `isLoading`
|
||||
* 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),
|
||||
layout: LayoutEnum.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* 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`, `onResponseAction`)
|
||||
* - React-specific props (`className`, `isLoading`, `responseActions`)
|
||||
*
|
||||
* @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, isLoading, className, responseActions, onResponseAction).
|
||||
*
|
||||
* @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}
|
||||
* responseActions={[{ id: "export", label: "Export" }]}
|
||||
* onResponseAction={(id) => console.log(id)}
|
||||
* />
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function parseSerializableDataTable(
|
||||
input: unknown,
|
||||
): Pick<
|
||||
DataTableProps<RowData>,
|
||||
"id" | "role" | "receipt" | "columns" | "data" | "layout"
|
||||
> {
|
||||
const { id, role, receipt, columns, data, layout } = parseWithSchema(
|
||||
SerializableDataTableSchema,
|
||||
input,
|
||||
"DataTable",
|
||||
);
|
||||
return {
|
||||
id,
|
||||
role,
|
||||
receipt,
|
||||
columns: columns as unknown as Column<RowData>[],
|
||||
data: data as RowData[],
|
||||
layout,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import type {
|
||||
ActionsProp,
|
||||
ToolUIId,
|
||||
ToolUIReceipt,
|
||||
ToolUIRole,
|
||||
} from "../shared";
|
||||
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[];
|
||||
/**
|
||||
* Layout mode for the component.
|
||||
* - 'auto' (default): Container queries choose table/cards
|
||||
* - 'table': Force table layout
|
||||
* - 'cards': Force stacked card layout
|
||||
*/
|
||||
layout?: "auto" | "table" | "cards";
|
||||
/**
|
||||
* 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 = {
|
||||
* isLoading: false,
|
||||
* className: "my-table",
|
||||
* onSortChange: (next) => setSort(next),
|
||||
* responseActions: [{ id: "export", label: "Export" }],
|
||||
* onResponseAction: (id) => console.log(id)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export interface DataTableClientProps<T extends object = RowData> {
|
||||
/** Show loading skeleton */
|
||||
isLoading?: boolean;
|
||||
/** 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;
|
||||
/** Optional response actions rendered below the table */
|
||||
responseActions?: ActionsProp;
|
||||
onResponseAction?: (actionId: string) => void | Promise<void>;
|
||||
onBeforeResponseAction?: (actionId: string) => boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}
|
||||
* responseActions={[{ id: "export", label: "Export" }]}
|
||||
* onResponseAction={(id) => handleAction(id)}
|
||||
* isLoading={loading}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
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;
|
||||
isLoading?: boolean;
|
||||
locale?: string;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, "");
|
||||
|
||||
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
|
||||
const frac = s.length - lastComma - 1;
|
||||
if (frac === 2 || frac === 3) s = s.replace(/,/g, ".");
|
||||
else s = s.replace(/,/g, "");
|
||||
} else if (lastDot !== -1) {
|
||||
// Only dot present; if multiple dots, treat as thousands and strip
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
export { cn } from "../../../lib/ui/cn";
|
||||
export { Button } from "../../ui/button";
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import type { Action } from "./schema";
|
||||
import { cn, Button } from "./_adapter";
|
||||
import { useActionButtons } from "./use-action-buttons";
|
||||
|
||||
export interface ActionButtonsProps {
|
||||
actions: Action[];
|
||||
onAction: (actionId: string) => void | Promise<void>;
|
||||
onBeforeAction?: (actionId: string) => boolean | Promise<boolean>;
|
||||
confirmTimeout?: number;
|
||||
align?: "left" | "center" | "right";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ActionButtons({
|
||||
actions,
|
||||
onAction,
|
||||
onBeforeAction,
|
||||
confirmTimeout = 3000,
|
||||
align = "right",
|
||||
className,
|
||||
}: ActionButtonsProps) {
|
||||
const { actions: resolvedActions, runAction } = useActionButtons({
|
||||
actions,
|
||||
onAction,
|
||||
onBeforeAction,
|
||||
confirmTimeout,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-3",
|
||||
"@sm/actions:flex-row @sm/actions:flex-wrap @sm/actions:items-center @sm/actions:gap-2",
|
||||
align === "left" && "@sm/actions:justify-start",
|
||||
align === "center" && "@sm/actions:justify-center",
|
||||
align === "right" && "@sm/actions:justify-end",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{resolvedActions.map((action) => {
|
||||
const label = action.currentLabel;
|
||||
const variant = action.variant || "default";
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={action.id}
|
||||
variant={variant}
|
||||
onClick={() => runAction(action.id)}
|
||||
disabled={action.isDisabled}
|
||||
className={cn(
|
||||
"rounded-full px-4!",
|
||||
"justify-center",
|
||||
"min-h-11 w-full text-base",
|
||||
"@sm/actions:min-h-0 @sm/actions:w-auto @sm/actions:px-3 @sm/actions:py-2 @sm/actions:text-sm",
|
||||
action.isConfirming &&
|
||||
"ring-destructive ring-2 ring-offset-2 motion-safe:animate-pulse",
|
||||
)}
|
||||
aria-label={
|
||||
action.shortcut ? `${label} (${action.shortcut})` : label
|
||||
}
|
||||
>
|
||||
{action.isLoading && (
|
||||
<svg
|
||||
className="mr-2 h-4 w-4 motion-safe:animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{action.icon && !action.isLoading && (
|
||||
<span className="mr-2">{action.icon}</span>
|
||||
)}
|
||||
{label}
|
||||
{action.shortcut && !action.isLoading && (
|
||||
<kbd className="border-border bg-muted ml-2.5 hidden rounded-lg border px-2 py-0.5 font-mono text-xs font-medium sm:inline-block">
|
||||
{action.shortcut}
|
||||
</kbd>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Action, ActionsConfig } from "./schema";
|
||||
|
||||
export type ActionsProp = ActionsConfig | Action[];
|
||||
|
||||
const NEGATORY_ACTION_IDS = new Set([
|
||||
"cancel",
|
||||
"dismiss",
|
||||
"skip",
|
||||
"no",
|
||||
"reset",
|
||||
"close",
|
||||
"decline",
|
||||
"reject",
|
||||
"back",
|
||||
"later",
|
||||
"not-now",
|
||||
"maybe-later",
|
||||
]);
|
||||
|
||||
function inferVariant(action: Action): Action {
|
||||
if (action.variant) return action;
|
||||
if (NEGATORY_ACTION_IDS.has(action.id)) {
|
||||
return { ...action, variant: "ghost" };
|
||||
}
|
||||
return action;
|
||||
}
|
||||
|
||||
export function normalizeActionsConfig(
|
||||
actions?: ActionsProp,
|
||||
): ActionsConfig | null {
|
||||
if (!actions) return null;
|
||||
|
||||
const rawItems = Array.isArray(actions) ? actions : (actions.items ?? []);
|
||||
|
||||
if (rawItems.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const items = rawItems.map(inferVariant);
|
||||
|
||||
return Array.isArray(actions)
|
||||
? { items }
|
||||
: {
|
||||
items,
|
||||
align: actions.align,
|
||||
confirmTimeout: actions.confirmTimeout,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
export interface ToolUIErrorBoundaryProps {
|
||||
componentName: string;
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ReactNode;
|
||||
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
|
||||
}
|
||||
|
||||
interface ToolUIErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export class ToolUIErrorBoundary extends React.Component<
|
||||
ToolUIErrorBoundaryProps,
|
||||
ToolUIErrorBoundaryState
|
||||
> {
|
||||
constructor(props: ToolUIErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ToolUIErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error(`[${this.props.componentName}] render error:`, error, errorInfo);
|
||||
this.props.onError?.(error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
this.props.fallback ?? (
|
||||
<div className="border-destructive text-destructive rounded-lg border p-4">
|
||||
<p className="font-semibold">
|
||||
{this.props.componentName} failed to render
|
||||
</p>
|
||||
<p className="text-sm">{this.state.error?.message}</p>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export { ActionButtons } from "./action-buttons";
|
||||
export type { ActionButtonsProps } from "./action-buttons";
|
||||
export { normalizeActionsConfig, type ActionsProp } from "./actions-config";
|
||||
export * from "./error-boundary";
|
||||
export * from "./parse";
|
||||
export * from "./schema";
|
||||
export * from "./use-copy-to-clipboard";
|
||||
export * from "./utils";
|
||||
@@ -0,0 +1,27 @@
|
||||
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";
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 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]}`;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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";
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Sanitize a URL to ensure it's safe for use in href attributes.
|
||||
* Only allows http: and https: protocols.
|
||||
*
|
||||
* @returns The sanitized URL string, or undefined if invalid/unsafe
|
||||
*/
|
||||
export function sanitizeHref(href?: string): string | undefined {
|
||||
if (!href) return undefined;
|
||||
try {
|
||||
const url = new URL(href);
|
||||
if (url.protocol === "http:" || url.protocol === "https:") {
|
||||
return url.toString();
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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)`
|
||||
* - Actions: `responseActions`, `onResponseAction`, `onBeforeResponseAction`
|
||||
* - 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 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>;
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { Action } from "./schema";
|
||||
|
||||
export type UseActionButtonsOptions = {
|
||||
actions: Action[];
|
||||
onAction: (actionId: string) => void | Promise<void>;
|
||||
onBeforeAction?: (actionId: string) => boolean | Promise<boolean>;
|
||||
confirmTimeout?: number;
|
||||
};
|
||||
|
||||
export type UseActionButtonsResult = {
|
||||
actions: Array<
|
||||
Action & {
|
||||
currentLabel: string;
|
||||
isConfirming: boolean;
|
||||
isExecuting: boolean;
|
||||
isDisabled: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
>;
|
||||
runAction: (actionId: string) => Promise<void>;
|
||||
confirmingActionId: string | null;
|
||||
executingActionId: string | null;
|
||||
};
|
||||
|
||||
export function useActionButtons(
|
||||
options: UseActionButtonsOptions,
|
||||
): UseActionButtonsResult {
|
||||
const {
|
||||
actions,
|
||||
onAction,
|
||||
onBeforeAction,
|
||||
confirmTimeout = 3000,
|
||||
} = options;
|
||||
|
||||
const [confirmingActionId, setConfirmingActionId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [executingActionId, setExecutingActionId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!confirmingActionId) return;
|
||||
const id = setTimeout(() => setConfirmingActionId(null), confirmTimeout);
|
||||
return () => clearTimeout(id);
|
||||
}, [confirmingActionId, confirmTimeout]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!confirmingActionId) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setConfirmingActionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [confirmingActionId]);
|
||||
|
||||
const runAction = useCallback(
|
||||
async (actionId: string) => {
|
||||
const action = actions.find((a) => a.id === actionId);
|
||||
if (!action) return;
|
||||
|
||||
const isAnyActionExecuting = executingActionId !== null;
|
||||
if (action.disabled || action.loading || isAnyActionExecuting) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.confirmLabel && confirmingActionId !== action.id) {
|
||||
setConfirmingActionId(action.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (onBeforeAction) {
|
||||
const shouldProceed = await onBeforeAction(action.id);
|
||||
if (!shouldProceed) {
|
||||
setConfirmingActionId(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
setExecutingActionId(action.id);
|
||||
await onAction(action.id);
|
||||
} finally {
|
||||
setExecutingActionId(null);
|
||||
setConfirmingActionId(null);
|
||||
}
|
||||
},
|
||||
[actions, confirmingActionId, executingActionId, onAction, onBeforeAction],
|
||||
);
|
||||
|
||||
const resolvedActions = useMemo(
|
||||
() =>
|
||||
actions.map((action) => {
|
||||
const isConfirming = confirmingActionId === action.id;
|
||||
const isThisActionExecuting = executingActionId === action.id;
|
||||
const isLoading = action.loading || isThisActionExecuting;
|
||||
const isDisabled =
|
||||
action.disabled || (executingActionId !== null && !isThisActionExecuting);
|
||||
const currentLabel =
|
||||
isConfirming && action.confirmLabel
|
||||
? action.confirmLabel
|
||||
: action.label;
|
||||
|
||||
return {
|
||||
...action,
|
||||
currentLabel,
|
||||
isConfirming,
|
||||
isExecuting: isThisActionExecuting,
|
||||
isDisabled,
|
||||
isLoading,
|
||||
};
|
||||
}),
|
||||
[actions, confirmingActionId, executingActionId],
|
||||
);
|
||||
|
||||
return {
|
||||
actions: resolvedActions,
|
||||
runAction,
|
||||
confirmingActionId,
|
||||
executingActionId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
function fallbackCopyToClipboard(text: string): boolean {
|
||||
try {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
textArea.setAttribute("readonly", "");
|
||||
textArea.style.position = "fixed";
|
||||
textArea.style.top = "-9999px";
|
||||
textArea.style.left = "-9999px";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
const ok = document.execCommand("copy");
|
||||
document.body.removeChild(textArea);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function useCopyToClipboard(options?: {
|
||||
resetAfterMs?: number;
|
||||
}): {
|
||||
copiedId: string | null;
|
||||
copy: (text: string, id?: string) => Promise<boolean>;
|
||||
} {
|
||||
const resetAfterMs = options?.resetAfterMs ?? 2000;
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
const copy = useCallback(
|
||||
async (text: string, id: string = "default") => {
|
||||
let ok = false;
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
ok = true;
|
||||
} else {
|
||||
ok = fallbackCopyToClipboard(text);
|
||||
}
|
||||
} catch {
|
||||
ok = fallbackCopyToClipboard(text);
|
||||
}
|
||||
|
||||
if (ok) {
|
||||
setCopiedId(id);
|
||||
}
|
||||
|
||||
return ok;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!copiedId) return;
|
||||
const timeout = setTimeout(() => setCopiedId(null), resetAfterMs);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [copiedId, resetAfterMs]);
|
||||
|
||||
return { copiedId, copy };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export function formatRelativeTime(iso: string): string {
|
||||
const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
|
||||
if (seconds < 86400) return `${Math.round(seconds / 3600)}h`;
|
||||
if (seconds < 604800) return `${Math.round(seconds / 86400)}d`;
|
||||
return `${Math.round(seconds / 604800)}w`;
|
||||
}
|
||||
|
||||
export function formatCount(count: number): string {
|
||||
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
||||
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`;
|
||||
return String(count);
|
||||
}
|
||||
|
||||
export function getDomain(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function prefersReducedMotion(): boolean {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "../../lib/ui/cn";
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:scale-y-[-1]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="group data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-y-clip overflow-x-visible text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"pt-0 pb-4",
|
||||
"group-data-[state=open]:animate-in group-data-[state=open]:fade-in-0",
|
||||
"group-data-[state=closed]:animate-out group-data-[state=closed]:fade-out-0",
|
||||
"duration-200",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "../../lib/ui/cn";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
warning:
|
||||
"border-transparent bg-amber-500/10 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 [a&]:hover:bg-amber-500/20 dark:[a&]:hover:bg-amber-500/30",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,63 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "../../lib/ui/cn";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
homeCTA: "rounded-full px-6 py-3 text-lg w-fit text-start",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "../../lib/ui/cn";
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "../../lib/ui/cn";
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "../../lib/ui/cn";
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
Reference in New Issue
Block a user