[eric] chat: vendor tool-ui component library (21 components, MIT) behind ShowUI; scoped Tailwind v4 utilities, upstream zod contracts, React 18 ports

This commit is contained in:
ciregenz
2026-07-19 22:31:00 -07:00
parent 27ff21176c
commit 3eb8969c87
193 changed files with 24236 additions and 24 deletions
+24
View File
@@ -16,6 +16,28 @@ COMPONENT_SPECS = {
"plan": "props: {title?: str, steps: [{label: str, status: 'pending'|'in_progress'|'completed'}] (max 20)}",
"stats": "props: {title?: str, stats: [{label: str, value: str, delta?: str, direction?: 'up'|'down'}] (max 8)}",
"links": "props: {links: [{title: str, url: str, description?: str}] (max 10)}",
# tool-ui vendored set: props follow the upstream Serializable contracts (https://tool-ui.com);
# the client validates strictly and shows a validation note instead of rendering on mismatch.
"data-table": "tabular results. props: {id: str, columns: [{key: str, label: str}], data: [{<key>: str|number|bool}]}",
"citation": "sourced claims. props: {id: str, citations: [{id: str, title: str, url?: str, snippet?: str}]}",
"item-carousel": "browsable items. props: {id: str, items: [{id: str, title: str, description?: str, imageUrl?: str, badge?: str}]}",
"link-preview": "one rich link card. props: {id: str, url: str, title: str, description?: str, imageUrl?: str, siteName?: str}",
"progress-tracker": "multi-stage progress. props: {id: str, stages: [{id: str, label: str, status: 'pending'|'active'|'complete'|'error'}]}",
"order-summary": "purchase/receipt breakdown. props: {id: str, items: [{id: str, label: str, amount: number}], total?: number, currency?: str}",
"terminal": "command output. props: {id: str, command?: str, output: str}",
"image": "single image. props: {id: str, src: str, alt?: str, caption?: str}",
"image-gallery": "several images. props: {id: str, images: [{src: str, alt?: str}]}",
"video": "video embed. props: {id: str, src: str, poster?: str, title?: str}",
"message-draft": "email/message draft for review. props: {id: str, to?: [str], subject?: str, body: str}",
"x-post": "an X/Twitter post preview. props: {id: str, author: {name: str, handle: str}, text: str}",
"linkedin-post": "a LinkedIn post preview. props: {id: str, author: {name: str, headline?: str}, text: str}",
"instagram-post": "an Instagram post preview. props: {id: str, username: str, imageUrl: str, caption?: str}",
"option-list": "choices for the user (display for now). props: {id: str, options: [{id: str, label: str, description?: str}], selectionMode?: 'single'|'multi'}",
"question-flow": "step-by-step question sequence (display for now). props follow the upstream question-flow contract",
"parameter-slider": "adjustable parameters (display for now). props: {id: str, parameters: [{id: str, label: str, min: number, max: number, value: number, step?: number}]}",
"preferences-panel": "grouped preference toggles (display for now). props follow the upstream preferences-panel contract",
"approval-card": "an approve/reject summary card. props follow the upstream approval-card contract",
"stats-display": "upstream stats-display contract (prefer 'stats' unless you need its exact shape)",
}
TOOLS = [
@@ -74,6 +96,8 @@ def validate(component: str, props: dict) -> str:
return f"stats needs a non-empty stats list. {COMPONENT_SPECS['stats']}"
if component == "links" and not (isinstance(props.get("links"), list) and props["links"]):
return f"links needs a non-empty links list. {COMPONENT_SPECS['links']}"
# Vendored tool-ui components validate deeply client-side against their zod contracts; here we
# only shape-check so a wrong payload comes back as a teaching error instead of a dead render.
return ""
+2821 -20
View File
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -21,23 +21,31 @@
"@mui/material": "^7.3.9",
"@reduxjs/toolkit": "^2.8.2",
"@types/react-syntax-highlighter": "^15.5.13",
"ansi-to-react": "^6.2.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"codemirror": "^6.0.2",
"framer-motion": "^12.35.2",
"html-to-image": "^1.11.13",
"lucide-react": "^1.17.0",
"radix-ui": "^1.6.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"react-redux": "^9.2.0",
"react-router-dom": "^7.13.1",
"react-syntax-highlighter": "^16.1.1",
"remark-gfm": "^4.0.1"
"recharts": "^3.9.2",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.6.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@babel/core": "^7.28.0",
"@babel/preset-env": "^7.28.0",
"@babel/preset-react": "^7.27.1",
"@babel/preset-typescript": "^7.27.1",
"@tailwindcss/postcss": "^4.3.3",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/react-redux": "^7.1.34",
@@ -46,9 +54,13 @@
"css-loader": "^6.8.0",
"css-modules-types-loader": "^0.6.10",
"html-webpack-plugin": "^5.5.0",
"postcss": "^8.5.20",
"postcss-loader": "^8.2.1",
"sass": "^1.89.2",
"sass-loader": "^16.0.5",
"style-loader": "^3.3.0",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
"typescript": "^5.0.0",
"webpack": "^5.88.0",
"webpack-cli": "^5.1.0",
@@ -3,6 +3,7 @@ import WeatherWidget from './WeatherWidget';
import PlanWidget from './PlanWidget';
import StatsWidget from './StatsWidget';
import LinksWidget from './LinksWidget';
import VendoredToolUi from '@toolui/VendoredToolUi';
import type { ShowUiPayload } from './showUiPayload';
/** One switch for every surface that renders a ShowUI payload (chat bubble, pill artifact). */
@@ -11,6 +12,7 @@ function ShowUiWidgetView({ payload }: { payload: ShowUiPayload }): React.ReactE
if (payload.component === 'plan') return <PlanWidget props={payload.props} />;
if (payload.component === 'stats') return <StatsWidget props={payload.props} />;
if (payload.component === 'links') return <LinksWidget props={payload.props} />;
if (payload.component === 'vendored') return <VendoredToolUi name={payload.name} props={payload.props} />;
return null;
}
@@ -1,4 +1,5 @@
import type { ToolPair } from '../tool-bubbles/ToolCallBubble';
import { isToolUiComponent } from '@toolui/registry';
export interface WeatherForecastDay {
day: string;
@@ -53,7 +54,8 @@ export type ShowUiPayload =
| { component: 'weather'; props: WeatherProps }
| { component: 'plan'; props: PlanProps }
| { component: 'stats'; props: StatsProps }
| { component: 'links'; props: LinksProps };
| { component: 'links'; props: LinksProps }
| { component: 'vendored'; name: string; props: Record<string, unknown> };
function num(v: unknown): v is number {
return typeof v === 'number' && Number.isFinite(v);
@@ -89,6 +91,14 @@ export function parseShowUiPayload(pair: ToolPair): ShowUiPayload | null {
function parseShowUiInput(input: unknown): ShowUiPayload | null {
if (!input || typeof input !== 'object') return null;
{
const name = String((input as { component?: unknown }).component || '');
const rawProps = (input as { props?: unknown }).props;
if (isToolUiComponent(name) && rawProps && typeof rawProps === 'object') {
// Vendored components carry their own zod contract; deep validation happens at render.
return { component: 'vendored', name, props: rawProps as Record<string, unknown> };
}
}
const component = String((input as { component?: unknown }).component || '');
const props = (input as { props?: unknown }).props;
if (!props || typeof props !== 'object') return null;
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 AgentbaseAI Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+59
View File
@@ -0,0 +1,59 @@
import React, { Suspense, useEffect, useState } from 'react';
import { useThemeMode } from '@/shared/styles/ThemeContext';
import { TOOL_UI_REGISTRY } from './registry';
interface VendoredToolUiProps {
name: string;
props: Record<string, unknown>;
}
type Gate = 'pending' | 'ok' | 'bad';
/** Validates against the upstream zod contract, then renders the vendored component inside the scoped theme. */
function VendoredToolUi({ name, props }: VendoredToolUiProps): React.ReactElement | null {
const { mode } = useThemeMode();
const entry = TOOL_UI_REGISTRY[name];
const [gate, setGate] = useState<Gate>('pending');
const [problem, setProblem] = useState<string>('');
useEffect(() => {
let cancelled = false;
if (!entry) return undefined;
entry
.loadSchema()
.then((schema) => {
if (cancelled) return;
const result = schema.safeParse(props);
if (result.success) {
setGate('ok');
} else {
setGate('bad');
setProblem(result.error.issues.slice(0, 2).map((i) => `${i.path.join('.')}: ${i.message}`).join('; '));
}
})
.catch(() => { if (!cancelled) { setGate('bad'); setProblem('component failed to load'); } });
return () => { cancelled = true; };
}, [entry, props]);
if (!entry) return null;
if (gate === 'bad') {
return (
<div style={{ fontSize: '0.75rem', opacity: 0.55, padding: '4px 0' }}>
{name} payload didn't validate ({problem})
</div>
);
}
if (gate === 'pending') {
return <div style={{ height: 48, width: 280, borderRadius: 12, background: 'rgba(127,127,127,0.12)' }} />;
}
const Component = entry.Component;
return (
<div className={`tool-ui-scope${mode === 'dark' ? ' dark' : ''}`}>
<Suspense fallback={<div style={{ height: 48, width: 280, borderRadius: 12, background: 'rgba(127,127,127,0.12)' }} />}>
<Component {...props} />
</Suspense>
</div>
);
}
export default VendoredToolUi;
@@ -0,0 +1,19 @@
# Approval Card
Implementation for the "approval-card" Tool UI surface.
## Files
- public exports: components/tool-ui/approval-card/index.tsx
- serializable schema + parse helpers: components/tool-ui/approval-card/schema.ts
## Companion assets
- Docs page: app/docs/approval-card/content.mdx
- Preset payload: lib/presets/approval-card.ts
## Quick check
Run this after edits:
pnpm test
@@ -0,0 +1,11 @@
/**
* 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., "@toolui/lib/utils", "~/lib/cn")
*/
export { cn } from "@toolui/lib/utils";
export { Separator } from "@toolui/ui/separator";
@@ -0,0 +1,212 @@
"use client";
import * as React from "react";
import { cn, Separator } from "./_adapter";
import type { ApprovalCardProps, ApprovalDecision } from "./schema";
import { ActionButtons } from "../shared/action-buttons";
import { type Action } from "../shared/schema";
import { icons, Check, X } from "lucide-react";
type LucideIcon = React.ComponentType<{ className?: string }>;
function getLucideIcon(name: string): LucideIcon | null {
const pascalName = name
.split("-")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join("");
const Icon = icons[pascalName as keyof typeof icons];
return Icon ?? null;
}
interface ApprovalCardReceiptProps {
id: string;
title: string;
choice: ApprovalDecision;
actionLabel?: string;
className?: string;
}
function ApprovalCardReceipt({
id,
title,
choice,
actionLabel,
className,
}: ApprovalCardReceiptProps) {
const isApproved = choice === "approved";
const displayLabel = actionLabel ?? (isApproved ? "Approved" : "Denied");
return (
<div
className={cn(
"flex w-full min-w-64 max-w-md flex-col",
"text-foreground",
"motion-safe:animate-in motion-safe:fade-in motion-safe:blur-in-sm motion-safe:zoom-in-95 motion-safe:duration-300 motion-safe:ease-[cubic-bezier(0.16,1,0.3,1)] motion-safe:fill-mode-both",
className,
)}
data-slot="approval-card"
data-tool-ui-id={id}
data-receipt="true"
role="status"
aria-label={displayLabel}
>
<div
className={cn(
"bg-card/60 flex w-full items-center gap-3 rounded-2xl border px-4 py-3 shadow-xs",
)}
>
<span
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-full bg-muted",
isApproved ? "text-primary" : "text-muted-foreground",
)}
>
{isApproved ? <Check className="size-4" /> : <X className="size-4" />}
</span>
<div className="flex flex-col">
<span className="text-sm font-medium">{displayLabel}</span>
<span className="text-muted-foreground text-sm">{title}</span>
</div>
</div>
</div>
);
}
export function ApprovalCard({
id,
title,
description,
icon,
metadata,
variant,
confirmLabel,
cancelLabel,
className,
choice,
onConfirm,
onCancel,
}: ApprovalCardProps) {
const resolvedVariant = variant ?? "default";
const resolvedConfirmLabel = confirmLabel ?? "Approve";
const resolvedCancelLabel = cancelLabel ?? "Deny";
const Icon = icon ? getLucideIcon(icon) : null;
const handleAction = React.useCallback(
async (actionId: string) => {
if (actionId === "confirm") {
await onConfirm?.();
} else if (actionId === "cancel") {
await onCancel?.();
}
},
[onConfirm, onCancel],
);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onCancel?.();
}
},
[onCancel],
);
const isDestructive = resolvedVariant === "destructive";
const actions: Action[] = [
{
id: "cancel",
label: resolvedCancelLabel,
variant: "ghost",
},
{
id: "confirm",
label: resolvedConfirmLabel,
variant: isDestructive ? "destructive" : "default",
},
];
const viewKey = choice ? `receipt-${choice}` : "interactive";
return (
<div key={viewKey} className="contents">
{choice ? (
<ApprovalCardReceipt
id={id}
title={title}
choice={choice}
className={className}
/>
) : (
<article
className={cn(
"flex w-full min-w-64 max-w-md flex-col gap-3",
"text-foreground",
className,
)}
data-slot="approval-card"
data-tool-ui-id={id}
role="dialog"
aria-labelledby={`${id}-title`}
aria-describedby={description ? `${id}-description` : undefined}
onKeyDown={handleKeyDown}
>
<div className="bg-card flex w-full flex-col gap-4 rounded-2xl border p-5 shadow-xs">
<div className="flex items-start gap-3">
{Icon && (
<span
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-xl",
isDestructive
? "bg-destructive/10 text-destructive"
: "bg-primary/10 text-primary",
)}
>
<Icon className="size-5" />
</span>
)}
<div className="flex flex-1 flex-col gap-1">
<h2
id={`${id}-title`}
className="text-base font-semibold leading-tight"
>
{title}
</h2>
{description && (
<p
id={`${id}-description`}
className="text-muted-foreground text-sm"
>
{description}
</p>
)}
</div>
</div>
{metadata && metadata.length > 0 && (
<>
<Separator />
<dl className="flex flex-col gap-2 text-sm">
{metadata.map((item, index) => (
<div key={index} className="flex justify-between gap-4">
<dt className="text-muted-foreground shrink-0">
{item.key}
</dt>
<dd className="min-w-0 truncate">{item.value}</dd>
</div>
))}
</dl>
</>
)}
</div>
<div className="@container/actions">
<ActionButtons actions={actions} onAction={handleAction} />
</div>
</article>
)}
</div>
);
}
@@ -0,0 +1,7 @@
export { ApprovalCard } from "./approval-card";
export {
type SerializableApprovalCard,
type ApprovalCardProps,
type ApprovalDecision,
type MetadataItem,
} from "./schema";
@@ -0,0 +1,54 @@
import { z } from "zod";
import { ToolUIIdSchema, ToolUIRoleSchema } from "../shared/schema";
import { defineToolUiContract } from "../shared/contract";
export const MetadataItemSchema = z.object({
key: z.string().min(1),
value: z.string(),
});
export type MetadataItem = z.infer<typeof MetadataItemSchema>;
export const ApprovalDecisionSchema = z.enum(["approved", "denied"]);
export type ApprovalDecision = z.infer<typeof ApprovalDecisionSchema>;
export const SerializableApprovalCardSchema = z.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
title: z.string().min(1),
description: z.string().optional(),
icon: z.string().optional(),
metadata: z.array(MetadataItemSchema).optional(),
variant: z.enum(["default", "destructive"]).optional(),
confirmLabel: z.string().optional(),
cancelLabel: z.string().optional(),
choice: ApprovalDecisionSchema.optional(),
});
export type SerializableApprovalCard = z.infer<
typeof SerializableApprovalCardSchema
>;
const SerializableApprovalCardSchemaContract = defineToolUiContract(
"ApprovalCard",
SerializableApprovalCardSchema,
);
export const parseSerializableApprovalCard: (
input: unknown,
) => SerializableApprovalCard = SerializableApprovalCardSchemaContract.parse;
export const safeParseSerializableApprovalCard: (
input: unknown,
) => SerializableApprovalCard | null =
SerializableApprovalCardSchemaContract.safeParse;
export interface ApprovalCardProps extends SerializableApprovalCard {
className?: string;
onConfirm?: () => void | Promise<void>;
onCancel?: () => void | Promise<void>;
}
@@ -0,0 +1,19 @@
# Citation
Implementation for the "citation" Tool UI surface.
## Files
- public exports: components/tool-ui/citation/index.ts
- serializable schema + parse helpers: components/tool-ui/citation/schema.ts
## Companion assets
- Docs page: app/docs/citation/content.mdx
- Preset payload: lib/presets/citation.ts
## Quick check
Run this after edits:
pnpm test
@@ -0,0 +1,18 @@
/**
* 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., "@toolui/lib/utils", "~/lib/cn")
* Tooltip → shadcn/ui Tooltip (only needed for variant="inline")
* Popover → shadcn/ui Popover (only needed for CitationList)
*/
"use client";
export { cn } from "@toolui/lib/utils";
export {
Popover,
PopoverContent,
PopoverTrigger,
} from "@toolui/ui/popover";
@@ -0,0 +1,460 @@
"use client";
import * as React from "react";
import type { LucideIcon } from "lucide-react";
import {
FileText,
Globe,
Code2,
Newspaper,
Database,
File,
ExternalLink,
} from "lucide-react";
import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter";
import { Citation } from "./citation";
import type {
SerializableCitation,
CitationType,
CitationVariant,
} from "./schema";
import {
openSafeNavigationHref,
resolveSafeNavigationHref,
} from "../shared/media";
const TYPE_ICONS: Record<CitationType, LucideIcon> = {
webpage: Globe,
document: FileText,
article: Newspaper,
api: Database,
code: Code2,
other: File,
};
function useHoverPopover(delay = 100) {
const [open, setOpen] = React.useState(false);
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const handleMouseEnter = React.useCallback(() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setOpen(true), delay);
}, [delay]);
const handleMouseLeave = React.useCallback(() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setOpen(false), delay);
}, [delay]);
const handleFocus = React.useCallback(() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setOpen(true);
}, []);
const handleBlur = React.useCallback(
(e: React.FocusEvent) => {
const relatedTarget = e.relatedTarget as HTMLElement | null;
if (containerRef.current?.contains(relatedTarget)) {
return;
}
if (relatedTarget?.closest("[data-radix-popper-content-wrapper]")) {
return;
}
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setOpen(false), delay);
},
[delay],
);
React.useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []);
return {
open,
setOpen,
containerRef,
handleMouseEnter,
handleMouseLeave,
handleFocus,
handleBlur,
};
}
export interface CitationListProps {
id: string;
citations: SerializableCitation[];
variant?: CitationVariant;
maxVisible?: number;
className?: string;
onNavigate?: (href: string, citation: SerializableCitation) => void;
}
export function CitationList(props: CitationListProps) {
const {
id,
citations,
variant = "default",
maxVisible,
className,
onNavigate,
} = props;
const shouldTruncate =
maxVisible !== undefined && citations.length > maxVisible;
const visibleCitations = shouldTruncate
? citations.slice(0, maxVisible)
: citations;
const overflowCitations = shouldTruncate ? citations.slice(maxVisible) : [];
const overflowCount = overflowCitations.length;
const wrapperClass =
variant === "inline"
? "flex flex-wrap items-center gap-1.5"
: "flex flex-col gap-2";
// Stacked variant: overlapping favicons with popover
if (variant === "stacked") {
return (
<StackedCitations
id={id}
citations={citations}
className={className}
onNavigate={onNavigate}
/>
);
}
if (variant === "default") {
return (
<div
className={cn("isolate flex flex-col gap-4", className)}
data-tool-ui-id={id}
data-slot="citation-list"
>
{visibleCitations.map((citation) => (
<Citation
key={citation.id}
{...citation}
variant="default"
onNavigate={onNavigate}
/>
))}
{shouldTruncate && (
<OverflowIndicator
citations={overflowCitations}
count={overflowCount}
variant="default"
onNavigate={onNavigate}
/>
)}
</div>
);
}
return (
<div
className={cn("isolate", wrapperClass, className)}
data-tool-ui-id={id}
data-slot="citation-list"
>
{visibleCitations.map((citation) => (
<Citation
key={citation.id}
{...citation}
variant={variant}
onNavigate={onNavigate}
/>
))}
{shouldTruncate && (
<OverflowIndicator
citations={overflowCitations}
count={overflowCount}
variant={variant}
onNavigate={onNavigate}
/>
)}
</div>
);
}
interface OverflowIndicatorProps {
citations: SerializableCitation[];
count: number;
variant: CitationVariant;
onNavigate?: (href: string, citation: SerializableCitation) => void;
}
function OverflowIndicator({
citations,
count,
variant,
onNavigate,
}: OverflowIndicatorProps) {
const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover();
const handleClick = (citation: SerializableCitation) => {
const href = resolveSafeNavigationHref(citation.href);
if (!href) return;
if (onNavigate) {
onNavigate(href, citation);
} else {
openSafeNavigationHref(href);
}
};
const popoverContent = (
<div className="flex max-h-72 flex-col overflow-y-auto">
{citations.map((citation) => (
<OverflowItem
key={citation.id}
citation={citation}
onClick={() => handleClick(citation)}
/>
))}
</div>
);
if (variant === "inline") {
return (
<Popover open={open}>
<PopoverTrigger asChild>
<button
type="button"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className={cn(
"inline-flex items-center gap-1 rounded-md px-2 py-1",
"bg-muted/60 text-sm tabular-nums",
"transition-colors duration-150",
"hover:bg-muted",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none",
)}
>
<span className="text-muted-foreground">+{count} more</span>
</button>
</PopoverTrigger>
<PopoverContent
side="top"
align="start"
className="w-80 p-1"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onOpenAutoFocus={(e) => e.preventDefault()}
>
{popoverContent}
</PopoverContent>
</Popover>
);
}
// Default variant
return (
<Popover open={open}>
<PopoverTrigger asChild>
<button
type="button"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className={cn(
"flex items-center justify-center rounded-xl px-4 py-3",
"border-border bg-card border border-dashed",
"transition-colors duration-150",
"hover:border-foreground/25 hover:bg-muted/50",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
)}
>
<span className="text-muted-foreground text-sm tabular-nums">
+{count} more sources
</span>
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
className="w-80 p-1"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onOpenAutoFocus={(e) => e.preventDefault()}
>
{popoverContent}
</PopoverContent>
</Popover>
);
}
interface OverflowItemProps {
citation: SerializableCitation;
onClick: () => void;
}
function OverflowItem({ citation, onClick }: OverflowItemProps) {
const TypeIcon = TYPE_ICONS[citation.type ?? "webpage"] ?? Globe;
return (
<button
type="button"
onClick={onClick}
className="group hover:bg-muted focus-visible:bg-muted flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2 py-2 text-left transition-colors focus-visible:outline-none"
>
{citation.favicon ? (
<img
src={citation.favicon}
alt=""
aria-hidden="true"
width={16}
height={16}
className="bg-muted size-4 shrink-0 rounded object-cover"
/>
) : (
<TypeIcon
className="text-muted-foreground size-4 shrink-0"
aria-hidden="true"
/>
)}
<div className="min-w-0 flex-1">
<p className="group-hover:decoration-foreground/30 truncate text-sm font-medium group-hover:underline group-hover:underline-offset-2">
{citation.title}
</p>
<p className="text-muted-foreground truncate text-xs">
{citation.domain}
</p>
</div>
<ExternalLink className="text-muted-foreground mt-0.5 size-3.5 shrink-0 self-start opacity-0 transition-opacity group-hover:opacity-100" />
</button>
);
}
interface StackedCitationsProps {
id: string;
citations: SerializableCitation[];
className?: string;
onNavigate?: (href: string, citation: SerializableCitation) => void;
}
function StackedCitations({
id,
citations,
className,
onNavigate,
}: StackedCitationsProps) {
const {
open,
setOpen,
containerRef,
handleMouseEnter,
handleMouseLeave,
handleBlur,
} = useHoverPopover();
const maxIcons = 4;
const visibleCitations = citations.slice(0, maxIcons);
const remainingCount = Math.max(0, citations.length - maxIcons);
const handleClick = (citation: SerializableCitation) => {
const href = resolveSafeNavigationHref(citation.href);
if (!href) return;
if (onNavigate) {
onNavigate(href, citation);
} else {
openSafeNavigationHref(href);
}
};
return (
<div ref={containerRef} onBlur={handleBlur} className="inline-flex">
<Popover open={open}>
<PopoverTrigger asChild>
<button
type="button"
data-tool-ui-id={id}
data-slot="citation-list"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpen(true);
}
}}
className={cn(
"isolate inline-flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2",
"bg-muted/40 outline-none",
"transition-colors duration-150",
"hover:bg-muted/70",
"focus-visible:ring-ring focus-visible:ring-2",
className,
)}
>
<div className="flex items-center">
{visibleCitations.map((citation, index) => {
const TypeIcon =
TYPE_ICONS[citation.type ?? "webpage"] ?? Globe;
return (
<div
key={citation.id}
className={cn(
"border-border bg-background dark:border-foreground/20 relative flex size-6 items-center justify-center rounded-full border shadow-xs",
index > 0 && "-ml-2",
)}
style={{ zIndex: maxIcons - index }}
>
{citation.favicon ? (
<img
src={citation.favicon}
alt=""
aria-hidden="true"
width={18}
height={18}
className="size-4.5 rounded-full object-cover"
/>
) : (
<TypeIcon
className="text-muted-foreground size-3"
aria-hidden="true"
/>
)}
</div>
);
})}
{remainingCount > 0 && (
<div
className="border-border bg-background dark:border-foreground/20 relative -ml-2 flex size-6 items-center justify-center rounded-full border shadow-xs"
style={{ zIndex: 0 }}
>
<span className="text-muted-foreground text-[10px] font-medium tracking-tight">
</span>
</div>
)}
</div>
<span className="text-muted-foreground text-sm tabular-nums">
{citations.length} source{citations.length !== 1 && "s"}
</span>
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
className="w-80 p-1"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onBlur={handleBlur}
onEscapeKeyDown={() => setOpen(false)}
>
<div className="flex max-h-72 flex-col overflow-y-auto">
{citations.map((citation) => (
<OverflowItem
key={citation.id}
citation={citation}
onClick={() => handleClick(citation)}
/>
))}
</div>
</PopoverContent>
</Popover>
</div>
);
}
@@ -0,0 +1,259 @@
"use client";
import * as React from "react";
import type { LucideIcon } from "lucide-react";
import {
FileText,
Globe,
Code2,
Newspaper,
Database,
File,
ExternalLink,
} from "lucide-react";
import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter";
import { openSafeNavigationHref, sanitizeHref } from "../shared/media";
import type {
SerializableCitation,
CitationType,
CitationVariant,
} from "./schema";
const FALLBACK_LOCALE = "en-US";
const TYPE_ICONS: Record<CitationType, LucideIcon> = {
webpage: Globe,
document: FileText,
article: Newspaper,
api: Database,
code: Code2,
other: File,
};
function extractDomain(url: string): string | undefined {
try {
const urlObj = new URL(url);
return urlObj.hostname.replace(/^www\./, "");
} catch {
return undefined;
}
}
function formatDate(isoString: string, locale: string): string {
try {
const date = new Date(isoString);
return date.toLocaleDateString(locale, {
year: "numeric",
month: "short",
});
} catch {
return isoString;
}
}
function useHoverPopover(delay = 100) {
const [open, setOpen] = React.useState(false);
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const handleMouseEnter = React.useCallback(() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setOpen(true), delay);
}, [delay]);
const handleMouseLeave = React.useCallback(() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setOpen(false), delay);
}, [delay]);
React.useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []);
return { open, setOpen, handleMouseEnter, handleMouseLeave };
}
export interface CitationProps extends SerializableCitation {
variant?: CitationVariant;
className?: string;
onNavigate?: (href: string, citation: SerializableCitation) => void;
}
export function Citation(props: CitationProps) {
const { variant = "default", className, onNavigate, ...serializable } = props;
const {
id,
href: rawHref,
title,
snippet,
domain: providedDomain,
favicon,
author,
publishedAt,
type = "webpage",
locale: providedLocale,
} = serializable;
const locale = providedLocale ?? FALLBACK_LOCALE;
const sanitizedHref = sanitizeHref(rawHref);
const domain = providedDomain ?? extractDomain(rawHref);
const citationData: SerializableCitation = {
...serializable,
href: sanitizedHref ?? rawHref,
domain,
locale,
};
const TypeIcon = TYPE_ICONS[type] ?? Globe;
const handleClick = () => {
if (!sanitizedHref) return;
if (onNavigate) {
onNavigate(sanitizedHref, citationData);
} else {
openSafeNavigationHref(sanitizedHref);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (sanitizedHref && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
handleClick();
}
};
const iconElement = favicon ? (
<img
src={favicon}
alt=""
aria-hidden="true"
width={14}
height={14}
className="bg-muted size-3.5 shrink-0 rounded object-cover"
/>
) : (
<TypeIcon className="size-3.5 shrink-0 opacity-60" aria-hidden="true" />
);
const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover();
// Inline variant: compact chip with hover popover
if (variant === "inline") {
return (
<Popover open={open}>
<PopoverTrigger asChild>
<button
type="button"
aria-label={title}
data-tool-ui-id={id}
data-slot="citation"
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className={cn(
"inline-flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1",
"bg-muted/60 text-sm outline-none",
"transition-colors duration-150",
"hover:bg-muted",
"focus-visible:ring-ring focus-visible:ring-2",
className,
)}
>
{iconElement}
<span className="text-muted-foreground">{domain}</span>
</button>
</PopoverTrigger>
<PopoverContent
side="top"
align="start"
className="w-72 cursor-pointer p-0"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
onClick={handleClick}
>
<div className="hover:bg-muted/50 flex flex-col gap-2 p-3 transition-colors">
<div className="flex items-start gap-2">
{iconElement}
<span className="text-muted-foreground text-xs">{domain}</span>
</div>
<p className="text-sm leading-snug font-medium">{title}</p>
{snippet && (
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
{snippet}
</p>
)}
</div>
</PopoverContent>
</Popover>
);
}
// Default variant: full card
return (
<article
className={cn("relative w-full max-w-md min-w-72", className)}
lang={locale}
data-tool-ui-id={id}
data-slot="citation"
>
<div
className={cn(
"group @container relative isolate flex w-full min-w-0 flex-col overflow-hidden rounded-xl",
"border-border bg-card border text-sm shadow-xs",
"transition-colors duration-150",
sanitizedHref && [
"cursor-pointer",
"hover:border-foreground/25",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
],
)}
onClick={sanitizedHref ? handleClick : undefined}
role={sanitizedHref ? "link" : undefined}
tabIndex={sanitizedHref ? 0 : undefined}
onKeyDown={handleKeyDown}
>
<div className="flex flex-col gap-2 p-4">
<div className="text-muted-foreground flex min-w-0 items-center justify-between gap-1.5 text-xs">
<div className="flex min-w-0 items-center gap-1.5">
{iconElement}
<span className="truncate font-medium">{domain}</span>
{(author || publishedAt) && (
<span className="opacity-70">
<span className="opacity-60"> </span>
{author}
{author && publishedAt && ", "}
{publishedAt && (
<time dateTime={publishedAt} className="tabular-nums">
{formatDate(publishedAt, locale)}
</time>
)}
</span>
)}
</div>
{sanitizedHref && (
<ExternalLink className="size-3.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" />
)}
</div>
<h3 className="text-foreground text-[15px] leading-snug font-medium text-pretty">
<span className="group-hover:decoration-foreground/30 line-clamp-2 group-hover:underline group-hover:underline-offset-2">
{title}
</span>
</h3>
{snippet && (
<p className="text-muted-foreground text-[13px] leading-relaxed text-pretty">
<span className="line-clamp-3">{snippet}</span>
</p>
)}
</div>
</div>
</article>
);
}
@@ -0,0 +1,9 @@
export { Citation } from "./citation";
export type { CitationProps } from "./citation";
export { CitationList } from "./citation-list";
export type { CitationListProps } from "./citation-list";
export type {
SerializableCitation,
CitationType,
CitationVariant,
} from "./schema";
@@ -0,0 +1,52 @@
import { z } from "zod";
import { defineToolUiContract } from "../shared/contract";
import {
ToolUIIdSchema,
ToolUIReceiptSchema,
ToolUIRoleSchema,
} from "../shared/schema";
export const CitationTypeSchema = z.enum([
"webpage",
"document",
"article",
"api",
"code",
"other",
]);
export type CitationType = z.infer<typeof CitationTypeSchema>;
export const CitationVariantSchema = z.enum(["default", "inline", "stacked"]);
export type CitationVariant = z.infer<typeof CitationVariantSchema>;
export const SerializableCitationSchema = z.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
receipt: ToolUIReceiptSchema.optional(),
href: z.string().url(),
title: z.string(),
snippet: z.string().optional(),
domain: z.string().optional(),
favicon: z.string().url().optional(),
author: z.string().optional(),
publishedAt: z.string().datetime().optional(),
type: CitationTypeSchema.optional(),
locale: z.string().optional(),
});
export type SerializableCitation = z.infer<typeof SerializableCitationSchema>;
const SerializableCitationSchemaContract = defineToolUiContract(
"Citation",
SerializableCitationSchema,
);
export const parseSerializableCitation: (
input: unknown,
) => SerializableCitation = SerializableCitationSchemaContract.parse;
export const safeParseSerializableCitation: (
input: unknown,
) => SerializableCitation | null = SerializableCitationSchemaContract.safeParse;
@@ -0,0 +1,19 @@
# Data Table
Implementation for the "data-table" Tool UI surface.
## Files
- public exports: components/tool-ui/data-table/index.tsx
- serializable schema + parse helpers: components/tool-ui/data-table/schema.ts
## Companion assets
- Docs page: app/docs/data-table/content.mdx
- Preset payload: lib/presets/data-table.ts
## Quick check
Run this after edits:
pnpm test
@@ -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., "@toolui/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 "@toolui/lib/utils";
export { Button } from "@toolui/ui/button";
export {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@toolui/ui/dropdown-menu";
export {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@toolui/ui/accordion";
export {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@toolui/ui/tooltip";
export { Badge } from "@toolui/ui/badge";
export {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from "@toolui/ui/table";
@@ -0,0 +1,936 @@
"use client";
import * as React from "react";
import {
cn,
Table,
TableBody,
TableRow,
TableCell,
TableHeader,
TableHead,
Button,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "./_adapter";
import {
sortData,
createDataTableRowKeys,
getDataTableMobileDescriptionId,
} from "./utilities";
import { renderFormattedValue } from "./formatters";
import type {
DataTableProps,
DataTableContextValue,
RowData,
DataTableRowData,
ColumnKey,
Column,
} from "./types";
import type { FormatConfig } from "./formatters";
export const DEFAULT_LOCALE = "en-US" as const;
function isNumericFormat(format?: FormatConfig): boolean {
const kind = format?.kind;
return (
kind === "number" ||
kind === "currency" ||
kind === "percent" ||
kind === "delta"
);
}
function getAlignmentClass(
align?: "left" | "right" | "center",
): string | undefined {
if (align === "right") return "text-right";
if (align === "center") return "text-center";
return undefined;
}
const DataTableContext = React.createContext<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
DataTableContextValue<any> | undefined
>(undefined);
export function useDataTable<T extends object = RowData>() {
const context = React.useContext(DataTableContext) as
| DataTableContextValue<T>
| undefined;
if (!context) {
throw new Error("useDataTable must be used within <DataTable.Provider />");
}
return context;
}
type DataTableLayout = "auto" | "table" | "cards";
type DataTableBaseProps<T extends object = RowData> = DataTableProps<T> & {
layout: DataTableLayout;
};
type DataTableProviderProps<T extends object = RowData> = Pick<
DataTableProps<T>,
| "columns"
| "data"
| "rowIdKey"
| "defaultSort"
| "sort"
| "onSortChange"
| "id"
| "locale"
> & {
children: React.ReactNode;
};
function DataTableProvider<T extends object = RowData>({
columns,
data: rawData,
rowIdKey,
defaultSort,
sort: controlledSort,
id,
onSortChange,
locale,
children,
}: DataTableProviderProps<T>) {
// Default locale avoids SSR/client formatting mismatches.
const resolvedLocale = locale ?? DEFAULT_LOCALE;
const [internalSortBy, setInternalSortBy] = React.useState<
ColumnKey<T> | undefined
>(defaultSort?.by);
const [internalSortDirection, setInternalSortDirection] = React.useState<
"asc" | "desc" | undefined
>(defaultSort?.direction);
const sortBy = controlledSort?.by ?? internalSortBy;
const sortDirection = controlledSort?.direction ?? internalSortDirection;
const data = React.useMemo(() => {
if (!sortBy || !sortDirection) return rawData;
return sortData(rawData, sortBy, sortDirection, resolvedLocale);
}, [rawData, sortBy, sortDirection, resolvedLocale]);
const handleSort = React.useCallback(
(key: ColumnKey<T>) => {
let newDirection: "asc" | "desc" | undefined;
if (sortBy === key) {
if (sortDirection === "asc") {
newDirection = "desc";
} else if (sortDirection === "desc") {
newDirection = undefined;
} else {
newDirection = "asc";
}
} else {
newDirection = "asc";
}
const next = {
by: newDirection ? key : undefined,
direction: newDirection,
} as const;
if (controlledSort) {
onSortChange?.(next);
} else {
setInternalSortBy(next.by);
setInternalSortDirection(next.direction);
}
},
[sortBy, sortDirection, controlledSort, onSortChange],
);
const contextValue: DataTableContextValue<T> = {
columns,
data,
rowIdKey,
sortBy,
sortDirection,
toggleSort: handleSort,
id,
locale: resolvedLocale,
};
return (
<DataTableContext.Provider value={contextValue}>
{children}
</DataTableContext.Provider>
);
}
interface DataTableLayoutProps {
layout: DataTableLayout;
emptyMessage: string;
maxHeight?: string;
className?: string;
}
function DataTableLayout({
layout,
emptyMessage,
maxHeight,
className,
}: DataTableLayoutProps) {
const { columns, data, rowIdKey, sortBy, sortDirection, id } = useDataTable();
const rowKeys = React.useMemo(
() =>
createDataTableRowKeys(
data as Array<Record<string, unknown>>,
rowIdKey ? String(rowIdKey) : undefined,
),
[data, rowIdKey],
);
const mobileDescriptionId = React.useMemo(
() => getDataTableMobileDescriptionId(String(id ?? "data-table")),
[id],
);
const sortAnnouncement = React.useMemo(() => {
const col = columns.find((c) => c.key === sortBy);
const label = col?.label ?? sortBy;
return sortBy && sortDirection
? `Sorted by ${label}, ${sortDirection === "asc" ? "ascending" : "descending"}`
: "";
}, [columns, sortBy, sortDirection]);
return (
<div
className={cn("@container w-full min-w-80", className)}
data-tool-ui-id={id}
data-slot="data-table"
data-layout={layout}
>
<div
className={cn(
layout === "table"
? "block"
: layout === "cards"
? "hidden"
: "hidden @md:block",
)}
>
<div className="relative">
<div
className={cn(
"bg-card relative w-full overflow-clip overflow-y-auto rounded-lg border",
"touch-pan-x",
maxHeight && "max-h-[--max-height]",
)}
style={
maxHeight
? ({ "--max-height": maxHeight } as React.CSSProperties)
: undefined
}
>
<Table>
{columns.length > 0 && (
<colgroup>
{columns.map((col) => (
<col
key={String(col.key)}
style={col.width ? { width: col.width } : undefined}
/>
))}
</colgroup>
)}
{data.length === 0 ? (
<DataTableEmpty message={emptyMessage} />
) : (
<DataTableContent />
)}
</Table>
</div>
</div>
</div>
<div
className={cn(
layout === "cards"
? ""
: layout === "table"
? "hidden"
: "@md:hidden",
)}
role="list"
aria-label="Data table (mobile card view)"
aria-describedby={mobileDescriptionId}
>
<div id={mobileDescriptionId} className="sr-only">
Table data shown as expandable cards. Each card represents one row.
{columns.length > 0 &&
` Columns: ${columns.map((c) => c.label).join(", ")}.`}
</div>
{data.length === 0 ? (
<div className="text-muted-foreground py-8 text-center">
{emptyMessage}
</div>
) : (
<div className="bg-card flex flex-col overflow-hidden rounded-2xl border shadow-xs">
{data.map((row, i) => {
const rowKey = rowKeys[i];
return (
<DataTableAccordionCard
key={rowKey}
row={row as unknown as DataTableRowData}
index={i}
rowKey={rowKey}
isFirst={i === 0}
/>
);
})}
</div>
)}
</div>
{sortAnnouncement && (
<div className="sr-only" aria-live="polite">
{sortAnnouncement}
</div>
)}
</div>
);
}
function DataTableBase<T extends object = RowData>(
props: DataTableBaseProps<T>,
) {
const {
columns,
data,
rowIdKey,
defaultSort,
sort,
onSortChange,
id,
locale,
layout,
emptyMessage = "No data available",
maxHeight,
className,
} = props;
return (
<DataTableProvider
columns={columns}
data={data}
rowIdKey={rowIdKey}
defaultSort={defaultSort}
sort={sort}
onSortChange={onSortChange}
id={id}
locale={locale}
>
<DataTableLayout
layout={layout}
emptyMessage={emptyMessage}
maxHeight={maxHeight}
className={className}
/>
</DataTableProvider>
);
}
function DataTableRoot<T extends object = RowData>(props: DataTableProps<T>) {
return <DataTableBase {...props} layout="auto" />;
}
function DataTableTable<T extends object = RowData>(props: DataTableProps<T>) {
return <DataTableBase {...props} layout="table" />;
}
function DataTableCards<T extends object = RowData>(props: DataTableProps<T>) {
return <DataTableBase {...props} layout="cards" />;
}
type DataTableComponent = {
<T extends object = RowData>(props: DataTableProps<T>): React.ReactElement;
Table: typeof DataTableTable;
Cards: typeof DataTableCards;
Provider: typeof DataTableProvider;
};
export const DataTable = Object.assign(DataTableRoot, {
Table: DataTableTable,
Cards: DataTableCards,
Provider: DataTableProvider,
}) as DataTableComponent;
function DataTableContent() {
return (
<>
<DataTableHeader />
<DataTableBody />
</>
);
}
function DataTableEmpty({ message }: { message: string }) {
const { columns } = useDataTable();
return (
<TableBody>
<TableRow className="bg-card h-24 text-center">
<TableCell colSpan={columns.length} role="status" aria-live="polite">
{message}
</TableCell>
</TableRow>
</TableBody>
);
}
function SortIcon({ state }: { state?: "asc" | "desc" }) {
let char = "⇅";
let className = "opacity-20";
if (state === "asc") {
char = "↑";
className = "";
}
if (state === "desc") {
char = "↓";
className = "";
}
return (
<span aria-hidden className={cn("min-w-4 shrink-0 text-center", className)}>
{char}
</span>
);
}
function DataTableHeader() {
const { columns } = useDataTable();
return (
<TooltipProvider delayDuration={300}>
<TableHeader>
<TableRow className="hover:bg-transparent">
{columns.map((column, columnIndex) => (
<DataTableHead
key={column.key}
column={column}
columnIndex={columnIndex}
totalColumns={columns.length}
/>
))}
</TableRow>
</TableHeader>
</TooltipProvider>
);
}
interface DataTableHeadProps {
column: Column;
columnIndex?: number;
totalColumns?: number;
}
function DataTableHead({
column,
columnIndex = 0,
totalColumns = 1,
}: DataTableHeadProps) {
const { sortBy, sortDirection, toggleSort } = useDataTable();
const isFirstColumn = columnIndex === 0;
const isLastColumn = columnIndex === totalColumns - 1;
const isSortable = column.sortable !== false;
const isSorted = sortBy === column.key;
const direction = isSorted ? sortDirection : undefined;
const isDisabled = !isSortable;
const handleClick = () => {
if (!isDisabled && toggleSort) {
toggleSort(column.key);
}
};
const displayText = column.abbr || column.label;
const shouldShowTooltip = column.abbr || displayText.length > 15;
const isNumericKind = isNumericFormat(column.format);
const align =
column.align ??
(columnIndex === 0 ? "left" : isNumericKind ? "right" : "left");
const alignClass = getAlignmentClass(align);
const buttonAlignClass = cn(
"min-w-0 gap-1 font-normal",
align === "right" && "text-right",
align === "center" && "text-center",
align === "left" && "text-left",
);
const labelAlignClass =
align === "right"
? "text-right"
: align === "center"
? "text-center"
: "text-left";
return (
<TableHead
scope="col"
className={cn(
alignClass,
isFirstColumn && "pl-1",
isLastColumn && "pr-1",
)}
style={column.width ? { width: column.width } : undefined}
aria-sort={
isSorted
? direction === "asc"
? "ascending"
: "descending"
: undefined
}
>
<Button
type="button"
size="sm"
onClick={handleClick}
onKeyDown={(e) => {
if (isDisabled) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleClick();
}
}}
disabled={isDisabled}
variant="ghost"
className={cn(
buttonAlignClass,
"w-fit min-w-10",
isFirstColumn && "pl-4",
isLastColumn && "pr-4",
)}
aria-label={
`Sort by ${column.label}` +
(isSorted && direction
? ` (${direction === "asc" ? "ascending" : "descending"})`
: "")
}
aria-disabled={isDisabled || undefined}
>
{shouldShowTooltip ? (
<Tooltip>
<TooltipTrigger asChild>
<span className={cn("truncate", labelAlignClass)}>
{column.abbr ? (
<abbr
title={column.label}
className={cn(
"cursor-help border-b border-dotted border-current no-underline",
labelAlignClass,
)}
>
{column.abbr}
</abbr>
) : (
<span className={labelAlignClass}>{column.label}</span>
)}
</span>
</TooltipTrigger>
<TooltipContent>
<p>{column.label}</p>
</TooltipContent>
</Tooltip>
) : (
<span className={cn("truncate", labelAlignClass)}>
{column.label}
</span>
)}
{isSortable && <SortIcon state={direction} />}
</Button>
</TableHead>
);
}
function DataTableBody() {
const { data, rowIdKey } = useDataTable<DataTableRowData>();
const rowKeys = React.useMemo(
() =>
createDataTableRowKeys(
data as Array<Record<string, unknown>>,
rowIdKey ? String(rowIdKey) : undefined,
),
[data, rowIdKey],
);
const hasWarnedRowKeyRef = React.useRef(false);
React.useEffect(() => {
if (hasWarnedRowKeyRef.current) return;
if (process.env.NODE_ENV !== "production" && !rowIdKey && data.length > 0) {
hasWarnedRowKeyRef.current = true;
console.warn(
"[DataTable] Missing `rowIdKey` prop. Falling back to inferred/content-derived row keys. " +
"Strongly recommended: Pass a `rowIdKey` prop that points to a unique identifier in your row data (e.g., 'id', 'uuid', 'symbol').\n" +
'Example: <DataTable rowIdKey="id" columns={...} data={...} />',
);
}
}, [rowIdKey, data.length]);
return (
<TableBody>
{data.map((row, index) => {
const rowKey = rowKeys[index];
return <DataTableRow key={rowKey} row={row} />;
})}
</TableBody>
);
}
interface DataTableRowProps {
row: DataTableRowData;
className?: string;
}
function DataTableRow({ row, className }: DataTableRowProps) {
const { columns } = useDataTable();
return (
<TableRow className={className}>
{columns.map((column, columnIndex) => (
<DataTableCell
key={column.key}
value={row[column.key]}
column={column}
row={row}
columnIndex={columnIndex}
/>
))}
</TableRow>
);
}
interface DataTableCellProps {
value:
| string
| number
| boolean
| null
| (string | number | boolean | null)[];
column: Column;
row: DataTableRowData;
className?: string;
columnIndex?: number;
}
function DataTableCell({
value,
column,
row,
className,
columnIndex = 0,
}: DataTableCellProps) {
const { locale } = useDataTable();
const isNumericKind = isNumericFormat(column.format);
const isNumericValue = typeof value === "number";
const displayValue = renderFormattedValue({ value, column, row, locale });
const align =
column.align ??
(columnIndex === 0
? "left"
: isNumericKind || isNumericValue
? "right"
: "left");
const alignClass = getAlignmentClass(align);
return (
<TableCell className={cn("px-5 py-3", alignClass, className)}>
{displayValue}
</TableCell>
);
}
function categorizeColumns(columns: Column[]) {
const primary: Column[] = [];
const secondary: Column[] = [];
let visibleColumnCount = 0;
columns.forEach((col) => {
if (col.hideOnMobile) return;
if (col.priority === "primary") {
primary.push(col);
} else if (col.priority === "secondary") {
secondary.push(col);
} else if (col.priority === "tertiary") {
return;
} else {
if (visibleColumnCount < 2) {
primary.push(col);
} else {
secondary.push(col);
}
visibleColumnCount++;
}
});
return { primary, secondary };
}
interface DataTableAccordionCardProps {
row: DataTableRowData;
index: number;
rowKey: string;
isFirst?: boolean;
}
function getDataTableRowDomId(rowKey: string): string {
return encodeURIComponent(rowKey).replace(/%/g, "_");
}
function DataTableAccordionCard({
row,
index,
rowKey,
isFirst = false,
}: DataTableAccordionCardProps) {
const { columns, locale } = useDataTable();
const { primary, secondary } = React.useMemo(
() => categorizeColumns(columns),
[columns],
);
if (secondary.length === 0) {
return (
<SimpleCard
row={row}
columns={primary}
index={index}
rowKey={rowKey}
isFirst={isFirst}
/>
);
}
const primaryColumn = primary[0];
const remainingPrimaryColumns = primary.slice(1);
const stableRowId = getDataTableRowDomId(rowKey);
const headingId = `row-${stableRowId}-heading`;
const detailsId = `row-${stableRowId}-details`;
const remainingPrimaryDataIds = remainingPrimaryColumns.map(
(col) => `row-${stableRowId}-${String(col.key)}`,
);
const primaryValue = primaryColumn
? String(row[primaryColumn.key] ?? "")
: "";
const rowLabel = `Row ${index + 1}: ${primaryValue}`;
const accordionItemId = `row-${stableRowId}`;
return (
<Accordion
type="single"
collapsible
className={cn(!isFirst && "border-t")}
role="listitem"
aria-label={rowLabel}
>
<AccordionItem value={accordionItemId} className="group border-0">
<AccordionTrigger
className="group-data-[state=closed]:hover:bg-accent/50 active:bg-accent/50 group-data-[state=open]:bg-muted w-full rounded-none px-4 py-3 hover:no-underline"
aria-controls={detailsId}
aria-label={`${rowLabel}. ${secondary.length > 0 ? "Expand for details" : ""}`}
>
<div className="flex min-w-0 flex-1 flex-col gap-2">
{primaryColumn && (
<div
id={headingId}
role="heading"
aria-level={3}
className="truncate"
aria-label={`${primaryColumn.label}: ${row[primaryColumn.key]}`}
>
{renderFormattedValue({
value: row[primaryColumn.key],
column: primaryColumn,
row,
locale,
})}
</div>
)}
{remainingPrimaryColumns.length > 0 && (
<div
className="text-muted-foreground flex w-full flex-wrap gap-x-4 gap-y-0.5"
role="group"
aria-label="Summary information"
>
{remainingPrimaryColumns.map((col, idx) => (
<span
key={col.key}
id={remainingPrimaryDataIds[idx]}
className="flex min-w-0 gap-1 font-normal"
role="cell"
aria-label={`${col.label}: ${row[col.key]}`}
>
<span className="sr-only">{col.label}:</span>
<span aria-hidden="true">{col.label}:</span>
<span className="truncate">
{renderFormattedValue({
value: row[col.key],
column: col,
row,
locale,
})}
</span>
</span>
))}
</div>
)}
</div>
</AccordionTrigger>
<AccordionContent
className={"flex flex-col gap-4 px-4 pb-4"}
id={detailsId}
role="region"
aria-labelledby={headingId}
>
{secondary.length > 0 && (
<dl
className={cn(
"flex flex-col gap-2 pt-4",
"motion-safe:group-data-[state=open]:animate-in motion-safe:group-data-[state=open]:fade-in-0",
"motion-safe:group-data-[state=open]:slide-in-from-top-1",
"motion-safe:group-data-[state=closed]:animate-out motion-safe:group-data-[state=closed]:fade-out-0",
"motion-safe:group-data-[state=closed]:slide-out-to-top-1",
"duration-150",
)}
role="list"
aria-label="Additional data"
>
{secondary.map((col) => (
<div
key={col.key}
className="flex items-start justify-between gap-4"
role="listitem"
>
<dt
className="text-muted-foreground shrink-0"
id={`row-${stableRowId}-${String(col.key)}-label`}
>
{col.label}
</dt>
<dd
className={cn(
"text-foreground min-w-0 text-pretty wrap-break-word",
col.align === "right" && "text-right",
col.align === "center" && "text-center",
)}
role="cell"
aria-labelledby={`row-${stableRowId}-${String(col.key)}-label`}
>
{renderFormattedValue({
value: row[col.key],
column: col,
row,
locale,
})}
</dd>
</div>
))}
</dl>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
);
}
/**
* Simple card with no accordion, for when there are only primary columns
*/
function SimpleCard({
row,
columns,
index,
rowKey,
isFirst = false,
}: {
row: DataTableRowData;
columns: Column[];
index: number;
rowKey: string;
isFirst?: boolean;
}) {
const { locale } = useDataTable();
const primaryColumn = columns[0];
const otherColumns = columns.slice(1);
const stableRowId = getDataTableRowDomId(rowKey);
const primaryValue = primaryColumn
? String(row[primaryColumn.key] ?? "")
: "";
const rowLabel = `Row ${index + 1}: ${primaryValue}`;
return (
<div
className={cn("flex flex-col gap-2 p-4", !isFirst && "border-t")}
role="listitem"
aria-label={rowLabel}
>
{primaryColumn && (
<div
role="heading"
aria-level={3}
aria-label={`${primaryColumn.label}: ${row[primaryColumn.key]}`}
>
{renderFormattedValue({
value: row[primaryColumn.key],
column: primaryColumn,
row,
locale,
})}
</div>
)}
{otherColumns.map((col) => (
<div
key={col.key}
className="flex items-start justify-between gap-4"
role="group"
>
<span
className="text-muted-foreground"
id={`row-${stableRowId}-${String(col.key)}-label`}
>
{col.label}:
</span>
<span
className={cn(
"min-w-0 wrap-break-word",
col.align === "right" && "text-right",
col.align === "center" && "text-center",
)}
role="cell"
aria-labelledby={`row-${stableRowId}-${String(col.key)}-label`}
>
{renderFormattedValue({
value: row[col.key],
column: col,
row,
locale,
})}
</span>
</div>
))}
</div>
);
}
@@ -0,0 +1,473 @@
"use client";
import * as React from "react";
import { cn, Badge, Tooltip, TooltipContent, TooltipTrigger } from "./_adapter";
import { resolveSafeNavigationHref } from "../shared/media";
type Tone = "success" | "warning" | "danger" | "info" | "neutral";
export type FormatConfig =
| { kind: "text" }
| {
kind: "number";
decimals?: number;
unit?: string;
compact?: boolean;
showSign?: boolean;
}
| { kind: "currency"; currency: string; decimals?: number }
| {
kind: "percent";
decimals?: number;
showSign?: boolean;
basis?: "fraction" | "unit";
}
| { kind: "date"; dateFormat?: "short" | "long" | "relative" }
| {
kind: "delta";
decimals?: number;
upIsPositive?: boolean;
showSign?: boolean;
}
| {
kind: "status";
statusMap: Record<string, { tone: Tone; label?: string }>;
}
| { kind: "boolean"; labels?: { true: string; false: string } }
| { kind: "link"; hrefKey?: string; external?: boolean }
| { kind: "badge"; colorMap?: Record<string, Tone> }
| { kind: "array"; maxVisible?: number };
interface DeltaValueProps {
value: number;
options?: Extract<FormatConfig, { kind: "delta" }>;
locale?: string;
}
export function DeltaValue({ value, options, locale }: DeltaValueProps) {
const decimals = options?.decimals ?? 2;
const upIsPositive = options?.upIsPositive ?? true;
const showSign = options?.showSign ?? true;
const isPositive = value > 0;
const isNegative = value < 0;
const isNeutral = value === 0;
const isGood = upIsPositive ? isPositive : isNegative;
const isBad = upIsPositive ? isNegative : isPositive;
const colorClass = isGood
? "text-green-700 dark:text-green-500"
: isBad
? "text-destructive"
: "text-muted-foreground";
const absValue = Math.abs(value);
const formatted = new Intl.NumberFormat(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(absValue);
const display =
showSign && !isNeutral
? isNegative
? `-${formatted}`
: `+${formatted}`
: formatted;
const arrow = isPositive ? "↑" : isNegative ? "↓" : "";
return (
<span className={cn("tabular-nums", colorClass)}>
{display}
{!isNeutral && <span className="ml-0.5">{arrow}</span>}
</span>
);
}
interface StatusBadgeProps {
value: string;
options?: Extract<FormatConfig, { kind: "status" }>;
}
export function StatusBadge({ value, options }: StatusBadgeProps) {
const config = options?.statusMap?.[value] ?? {
tone: "neutral" as Tone,
label: value,
};
const label = config.label ?? value;
const variant =
config.tone === "danger"
? "destructive"
: config.tone === "neutral"
? "outline"
: "secondary";
return (
<Badge
variant={variant}
className={cn(
"border",
config.tone === "warning" &&
"bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-100",
config.tone === "success" &&
"bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-100",
config.tone === "info" &&
"bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-100",
config.tone === "danger" &&
"bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-100",
)}
>
{label}
</Badge>
);
}
interface CurrencyValueProps {
value: number;
options?: Extract<FormatConfig, { kind: "currency" }>;
locale?: string;
}
export function CurrencyValue({ value, options, locale }: CurrencyValueProps) {
const currency = options?.currency ?? "USD";
const decimals = options?.decimals ?? 2;
const formatted = new Intl.NumberFormat(locale, {
style: "currency",
currency,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(value);
return <span className="tabular-nums">{formatted}</span>;
}
interface PercentValueProps {
value: number;
options?: Extract<FormatConfig, { kind: "percent" }>;
locale?: string;
}
export function PercentValue({ value, options, locale }: PercentValueProps) {
const decimals = options?.decimals ?? 2;
const showSign = options?.showSign ?? false;
const basis = options?.basis ?? "fraction";
const numeric = basis === "fraction" ? value : value / 100;
const formatted = new Intl.NumberFormat(locale, {
style: "percent",
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
signDisplay: showSign ? "always" : "auto",
}).format(numeric);
return <span className="tabular-nums">{formatted}</span>;
}
interface DateValueProps {
value: string;
options?: Extract<FormatConfig, { kind: "date" }>;
locale?: string;
}
export function DateValue({ value, options, locale }: DateValueProps) {
const dateFormat = options?.dateFormat ?? "short";
const date = new Date(value);
if (isNaN(date.getTime())) {
return <span className="text-muted-foreground">{value}</span>;
}
let formatted: string;
if (dateFormat === "relative") {
formatted = getRelativeTime(date, locale);
} else if (dateFormat === "long") {
formatted = new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "long",
day: "numeric",
}).format(date);
} else {
formatted = new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "short",
day: "numeric",
}).format(date);
}
const title = new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
return (
<span className="tabular-nums" title={title}>
{formatted}
</span>
);
}
function getRelativeTime(date: Date, locale?: string): string {
const now = new Date();
const diffInSeconds = Math.trunc((date.getTime() - now.getTime()) / 1000);
const absDiffInSeconds = Math.abs(diffInSeconds);
if (absDiffInSeconds < 60) return "just now";
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
if (absDiffInSeconds < 3600) {
const mins = Math.trunc(diffInSeconds / 60);
return rtf.format(mins, "minute");
}
if (absDiffInSeconds < 86400) {
const hours = Math.trunc(diffInSeconds / 3600);
return rtf.format(hours, "hour");
}
if (absDiffInSeconds < 604800) {
const days = Math.trunc(diffInSeconds / 86400);
return rtf.format(days, "day");
}
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "short",
day: "numeric",
}).format(date);
}
interface BooleanValueProps {
value: boolean;
options?: Extract<FormatConfig, { kind: "boolean" }>;
}
export function BooleanValue({ value, options }: BooleanValueProps) {
const labels = options?.labels ?? { true: "Yes", false: "No" };
const label = value ? labels.true : labels.false;
const variant = value ? "secondary" : "outline";
return <Badge variant={variant}>{label}</Badge>;
}
interface LinkValueProps {
value: string;
options?: Extract<FormatConfig, { kind: "link" }>;
row?: Record<
string,
string | number | boolean | null | (string | number | boolean | null)[]
>;
}
export function LinkValue({ value, options, row }: LinkValueProps) {
const rawHref =
options?.hrefKey && row ? String(row[options.hrefKey] ?? "") : value;
const href = resolveSafeNavigationHref(rawHref);
const external = options?.external ?? false;
if (!href) {
return <span>{value}</span>;
}
return (
<a
href={href}
target={external ? "_blank" : undefined}
rel={external ? "noopener noreferrer" : undefined}
className="text-accent-foreground inline-block max-w-full break-words underline underline-offset-2 hover:opacity-90"
aria-label={external ? `${value} (opens in a new tab)` : undefined}
onClick={(e) => e.stopPropagation()}
>
{value}
{external && (
<span className="ml-1 inline-block" aria-label="Opens in new tab">
</span>
)}
</a>
);
}
interface NumberValueProps {
value: number;
options?: Extract<FormatConfig, { kind: "number" }>;
locale?: string;
}
export function NumberValue({ value, options, locale }: NumberValueProps) {
const decimals = options?.decimals ?? 0;
const unit = options?.unit ?? "";
const compact = options?.compact ?? false;
const showSign = options?.showSign ?? false;
const formatted = new Intl.NumberFormat(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
notation: compact ? "compact" : "standard",
}).format(value);
const display = showSign && value > 0 ? `+${formatted}` : formatted;
return (
<span className="tabular-nums">
{display}
{unit}
</span>
);
}
interface BadgeValueProps {
value: string;
options?: Extract<FormatConfig, { kind: "badge" }>;
}
export function BadgeValue({ value, options }: BadgeValueProps) {
const tone = options?.colorMap?.[value] ?? "neutral";
const variant =
tone === "danger"
? "destructive"
: tone === "neutral"
? "outline"
: "secondary";
return (
<Badge
variant={variant}
className={cn(
"border",
tone === "warning" &&
"bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-100",
tone === "success" &&
"bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-100",
tone === "info" &&
"bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-100",
tone === "danger" &&
"bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-100",
)}
>
{value}
</Badge>
);
}
interface ArrayValueProps {
value: (string | number | boolean | null)[] | string;
options?: Extract<FormatConfig, { kind: "array" }>;
}
export function ArrayValue({ value, options }: ArrayValueProps) {
const maxVisible = options?.maxVisible ?? 3;
const items: (string | number | boolean | null)[] = Array.isArray(value)
? value
: typeof value === "string"
? value.split(",").map((s) => s.trim())
: [];
if (items.length === 0) {
return <span className="text-muted"></span>;
}
const visible = items.slice(0, maxVisible);
const remaining = items.length - maxVisible;
const hidden = items.slice(maxVisible);
return (
<span className="inline-flex flex-wrap items-center gap-1">
{visible.map((item, i) => (
<span
key={i}
className="bg-muted text-muted-foreground inline-flex items-center rounded-md px-2 py-0.5"
>
{item === null ? "null" : String(item)}
</span>
))}
{remaining > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground cursor-default">
+{remaining} more
</span>
</TooltipTrigger>
<TooltipContent>
{hidden
.map((item) => (item === null ? "null" : String(item)))
.join(", ")}
</TooltipContent>
</Tooltip>
)}
</span>
);
}
interface RenderFormattedValueParams {
value:
| string
| number
| boolean
| null
| (string | number | boolean | null)[];
column: { format?: FormatConfig };
row?: Record<
string,
string | number | boolean | null | (string | number | boolean | null)[]
>;
locale?: string;
}
export function renderFormattedValue({
value,
column,
row,
locale,
}: RenderFormattedValueParams): React.ReactNode {
if (value == null || value === "") {
return <span className="text-muted"></span>;
}
const fmt = column.format;
switch (fmt?.kind) {
case "delta":
return <DeltaValue value={Number(value)} options={fmt} locale={locale} />;
case "status":
return <StatusBadge value={String(value)} options={fmt} />;
case "currency":
return (
<CurrencyValue value={Number(value)} options={fmt} locale={locale} />
);
case "percent":
return (
<PercentValue value={Number(value)} options={fmt} locale={locale} />
);
case "date":
return <DateValue value={String(value)} options={fmt} locale={locale} />;
case "boolean":
return <BooleanValue value={Boolean(value)} options={fmt} />;
case "link":
return <LinkValue value={String(value)} options={fmt} row={row} />;
case "number":
return (
<NumberValue value={Number(value)} options={fmt} locale={locale} />
);
case "badge":
return <BadgeValue value={String(value)} options={fmt} />;
case "array":
return (
<ArrayValue
value={Array.isArray(value) ? value : String(value)}
options={fmt}
/>
);
case "text":
default:
return String(value);
}
}
@@ -0,0 +1,29 @@
export { DataTable, useDataTable } from "./data-table";
export { renderFormattedValue } from "./formatters";
export {
NumberValue,
CurrencyValue,
PercentValue,
DeltaValue,
DateValue,
BooleanValue,
LinkValue,
BadgeValue,
StatusBadge,
ArrayValue,
} from "./formatters";
export type {
Column,
DataTableProps,
DataTableSerializableProps,
DataTableClientProps,
DataTableRowData,
RowPrimitive,
RowData,
ColumnKey,
} from "./types";
export type { FormatConfig } from "./formatters";
export { sortData, parseNumericLike } from "./utilities";
@@ -0,0 +1,345 @@
import { z } from "zod";
import {
ToolUIIdSchema,
ToolUIReceiptSchema,
ToolUIRoleSchema,
} from "../shared/schema";
import { defineToolUiContract } from "../shared/contract";
import type { Column, DataTableProps, RowData } from "./types";
const AlignEnum = z.enum(["left", "right", "center"]);
const PriorityEnum = z.enum(["primary", "secondary", "tertiary"]);
const formatSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("text") }),
z.object({
kind: z.literal("number"),
decimals: z.number().optional(),
unit: z.string().optional(),
compact: z.boolean().optional(),
showSign: z.boolean().optional(),
}),
z.object({
kind: z.literal("currency"),
currency: z.string(),
decimals: z.number().optional(),
}),
z.object({
kind: z.literal("percent"),
decimals: z.number().optional(),
showSign: z.boolean().optional(),
basis: z.enum(["fraction", "unit"]).optional(),
}),
z.object({
kind: z.literal("date"),
dateFormat: z.enum(["short", "long", "relative"]).optional(),
}),
z.object({
kind: z.literal("delta"),
decimals: z.number().optional(),
upIsPositive: z.boolean().optional(),
showSign: z.boolean().optional(),
}),
z.object({
kind: z.literal("status"),
statusMap: z.record(
z.string(),
z.object({
tone: z.enum(["success", "warning", "danger", "info", "neutral"]),
label: z.string().optional(),
}),
),
}),
z.object({
kind: z.literal("boolean"),
labels: z
.object({
true: z.string(),
false: z.string(),
})
.optional(),
}),
z.object({
kind: z.literal("link"),
hrefKey: z.string().optional(),
external: z.boolean().optional(),
}),
z.object({
kind: z.literal("badge"),
colorMap: z
.record(
z.string(),
z.enum(["success", "warning", "danger", "info", "neutral"]),
)
.optional(),
}),
z.object({
kind: z.literal("array"),
maxVisible: z.number().optional(),
}),
]);
export const serializableColumnSchema = z.object({
key: z.string(),
label: z.string(),
abbr: z.string().optional(),
sortable: z.boolean().optional(),
align: AlignEnum.optional(),
width: z.string().optional(),
truncate: z.boolean().optional(),
priority: PriorityEnum.optional(),
hideOnMobile: z.boolean().optional(),
format: formatSchema.optional(),
});
const JsonPrimitiveSchema = z.union([
z.string(),
z.number(),
z.boolean(),
z.null(),
]);
/**
* Schema for serializable row data.
*
* Supports:
* - Primitives: string, number, boolean, null
* - Arrays of primitives: string[], number[], boolean[], or mixed primitive arrays
*
* Does NOT support:
* - Functions
* - Class instances (Date, Map, Set, etc.)
* - Plain objects (use format configs instead)
*
* @example
* Valid row data:
* ```json
* {
* "name": "Widget",
* "price": 29.99,
* "active": true,
* "tags": ["electronics", "featured"],
* "metrics": [1.2, 3.4, 5.6],
* "flags": [true, false, true],
* "mixed": ["label", 42, true]
* }
* ```
*/
export const serializableDataSchema = z.record(
z.string(),
z.union([JsonPrimitiveSchema, z.array(JsonPrimitiveSchema)]),
);
/**
* Zod schema for validating DataTable payloads from LLM tool calls.
*
* This schema validates the serializable parts of a DataTable:
* - id: Unique identifier for this tool UI in the conversation
* - columns: Column definitions (keys, labels, formatting, etc.)
* - data: Data rows (primitives only - no functions or class instances)
* - optional presentation props: rowIdKey, sort/defaultSort, locale, etc.
*
* Non-serializable props like `onSortChange`, `className`, and sibling action surfaces
* must be provided separately in your React component.
*
* @example
* ```ts
* const result = SerializableDataTableSchema.safeParse(llmResponse)
* if (result.success) {
* // result.data contains validated id, columns, and data
* }
* ```
*/
export const SerializableDataTableSchema = z.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
receipt: ToolUIReceiptSchema.optional(),
columns: z.array(serializableColumnSchema),
data: z.array(serializableDataSchema),
rowIdKey: z.string().optional(),
defaultSort: z
.object({
by: z.string().optional(),
direction: z.enum(["asc", "desc"]).optional(),
})
.optional(),
sort: z
.object({
by: z.string().optional(),
direction: z.enum(["asc", "desc"]).optional(),
})
.optional(),
emptyMessage: z.string().optional(),
maxHeight: z.string().optional(),
locale: z.string().optional(),
});
const SerializableDataTableSchemaContract = defineToolUiContract(
"DataTable",
SerializableDataTableSchema,
);
/**
* Type representing the serializable parts of a DataTable payload.
*
* This type includes only JSON-serializable data that can come from LLM tool calls:
* - Column definitions (format configs, alignment, labels, etc.)
* - Row data (primitives: strings, numbers, booleans, null, string arrays)
*
* Excluded from this type:
* - Event handlers (`onSortChange`)
* - React-specific props (`className`)
*
* @example
* ```ts
* const payload: SerializableDataTable = {
* id: "data-table-expenses",
* columns: [
* { key: "name", label: "Name" },
* { key: "price", label: "Price", format: { kind: "currency", currency: "USD" } }
* ],
* data: [
* { name: "Widget", price: 29.99 }
* ]
* }
* ```
*/
export type SerializableDataTable = z.infer<typeof SerializableDataTableSchema>;
/**
* Validates and parses a DataTable payload from unknown data (e.g., LLM tool call result).
*
* This function:
* 1. Validates the input against the `SerializableDataTableSchema`
* 2. Throws a descriptive error if validation fails
* 3. Returns typed serializable props ready to pass to the `<DataTable>` component
*
* The returned props are **serializable only** - you must provide client-side props
* separately (onSortChange, className).
*
* @param input - Unknown data to validate (typically from an LLM tool call)
* @returns Validated and typed DataTable serializable props (id, columns, data)
* @throws Error with validation details if input is invalid
*
* @example
* ```tsx
* function MyToolUI({ result }: { result: unknown }) {
* const serializableProps = parseSerializableDataTable(result)
*
* return (
* <DataTable
* {...serializableProps}
* />
* )
* }
* ```
*/
export function parseSerializableDataTable(
input: unknown,
): Pick<
DataTableProps<RowData>,
| "id"
| "role"
| "receipt"
| "columns"
| "data"
| "rowIdKey"
| "defaultSort"
| "sort"
| "emptyMessage"
| "maxHeight"
| "locale"
> {
const {
id,
role,
receipt,
columns,
data,
rowIdKey,
defaultSort,
sort,
emptyMessage,
maxHeight,
locale,
} = SerializableDataTableSchemaContract.parse(input);
return {
id,
role,
receipt,
columns: columns as unknown as Column<RowData>[],
data: data as RowData[],
rowIdKey: rowIdKey as keyof RowData | undefined,
defaultSort: defaultSort
? {
by: defaultSort.by as keyof RowData | undefined,
direction: defaultSort.direction,
}
: undefined,
sort: sort
? {
by: sort.by as keyof RowData | undefined,
direction: sort.direction,
}
: undefined,
emptyMessage,
maxHeight,
locale,
};
}
export function safeParseSerializableDataTable(
input: unknown,
): Pick<
DataTableProps<RowData>,
| "id"
| "role"
| "receipt"
| "columns"
| "data"
| "rowIdKey"
| "defaultSort"
| "sort"
| "emptyMessage"
| "maxHeight"
| "locale"
> | null {
const res = SerializableDataTableSchemaContract.safeParse(input);
if (!res) return null;
const {
id,
role,
receipt,
columns,
data,
rowIdKey,
defaultSort,
sort,
emptyMessage,
maxHeight,
locale,
} = res;
return {
id,
role,
receipt,
columns: columns as unknown as Column<RowData>[],
data: data as RowData[],
rowIdKey: rowIdKey as keyof RowData | undefined,
defaultSort: defaultSort
? {
by: defaultSort.by as keyof RowData | undefined,
direction: defaultSort.direction,
}
: undefined,
sort: sort
? {
by: sort.by as keyof RowData | undefined,
direction: sort.direction,
}
: undefined,
emptyMessage,
maxHeight,
locale,
};
}
@@ -0,0 +1,262 @@
import type { ToolUIId, ToolUIReceipt, ToolUIRole } from "../shared/schema";
import type { FormatConfig } from "./formatters";
/**
* JSON primitive type that can be serialized.
*/
type JsonPrimitive = string | number | boolean | null;
/**
* Valid row value types for serializable DataTable data.
*
* Supports:
* - Primitives: string, number, boolean, null
* - Arrays of primitives: string[], number[], boolean[], or mixed primitive arrays
*
* For complex data (objects with href/label, etc.), use column format configs
* instead of putting objects in row data.
*
* @example
* ```ts
* // 👍 Good: Use primitives and primitive arrays
* const row = {
* name: "Widget",
* price: 29.99,
* tags: ["electronics", "featured"],
* metrics: [1.2, 3.4, 5.6]
* }
*
* // 🚫 Bad: Don't put objects in row data
* const row = {
* link: { href: "/path", label: "Click" } // Use format: { kind: 'link' } instead
* }
* ```
*/
export type RowPrimitive = JsonPrimitive | JsonPrimitive[];
export type DataTableRowData = Record<string, RowPrimitive>;
export type RowData = Record<string, unknown>;
export type ColumnKey<T extends object> = Extract<keyof T, string>;
export type FormatFor<V> = V extends number
? Extract<FormatConfig, { kind: "number" | "currency" | "percent" | "delta" }>
: V extends boolean
? Extract<FormatConfig, { kind: "boolean" | "status" | "badge" }>
: V extends (string | number | boolean | null)[]
? Extract<FormatConfig, { kind: "array" }>
: V extends string
? Extract<
FormatConfig,
{ kind: "text" | "link" | "date" | "badge" | "status" }
>
: Extract<FormatConfig, { kind: "text" }>;
/**
* Column definition for DataTable
*
* @remarks
* **Important:** Columns are sortable by default (opt-out pattern).
* Set `sortable: false` explicitly to disable sorting for specific columns.
*/
export interface Column<
T extends object = DataTableRowData,
K extends ColumnKey<T> = ColumnKey<T>,
> {
/** Unique identifier that maps to a key in the row data */
key: K;
/** Display text for the column header */
label: string;
/** Abbreviated label for narrow viewports */
abbr?: string;
/** Whether column is sortable. Default: true (opt-out pattern) */
sortable?: boolean;
/** Text alignment for column cells */
align?: "left" | "right" | "center";
/** Optional fixed width (CSS value) */
width?: string;
/** Enable text truncation with ellipsis */
truncate?: boolean;
/** Mobile display priority (primary = always visible, secondary = expandable, tertiary = hidden) */
priority?: "primary" | "secondary" | "tertiary";
/** Completely hide column on mobile viewports */
hideOnMobile?: boolean;
/** Formatting configuration for cell values */
format?: FormatFor<T[K]>;
}
/**
* Serializable props that can come from LLM tool calls or be JSON-serialized.
*
* These props contain only primitive values, arrays, and plain objects -
* no functions, class instances, or other non-serializable values.
*
* @example
* ```tsx
* const serializableProps: DataTableSerializableProps = {
* columns: [...],
* data: [...],
* rowIdKey: "id",
* defaultSort: { by: "price", direction: "desc" }
* }
* ```
*/
export interface DataTableSerializableProps<T extends object = RowData> {
/**
* Unique identifier for this tool UI instance in the conversation.
*
* Used for:
* - Assistant referencing ("the table above")
* - Receipt generation (linking actions to their source)
* - Narration context
*
* Should be stable across re-renders, meaningful, and unique within the conversation.
*
* @example "data-table-expenses-q3", "search-results-repos"
*/
id: ToolUIId;
/** Optional surface role metadata (serializable) */
role?: ToolUIRole;
/** Optional receipt metadata for consequential outcomes (serializable) */
receipt?: ToolUIReceipt;
/** Column definitions */
columns: Column<T>[];
/** Row data (primitives only - no functions or class instances) */
data: T[];
/**
* Key in row data to use as unique identifier for React keys
*
* **Strongly recommended:** Always provide this for dynamic data to prevent
* reconciliation issues (focus traps, animation glitches, incorrect state preservation)
* when data reorders. Falls back to array index if omitted (only acceptable for static mock data).
*
* @example rowIdKey="id" or rowIdKey="uuid"
*/
rowIdKey?: ColumnKey<T>;
/**
* Uncontrolled initial sort state (table manages its own sort state internally)
*
* **Sorting cycle:** Clicking column headers cycles through tri-state:
* 1. none (unsorted) → 2. asc → 3. desc → 4. none (back to unsorted)
*
* @example
* ```tsx
* // Start with descending price sort
* <DataTable defaultSort={{ by: "price", direction: "desc" }} />
* ```
*/
defaultSort?: { by?: ColumnKey<T>; direction?: "asc" | "desc" };
/**
* Controlled sort state (use with onSortChange from client props)
*
* When provided, you must also provide `onSortChange` to handle sort updates.
* The table will cycle through: none → asc → desc → none.
*
* @example
* ```tsx
* const [sort, setSort] = useState({ by: "price", direction: "desc" })
* <DataTable sort={sort} onSortChange={setSort} />
* ```
*/
sort?: { by?: ColumnKey<T>; direction?: "asc" | "desc" };
/** Empty state message */
emptyMessage?: string;
/** Max table height with vertical scroll (CSS value) */
maxHeight?: string;
/**
* BCP47 locale for formatting and sorting (e.g., 'en-US', 'de-DE', 'ja-JP')
*
* Defaults to 'en-US' to ensure consistent server/client rendering.
* Pass explicit locale for internationalization.
*
* @example
* ```tsx
* <DataTable locale="de-DE" /> // German formatting
* <DataTable locale="ja-JP" /> // Japanese formatting
* <DataTable /> // Uses 'en-US' default
* ```
*/
locale?: string;
}
/**
* Client-side React-only props that cannot be serialized.
*
* These props contain functions, component state, or other React-specific values
* that must be provided by your React code (not from LLM tool calls).
*
* @example
* ```tsx
* const clientProps: DataTableClientProps = {
* className: "my-table",
* onSortChange: (next) => setSort(next),
* // Compose local/decision actions externally via LocalActions/DecisionActions
* }
* ```
*/
export interface DataTableClientProps<T extends object = RowData> {
/** Additional CSS classes */
className?: string;
/**
* Sort change handler for controlled mode (required if sort is provided)
*
* **Tri-state cycle behavior:**
* - Click unsorted column: `{ by: "column", direction: "asc" }`
* - Click asc column: `{ by: "column", direction: "desc" }`
* - Click desc column: `{ by: "column", direction: undefined }` (returns to unsorted)
* - Click different column: `{ by: "newColumn", direction: "asc" }`
*
* @example
* ```tsx
* const [sort, setSort] = useState<{ by?: string; direction?: "asc" | "desc" }>({})
*
* <DataTable
* sort={sort}
* onSortChange={(next) => {
* console.log("Sort changed:", next)
* setSort(next)
* }}
* />
* ```
*/
onSortChange?: (next: {
by?: ColumnKey<T>;
direction?: "asc" | "desc";
}) => void;
}
/**
* Complete props for the DataTable component.
*
* Combines serializable props (can come from LLM tool calls) with client-side
* React-only props. This separation makes the boundary explicit and prevents
* accidental serialization of non-serializable values.
*
* @see {@link DataTableSerializableProps} for props that can be JSON-serialized
* @see {@link DataTableClientProps} for React-only props
* @see {@link parseSerializableDataTable} for parsing LLM tool call results
*
* @example
* ```tsx
* // From LLM tool call
* const serializableProps = parseSerializableDataTable(llmResult)
*
* // Combine with React-specific props
* <DataTable
* {...serializableProps}
* onSortChange={setSort}
* // Render sibling LocalActions / DecisionActions where needed
* />
* ```
*/
export interface DataTableProps<T extends object = RowData>
extends DataTableSerializableProps<T>, DataTableClientProps<T> {}
export interface DataTableContextValue<T extends object = RowData> {
columns: Column<T>[];
data: T[];
rowIdKey?: ColumnKey<T>;
sortBy?: ColumnKey<T>;
sortDirection?: "asc" | "desc";
toggleSort?: (key: ColumnKey<T>) => void;
id?: string;
locale?: string;
}
@@ -0,0 +1,299 @@
/**
* Sort an array of objects by a key
*/
export function sortData<T, K extends Extract<keyof T, string>>(
data: T[],
key: K,
direction: "asc" | "desc",
locale?: string,
): T[] {
const get = (obj: T, k: K): unknown => (obj as Record<string, unknown>)[k];
const collator = new Intl.Collator(locale, {
numeric: true,
sensitivity: "base",
});
return [...data].sort((a, b) => {
const aVal = get(a, key);
const bVal = get(b, key);
// Handle nulls
if (aVal == null && bVal == null) return 0;
if (aVal == null) return 1;
if (bVal == null) return -1;
// Type-specific comparison
// Numbers
if (typeof aVal === "number" && typeof bVal === "number") {
return direction === "asc" ? aVal - bVal : bVal - aVal;
}
// Dates (Date instances)
if (aVal instanceof Date && bVal instanceof Date) {
const diff = aVal.getTime() - bVal.getTime();
return direction === "asc" ? diff : -diff;
}
// Booleans: false < true
if (typeof aVal === "boolean" && typeof bVal === "boolean") {
const diff = aVal === bVal ? 0 : aVal ? 1 : -1;
return direction === "asc" ? diff : -diff;
}
// Arrays: compare length
if (Array.isArray(aVal) && Array.isArray(bVal)) {
const diff = aVal.length - bVal.length;
return direction === "asc" ? diff : -diff;
}
// Strings that look like numbers -> numeric compare
if (typeof aVal === "string" && typeof bVal === "string") {
const numA = parseNumericLike(aVal);
const numB = parseNumericLike(bVal);
if (numA != null && numB != null) {
const diff = numA - numB;
return direction === "asc" ? diff : -diff;
}
// ISO-like date strings
if (/^\d{4}-\d{2}-\d{2}/.test(aVal) && /^\d{4}-\d{2}-\d{2}/.test(bVal)) {
const da = new Date(aVal).getTime();
const db = new Date(bVal).getTime();
const diff = da - db;
return direction === "asc" ? diff : -diff;
}
}
// Fallback: locale-aware string compare with numeric collation
const aStr = String(aVal);
const bStr = String(bVal);
const comparison = collator.compare(aStr, bStr);
return direction === "asc" ? comparison : -comparison;
});
}
/**
* Return a human-friendly identifier for a row using common keys
*
* Accepts any JSON-serializable primitive or array of primitives.
* Arrays are converted to comma-separated strings.
*/
export function getRowIdentifier(
row: Record<
string,
string | number | boolean | null | (string | number | boolean | null)[]
>,
identifierKey?: string,
): string {
const candidate =
(identifierKey ? row[identifierKey] : undefined) ??
(row as Record<string, unknown>).name ??
(row as Record<string, unknown>).title ??
(row as Record<string, unknown>).id;
if (candidate == null) {
return "";
}
// Handle arrays by joining them
if (Array.isArray(candidate)) {
return candidate.map((v) => (v === null ? "null" : String(v))).join(", ");
}
return String(candidate).trim();
}
function stableStringify(value: unknown): string {
if (value == null) return "null";
if (typeof value === "string") return JSON.stringify(value);
if (
typeof value === "number" ||
typeof value === "boolean" ||
typeof value === "bigint"
) {
return String(value);
}
if (Array.isArray(value)) {
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
}
if (typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>).sort(
([a], [b]) => a.localeCompare(b),
);
return `{${entries
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
.join(",")}}`;
}
return JSON.stringify(String(value));
}
function hashString(value: string): string {
let hash = 5381;
for (let i = 0; i < value.length; i++) {
hash = (hash * 33) ^ value.charCodeAt(i);
}
return (hash >>> 0).toString(36);
}
/**
* Create deterministic, reorder-stable React keys for DataTable rows.
*
* - Uses `identifierKey` or common identifier fields as the primary base.
* - Falls back to stable content fingerprints when no identifier exists.
* - Disambiguates duplicates without relying on array index.
*/
export function createDataTableRowKeys(
rows: Array<Record<string, unknown>>,
identifierKey?: string,
): string[] {
const canonicalRows = rows.map((row) => stableStringify(row));
const baseKeys = rows.map((row, index) => {
const identifier = getRowIdentifier(
row as Record<
string,
string | number | boolean | null | (string | number | boolean | null)[]
>,
identifierKey,
);
if (identifier) {
return `id:${identifier}`;
}
return `row:${hashString(canonicalRows[index])}`;
});
const baseCounts = new Map<string, number>();
baseKeys.forEach((key) => {
baseCounts.set(key, (baseCounts.get(key) ?? 0) + 1);
});
const usedKeys = new Map<string, number>();
return rows.map((row, index) => {
const baseKey = baseKeys[index];
if ((baseCounts.get(baseKey) ?? 0) === 1) {
return baseKey;
}
const rowFingerprint = hashString(canonicalRows[index]);
let disambiguatedKey = `${baseKey}::${rowFingerprint}`;
const seenCount = usedKeys.get(disambiguatedKey) ?? 0;
usedKeys.set(disambiguatedKey, seenCount + 1);
if (seenCount > 0) {
disambiguatedKey = `${disambiguatedKey}::d${seenCount + 1}`;
}
return disambiguatedKey;
});
}
function sanitizeDomIdToken(value: string): string {
return encodeURIComponent(value).replace(/%/g, "_");
}
export function getDataTableMobileDescriptionId(surfaceId: string): string {
return `${sanitizeDomIdToken(surfaceId)}-mobile-table-description`;
}
/**
* Parse a string that represents a numeric value, handling various formats:
* - Currency symbols: $, €, £, ¥, etc.
* - Percent symbols: %
* - Accounting negatives: (1234) → -1234
* - Thousands/decimal separators: 1,234.56 or 1.234,56
* - Compact notation: 2.8T (trillion), 1.5M (million), 500K (thousand)
* - Byte suffixes: 768B (bytes), 1.5KB, 2GB, 1TB
*
* Note: Single "B" is disambiguated - integers < 1024 are bytes, otherwise billions.
*
* @param input - String to parse
* @returns Parsed number or null if unparseable
*
* @example
* parseNumericLike("$1,234.56") // 1234.56
* parseNumericLike("2.8T") // 2800000000000
* parseNumericLike("768B") // 768
* parseNumericLike("50%") // 50
* parseNumericLike("(1234)") // -1234
*/
export function parseNumericLike(input: string): number | null {
// Normalize whitespace (spaces, NBSPs, thin spaces)
let s = input.replace(/[\u00A0\u202F\s]/g, "").trim();
if (!s) return null;
// Accounting negatives: (1234) -> -1234
s = s.replace(/^\((.*)\)$/g, "-$1");
// Strip common currency and percent symbols
s = s.replace(/[%$€£¥₩₹₽₺₪₫฿₦₴₡₲₵₸]/g, "");
function hasGroupedThousands(value: string, sep: "," | "."): boolean {
const unsigned = value.replace(/^[+-]/, "");
const parts = unsigned.split(sep);
if (parts.length < 2) return false;
if (parts.some((part) => part.length === 0)) return false;
if (!/^\d{1,3}$/.test(parts[0])) return false;
if (parts[0] === "0") return false;
return parts.slice(1).every((part) => /^\d{3}$/.test(part));
}
const lastComma = s.lastIndexOf(",");
const lastDot = s.lastIndexOf(".");
if (lastComma !== -1 && lastDot !== -1) {
// Decide decimal by whichever occurs last
const decimalSep = lastComma > lastDot ? "," : ".";
const thousandSep = decimalSep === "," ? "." : ",";
s = s.split(thousandSep).join("");
s = s.replace(decimalSep, ".");
} else if (lastComma !== -1) {
// Only comma present
if (hasGroupedThousands(s, ",")) {
s = s.replace(/,/g, "");
} else {
const frac = s.length - lastComma - 1;
if (frac >= 1 && frac <= 3) s = s.replace(/,/g, ".");
else s = s.replace(/,/g, "");
}
} else if (lastDot !== -1) {
// Only dot present; normalize grouped thousands separators.
if (hasGroupedThousands(s, ".")) {
s = s.replace(/\./g, "");
} else if ((s.match(/\./g) || []).length > 1) {
s = s.replace(/\./g, "");
}
}
// Handle compact notation (K, M, B, T, P, G) and byte suffixes (KB, MB, GB, TB, PB)
const compactMatch = s.match(/^([+-]?\d+\.?\d*|\d*\.\d+)([KMBTPG]B?|B)$/i);
if (compactMatch) {
const baseNum = Number(compactMatch[1]);
if (Number.isNaN(baseNum)) return null;
const suffix = compactMatch[2].toUpperCase();
// Disambiguate single "B" (bytes vs billions)
// If whole number < 1024, treat as bytes. Otherwise, billions.
if (suffix === "B") {
const isLikelyBytes = Number.isInteger(baseNum) && baseNum < 1024;
return isLikelyBytes ? baseNum : baseNum * 1e9;
}
const multipliers: Record<string, number> = {
K: 1e3,
KB: 1024, // Kilo: metric vs binary
M: 1e6,
MB: 1024 ** 2, // Mega
G: 1e9,
GB: 1024 ** 3, // Giga
T: 1e12,
TB: 1024 ** 4, // Tera
P: 1e15,
PB: 1024 ** 5, // Peta
};
return baseNum * (multipliers[suffix] ?? 1);
}
if (/^[+-]?(?:\d+\.?\d*|\d*\.\d+)$/.test(s)) {
const n = Number(s);
return Number.isNaN(n) ? null : n;
}
return null;
}
@@ -0,0 +1,19 @@
# Image Gallery
Implementation for the "image-gallery" Tool UI surface.
## Files
- public exports: components/tool-ui/image-gallery/index.tsx
- serializable schema + parse helpers: components/tool-ui/image-gallery/schema.ts
## Companion assets
- Docs page: app/docs/image-gallery/content.mdx
- Preset payload: lib/presets/image-gallery.ts
## Quick check
Run this after edits:
pnpm test
@@ -0,0 +1,13 @@
/**
* 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., "@toolui/lib/utils", "~/lib/cn")
* Button → shadcn/ui Button
*/
export { cn } from "@toolui/lib/utils";
export { Button } from "@toolui/ui/button";
export { ChevronLeft, ChevronRight, X, ImageOff } from "lucide-react";
@@ -0,0 +1,184 @@
"use client";
import {
createContext,
useContext,
useState,
useCallback,
useMemo,
useRef,
} from "react";
import { flushSync } from "react-dom";
import type { ImageGalleryItem } from "./schema";
const VIEW_TRANSITION_NAME = "active-gallery-image";
interface ImageGalleryContextValue {
images: ImageGalleryItem[];
activeIndex: number | null;
openLightbox: (index: number) => void;
closeLightbox: () => void;
registerImage: (id: string, element: HTMLElement | null) => void;
lightboxContentRef: React.MutableRefObject<HTMLDivElement | null>;
setDialogRef: (element: HTMLDialogElement | null) => void;
}
const ImageGalleryContext = createContext<ImageGalleryContextValue | null>(
null,
);
export function useImageGallery(): ImageGalleryContextValue {
const context = useContext(ImageGalleryContext);
if (!context) {
throw new Error("useImageGallery must be used within ImageGalleryProvider");
}
return context;
}
function supportsViewTransitions(): boolean {
return (
typeof document !== "undefined" &&
"startViewTransition" in document &&
typeof window !== "undefined" &&
!window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches
);
}
function withViewTransition(
element: HTMLElement,
domUpdate: () => void,
onFinished?: () => void,
): void {
if (!supportsViewTransitions()) {
domUpdate();
onFinished?.();
return;
}
element.style.viewTransitionName = VIEW_TRANSITION_NAME;
const transition = document.startViewTransition(() => domUpdate());
transition.finished.finally(() => {
element.style.removeProperty("view-transition-name");
onFinished?.();
});
}
interface ImageGalleryProviderProps {
images: ImageGalleryItem[];
children: React.ReactNode;
}
export function ImageGalleryProvider({
images,
children,
}: ImageGalleryProviderProps) {
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const imageElementsRef = useRef<Map<string, HTMLElement>>(new Map());
const lightboxContentRef = useRef<HTMLDivElement | null>(null);
const dialogRef = useRef<HTMLDialogElement | null>(null);
const originalParentRef = useRef<HTMLElement | null>(null);
const registerImage = useCallback(
(id: string, element: HTMLElement | null) => {
if (element) {
imageElementsRef.current.set(id, element);
} else {
imageElementsRef.current.delete(id);
}
},
[],
);
const setDialogRef = useCallback((element: HTMLDialogElement | null) => {
dialogRef.current = element;
}, []);
const openLightbox = useCallback(
(index: number) => {
const image = images[index];
if (!image) return;
const imageElement = imageElementsRef.current.get(image.id);
const container = lightboxContentRef.current;
const dialog = dialogRef.current;
if (!imageElement || !container || !dialog) {
setActiveIndex(index);
dialog?.showModal();
return;
}
originalParentRef.current = imageElement.parentElement;
withViewTransition(imageElement, () => {
container.appendChild(imageElement);
flushSync(() => setActiveIndex(index));
dialog.showModal();
});
},
[images],
);
const closeLightbox = useCallback(() => {
if (activeIndex === null) return;
const image = images[activeIndex];
const dialog = dialogRef.current;
if (!image) {
setActiveIndex(null);
dialog?.close();
return;
}
const imageElement = imageElementsRef.current.get(image.id);
const originalParent = originalParentRef.current;
if (!imageElement || !originalParent) {
setActiveIndex(null);
dialog?.close();
return;
}
withViewTransition(
imageElement,
() => {
originalParent.appendChild(imageElement);
flushSync(() => setActiveIndex(null));
dialog?.close();
},
() => {
originalParentRef.current = null;
},
);
}, [activeIndex, images]);
const value = useMemo<ImageGalleryContextValue>(
() => ({
images,
activeIndex,
openLightbox,
closeLightbox,
registerImage,
lightboxContentRef,
setDialogRef,
}),
[
images,
activeIndex,
openLightbox,
closeLightbox,
registerImage,
setDialogRef,
],
);
return (
<ImageGalleryContext.Provider value={value}>
{children}
</ImageGalleryContext.Provider>
);
}
@@ -0,0 +1,133 @@
"use client";
import { useState, useCallback, useEffect, useRef } from "react";
import { cn, ImageOff } from "./_adapter";
import { useImageGallery } from "./context";
import type { ImageGalleryItem } from "./schema";
type GridImage = Pick<
ImageGalleryItem,
"id" | "src" | "alt" | "width" | "height"
>;
interface GalleryGridProps {
onImageClick?: (imageId: string) => void;
}
export function GalleryGrid({ onImageClick }: GalleryGridProps) {
const { images, openLightbox } = useImageGallery();
const handleOpen = useCallback(
(index: number) => {
const image = images[index];
if (image && onImageClick) {
onImageClick(image.id);
}
openLightbox(index);
},
[images, onImageClick, openLightbox],
);
return (
<div
className="grid grid-cols-2 gap-2 @md:grid-cols-3 @lg:grid-cols-4"
role="list"
>
{images.map((image, index) => (
<GridImageCard
key={image.id}
image={image}
index={index}
onClick={handleOpen}
/>
))}
</div>
);
}
interface GridImageCardProps {
image: GridImage;
index: number;
onClick: (index: number) => void;
}
function GridImageCard({ image, index, onClick }: GridImageCardProps) {
const [hasError, setHasError] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
const { registerImage } = useImageGallery();
const shouldSpanTwoRows = isPortraitImage(image);
useEffect(() => {
const wrapper = wrapperRef.current;
const img = wrapper?.querySelector("img");
if (img) {
registerImage(image.id, img);
}
return () => {
registerImage(image.id, null);
};
}, [image.id, registerImage]);
const handleClick = useCallback(() => {
onClick(index);
}, [onClick, index]);
return (
<div
role="listitem"
className={cn(
"group relative cursor-pointer",
shouldSpanTwoRows && "row-span-2",
)}
style={{ aspectRatio: shouldSpanTwoRows ? undefined : "1 / 1" }}
>
<button
type="button"
onClick={handleClick}
className="absolute inset-0 z-20 h-full w-full rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
aria-label={image.alt}
/>
<div
ref={wrapperRef}
className="bg-muted relative h-full w-full overflow-hidden rounded-lg transition-transform duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] group-hover:scale-[1.02] group-active:scale-[0.98]"
>
{hasError ? (
<ImageErrorState alt={image.alt} />
) : (
<img
src={image.src}
alt={image.alt}
width={image.width}
height={image.height}
loading="lazy"
decoding="async"
draggable={false}
onError={() => setHasError(true)}
className="h-full w-full object-cover"
/>
)}
</div>
</div>
);
}
function isPortraitImage(image: GridImage): boolean {
const aspectRatio = image.width / image.height;
const isPortrait = aspectRatio < 1;
const isSquarish = aspectRatio >= 0.9 && aspectRatio <= 1.1;
return isPortrait && !isSquarish;
}
function ImageErrorState({ alt }: { alt: string }) {
return (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 p-4">
<ImageOff className="text-muted-foreground h-8 w-8" />
<span className="text-muted-foreground line-clamp-2 text-center text-xs">
{alt}
</span>
</div>
);
}
@@ -0,0 +1,146 @@
"use client";
import { useRef, useCallback } from "react";
import { cn, Button, X } from "./_adapter";
import { useImageGallery } from "./context";
import type { ImageGalleryItem } from "./schema";
import { resolveSafeNavigationHref } from "../shared/media";
type LightboxImage = Pick<ImageGalleryItem, "title" | "caption" | "source">;
export function GalleryLightbox() {
const dialogRef = useRef<HTMLDialogElement | null>(null);
const {
images,
activeIndex,
closeLightbox,
lightboxContentRef,
setDialogRef,
} = useImageGallery();
const isOpen = activeIndex !== null;
const currentImage = isOpen ? images[activeIndex] : null;
const handleDialogRef = useCallback(
(element: HTMLDialogElement | null) => {
dialogRef.current = element;
setDialogRef(element);
},
[setDialogRef],
);
const handleBackdropClick = useCallback(
(e: React.MouseEvent<HTMLDialogElement>) => {
if (e.target === dialogRef.current) {
closeLightbox();
}
},
[closeLightbox],
);
const handleCancel = useCallback(
(e: React.SyntheticEvent<HTMLDialogElement>) => {
e.preventDefault();
closeLightbox();
},
[closeLightbox],
);
return (
<dialog
ref={handleDialogRef}
onClick={handleBackdropClick}
onCancel={handleCancel}
className={cn(
"m-0 h-full max-h-full w-full max-w-full",
"overflow-hidden p-0",
"bg-transparent backdrop:bg-black/95 dark:backdrop:bg-black/90",
"focus-visible:outline-none",
)}
aria-label="Image lightbox"
>
<div className="relative h-full w-full">
{isOpen && <CloseButton onClose={closeLightbox} />}
<div className="relative z-10 flex h-full w-full flex-col items-center justify-center gap-4 p-8">
<div
ref={lightboxContentRef}
className={cn(
"pointer-events-auto relative w-fit max-w-full overflow-hidden rounded-lg shadow-2xl",
"[&>img]:block [&>img]:max-h-[80vh] [&>img]:max-w-full",
"[&>img]:h-auto [&>img]:w-auto [&>img]:object-contain [&>img]:select-none",
)}
/>
{currentImage && <Metadata image={currentImage} />}
</div>
</div>
</dialog>
);
}
function CloseButton({ onClose }: { onClose: () => void }) {
return (
<div className="absolute top-4 right-4 z-20">
<Button
type="button"
variant="ghost"
size="icon"
onClick={onClose}
className="text-white/80 hover:bg-white/10 hover:text-white"
aria-label="Close"
>
<X className="h-5 w-5" />
</Button>
</div>
);
}
function Metadata({ image }: { image: LightboxImage }) {
const { title, caption, source } = image;
const hasTitle = Boolean(title);
const hasCaption = Boolean(caption);
const hasSource = Boolean(source?.label);
if (!hasTitle && !hasCaption && !hasSource) {
return null;
}
return (
<div className="text-center">
{hasTitle && (
<h3 className="text-base font-medium tracking-tight text-white">
{title}
</h3>
)}
{(hasCaption || hasSource) && (
<p className="mt-1 text-sm text-white/60">
{caption}
{hasCaption && hasSource && " · "}
{hasSource && <SourceLink source={source!} />}
</p>
)}
</div>
);
}
function SourceLink({
source,
}: {
source: NonNullable<LightboxImage["source"]>;
}) {
const href = resolveSafeNavigationHref(source.url);
if (!href) {
return <>{source.label}</>;
}
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="hover:text-white/80 hover:underline"
>
{source.label}
</a>
);
}
@@ -0,0 +1,75 @@
"use client";
import "./styles.css";
import { cn } from "./_adapter";
import { ImageGalleryProvider } from "./context";
import { GalleryGrid } from "./gallery-grid";
import { GalleryLightbox } from "./gallery-lightbox";
import type { ImageGalleryProps } from "./schema";
export function ImageGallery({
id,
images,
title,
description,
className,
onImageClick,
}: ImageGalleryProps) {
const handleImageClick = (imageId: string) => {
if (!onImageClick) return;
const image = images.find((img) => img.id === imageId);
if (image) {
onImageClick(imageId, image);
}
};
return (
<article
className={cn("relative w-full min-w-80 max-w-lg", className)}
data-tool-ui-id={id}
data-slot="image-gallery"
>
<div
className={cn(
"@container relative isolate flex w-full min-w-0 flex-col rounded-xl",
"border border-border bg-card text-sm shadow-xs",
)}
>
<ImageGalleryProvider images={images}>
<Header title={title} description={description} />
<div className="p-3">
<GalleryGrid onImageClick={handleImageClick} />
</div>
<GalleryLightbox />
</ImageGalleryProvider>
</div>
</article>
);
}
interface HeaderProps {
title?: string;
description?: string;
}
function Header({ title, description }: HeaderProps) {
if (!title && !description) {
return null;
}
return (
<div className="border-border/60 border-b px-4 pt-4 pb-3">
{title && (
<h3 className="text-[15px] leading-tight font-semibold tracking-tight">
{title}
</h3>
)}
{description && (
<p className="text-muted-foreground mt-1 text-sm leading-snug">
{description}
</p>
)}
</div>
);
}
@@ -0,0 +1,6 @@
export { ImageGallery } from "./image-gallery";
export type {
ImageGalleryProps,
ImageGalleryItem,
SerializableImageGallery,
} from "./schema";
@@ -0,0 +1,59 @@
import { z } from "zod";
import { defineToolUiContract } from "../shared/contract";
import {
ToolUIIdSchema,
ToolUIReceiptSchema,
ToolUIRoleSchema,
} from "../shared/schema";
export const ImageGallerySourceSchema = z.object({
label: z.string(),
url: z.string().url().optional(),
});
export type ImageGallerySource = z.infer<typeof ImageGallerySourceSchema>;
export const ImageGalleryItemSchema = z.object({
id: z.string().min(1),
src: z.string().url(),
alt: z.string().min(1, "Images require alt text for accessibility"),
width: z.number().positive(),
height: z.number().positive(),
title: z.string().optional(),
caption: z.string().optional(),
source: ImageGallerySourceSchema.optional(),
});
export type ImageGalleryItem = z.infer<typeof ImageGalleryItemSchema>;
export const SerializableImageGallerySchema = z.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
receipt: ToolUIReceiptSchema.optional(),
images: z.array(ImageGalleryItemSchema).min(1),
title: z.string().optional(),
description: z.string().optional(),
});
export type SerializableImageGallery = z.infer<
typeof SerializableImageGallerySchema
>;
export interface ImageGalleryProps extends SerializableImageGallery {
className?: string;
onImageClick?: (imageId: string, image: ImageGalleryItem) => void;
}
const SerializableImageGallerySchemaContract = defineToolUiContract(
"ImageGallery",
SerializableImageGallerySchema,
);
export const parseSerializableImageGallery: (
input: unknown,
) => SerializableImageGallery = SerializableImageGallerySchemaContract.parse;
export const safeParseSerializableImageGallery: (
input: unknown,
) => SerializableImageGallery | null =
SerializableImageGallerySchemaContract.safeParse;
@@ -0,0 +1,25 @@
@supports (view-transition-name: none) {
::view-transition-group(active-gallery-image) {
animation-duration: 300ms;
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
overflow: clip;
border-radius: 0.75rem;
}
::view-transition-image-pair(active-gallery-image) {
overflow: clip;
border-radius: 0.75rem;
}
::view-transition-old(active-gallery-image),
::view-transition-new(active-gallery-image) {
border-radius: 0.75rem;
mix-blend-mode: normal;
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(active-gallery-image) {
animation-duration: 0ms;
}
}
}
@@ -0,0 +1,19 @@
# Image
Implementation for the "image" Tool UI surface.
## Files
- public exports: components/tool-ui/image/index.ts
- serializable schema + parse helpers: components/tool-ui/image/schema.ts
## Companion assets
- Docs page: app/docs/image/content.mdx
- Preset payload: lib/presets/image.ts
## Quick check
Run this after edits:
pnpm test
@@ -0,0 +1,11 @@
/**
* 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., "@toolui/lib/utils", "~/lib/cn")
*/
"use client";
export { cn } from "@toolui/lib/utils";
@@ -0,0 +1,206 @@
"use client";
import * as React from "react";
import { cn } from "./_adapter";
import {
RATIO_CLASS_MAP,
getFitClass,
openSafeNavigationHref,
resolveSafeNavigationHref,
sanitizeHref,
} from "../shared/media";
import type { SerializableImage, Source } from "./schema";
const FALLBACK_LOCALE = "en-US";
export interface ImageProps extends SerializableImage {
className?: string;
onNavigate?: (href: string, image: SerializableImage) => void;
}
export function Image(props: ImageProps) {
const { className, onNavigate, ...serializable } = props;
const {
id,
src,
alt,
title,
href: rawHref,
domain,
ratio = "auto",
fit = "cover",
source,
locale: providedLocale,
} = serializable;
const locale = providedLocale ?? FALLBACK_LOCALE;
const sanitizedHref = sanitizeHref(rawHref);
const resolvedSourceUrl = sanitizeHref(source?.url);
const imageData: SerializableImage = {
...serializable,
href: sanitizedHref,
source: source ? { ...source, url: resolvedSourceUrl } : undefined,
locale,
};
const sourceLabel = source?.label ?? domain;
const fallbackInitial = (sourceLabel ?? "").trim().charAt(0).toUpperCase();
const hasSource = Boolean(sourceLabel || source?.iconUrl);
const handleSourceClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
const targetUrl = resolveSafeNavigationHref(
resolvedSourceUrl,
source?.url,
sanitizedHref,
src,
);
if (!targetUrl) return;
if (onNavigate) {
onNavigate(targetUrl, imageData);
} else {
openSafeNavigationHref(targetUrl);
}
};
const handleImageClick = () => {
if (!sanitizedHref) return;
if (onNavigate) {
onNavigate(sanitizedHref, imageData);
} else {
openSafeNavigationHref(sanitizedHref);
}
};
const hasMetadata = title || hasSource;
return (
<article
className={cn("relative w-full max-w-md min-w-80", className)}
lang={locale}
data-tool-ui-id={id}
data-slot="image"
>
<div
className={cn(
"group @container relative isolate flex w-full min-w-0 flex-col overflow-hidden rounded-xl",
"border-border bg-card border text-sm shadow-xs",
)}
>
<>
<div
className={cn(
"bg-muted group relative w-full overflow-hidden",
ratio !== "auto" ? RATIO_CLASS_MAP[ratio] : "min-h-[160px]",
sanitizedHref && "cursor-pointer",
)}
onClick={sanitizedHref ? handleImageClick : undefined}
role={sanitizedHref ? "link" : undefined}
tabIndex={sanitizedHref ? 0 : undefined}
onKeyDown={
sanitizedHref
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleImageClick();
}
}
: undefined
}
>
<img
src={src}
alt={alt}
loading="lazy"
decoding="async"
className={cn("absolute inset-0 h-full w-full", getFitClass(fit))}
/>
</div>
{hasMetadata && (
<div className="flex items-center gap-3 px-4 py-3">
<SourceAttribution
source={source}
sourceLabel={sourceLabel}
fallbackInitial={fallbackInitial}
hasClickableUrl={Boolean(resolvedSourceUrl)}
onSourceClick={handleSourceClick}
title={title}
/>
</div>
)}
</>
</div>
</article>
);
}
interface SourceAttributionProps {
source?: Source;
sourceLabel?: string;
fallbackInitial: string;
hasClickableUrl: boolean;
onSourceClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
title?: string;
}
function SourceAttribution({
source,
sourceLabel,
fallbackInitial,
hasClickableUrl,
onSourceClick,
title,
}: SourceAttributionProps) {
const hasSource = Boolean(sourceLabel || source?.iconUrl);
const content = (
<div className="flex min-w-0 flex-1 items-center gap-3">
{source?.iconUrl ? (
<img
src={source.iconUrl}
alt=""
aria-hidden="true"
width={32}
height={32}
className="size-8 shrink-0 rounded-full object-cover"
loading="lazy"
decoding="async"
/>
) : fallbackInitial ? (
<div className="bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold uppercase">
{fallbackInitial}
</div>
) : null}
<div className="min-w-0 flex-1">
{title && (
<div className="text-foreground line-clamp-1 text-sm font-medium">
{title}
</div>
)}
{sourceLabel && (
<div className="text-muted-foreground line-clamp-1 text-xs">
{sourceLabel}
</div>
)}
</div>
</div>
);
if (hasClickableUrl && hasSource) {
return (
<button
type="button"
onClick={onSourceClick}
className="focus-visible:ring-ring flex w-full items-center gap-3 text-left hover:opacity-80 focus-visible:ring-2 focus-visible:outline-none"
>
{content}
</button>
);
}
return <div className="flex w-full items-center gap-3">{content}</div>;
}
@@ -0,0 +1,3 @@
export { Image } from "./image";
export type { ImageProps } from "./image";
export type { SerializableImage, Source } from "./schema";
@@ -0,0 +1,50 @@
import { z } from "zod";
import { defineToolUiContract } from "../shared/contract";
import {
ToolUIIdSchema,
ToolUIReceiptSchema,
ToolUIRoleSchema,
} from "../shared/schema";
import { AspectRatioSchema, MediaFitSchema } from "../shared/media";
export const SourceSchema = z.object({
label: z.string(),
iconUrl: z.url().optional(),
url: z.url().optional(),
});
export type Source = z.infer<typeof SourceSchema>;
export const SerializableImageSchema = z.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
receipt: ToolUIReceiptSchema.optional(),
assetId: z.string(),
src: z.url(),
alt: z.string().min(1, "Images require alt text for accessibility"),
title: z.string().optional(),
description: z.string().optional(),
href: z.url().optional(),
domain: z.string().optional(),
ratio: AspectRatioSchema.optional(),
fit: MediaFitSchema.optional(),
fileSizeBytes: z.number().int().positive().optional(),
createdAt: z.string().datetime().optional(),
locale: z.string().optional(),
source: SourceSchema.optional(),
});
export type SerializableImage = z.infer<typeof SerializableImageSchema>;
const SerializableImageSchemaContract = defineToolUiContract(
"Image",
SerializableImageSchema,
);
export const parseSerializableImage: (input: unknown) => SerializableImage =
SerializableImageSchemaContract.parse;
export const safeParseSerializableImage: (
input: unknown,
) => SerializableImage | null = SerializableImageSchemaContract.safeParse;
@@ -0,0 +1,19 @@
# Instagram Post
Implementation for the "instagram-post" Tool UI surface.
## Files
- public exports: components/tool-ui/instagram-post/index.ts
- serializable schema + parse helpers: components/tool-ui/instagram-post/schema.ts
## Companion assets
- Docs page: app/docs/social-post/content.mdx
- Preset payload: lib/presets/instagram-post.ts
## Quick check
Run this after edits:
pnpm test
@@ -0,0 +1,19 @@
/**
* 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., "@toolui/lib/utils", "~/lib/cn")
* Button → shadcn/ui Button
* Tooltip → shadcn/ui Tooltip
*/
export { cn } from "@toolui/lib/utils";
export { Button } from "@toolui/ui/button";
export {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@toolui/ui/tooltip";
@@ -0,0 +1,8 @@
export { InstagramPost } from "./instagram-post";
export type { InstagramPostProps } from "./instagram-post";
export type {
InstagramPostData,
InstagramPostAuthor,
InstagramPostMedia,
InstagramPostStats,
} from "./schema";
@@ -0,0 +1,305 @@
"use client";
import * as React from "react";
import { BadgeCheck, Heart, Share } from "lucide-react";
import {
cn,
Button,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "./_adapter";
import { formatRelativeTime } from "../shared/utils";
import type { InstagramPostData, InstagramPostMedia } from "./schema";
export interface InstagramPostProps {
post: InstagramPostData;
className?: string;
onAction?: (action: string, post: InstagramPostData) => void;
}
function InstagramLogo({ className }: { className?: string }) {
const id = React.useId();
const gradientPrimaryId = `ig-primary-${id}`;
const gradientSecondaryId = `ig-secondary-${id}`;
return (
<svg
viewBox="0 0 132 132"
className={className}
role="img"
aria-label="Instagram logo"
>
<defs>
<radialGradient
id={gradientPrimaryId}
cx="158.429"
cy="578.088"
r="65"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0 -1.982 1.844 0 -1031.4 454)"
>
<stop offset="0" stopColor="#fd5" />
<stop offset=".1" stopColor="#fd5" />
<stop offset=".5" stopColor="#ff543e" />
<stop offset="1" stopColor="#c837ab" />
</radialGradient>
<radialGradient
id={gradientSecondaryId}
cx="147.694"
cy="473.455"
r="65"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(.174 .869 -3.58 .717 1648 -458.5)"
>
<stop offset="0" stopColor="#3771c8" />
<stop offset=".128" stopColor="#3771c8" />
<stop offset="1" stopColor="#60f" stopOpacity="0" />
</radialGradient>
</defs>
<path
fill={`url(#${gradientPrimaryId})`}
d="M65 0C37.9 0 30 .03 28.4.16c-5.6.46-9 1.34-12.8 3.22-2.9 1.44-5.2 3.12-7.5 5.47C4 13.1 1.5 18.4.6 24.66c-.44 3.04-.57 3.66-.6 19.2-.01 5.16 0 12 0 21.1 0 27.12.03 35.05.16 36.6.45 5.4 1.3 8.82 3.1 12.55 3.44 7.14 10 12.5 17.76 14.5 2.68.7 5.64 1.1 9.44 1.26 1.6.07 18 .12 34.44.12s32.84-.02 34.4-.1c4.4-.2 6.96-.55 9.8-1.28 7.78-2.01 14.23-7.3 17.74-14.53 1.76-3.64 2.66-7.18 3.07-12.32.08-1.12.12-18.97.12-36.8 0-17.85-.04-35.67-.13-36.8-.4-5.2-1.3-8.7-3.13-12.43-1.5-3.04-3.16-5.3-5.56-7.62C116.9 4 111.64 1.5 105.37.6 102.34.16 101.73.03 86.2 0H65z"
transform="translate(1 1)"
/>
<path
fill={`url(#${gradientSecondaryId})`}
d="M65 0C37.9 0 30 .03 28.4.16c-5.6.46-9 1.34-12.8 3.22-2.9 1.44-5.2 3.12-7.5 5.47C4 13.1 1.5 18.4.6 24.66c-.44 3.04-.57 3.66-.6 19.2-.01 5.16 0 12 0 21.1 0 27.12.03 35.05.16 36.6.45 5.4 1.3 8.82 3.1 12.55 3.44 7.14 10 12.5 17.76 14.5 2.68.7 5.64 1.1 9.44 1.26 1.6.07 18 .12 34.44.12s32.84-.02 34.4-.1c4.4-.2 6.96-.55 9.8-1.28 7.78-2.01 14.23-7.3 17.74-14.53 1.76-3.64 2.66-7.18 3.07-12.32.08-1.12.12-18.97.12-36.8 0-17.85-.04-35.67-.13-36.8-.4-5.2-1.3-8.7-3.13-12.43-1.5-3.04-3.16-5.3-5.56-7.62C116.9 4 111.64 1.5 105.37.6 102.34.16 101.73.03 86.2 0H65z"
transform="translate(1 1)"
/>
<path
fill="#fff"
d="M66 18c-13 0-14.67.06-19.8.3-5.1.23-8.6 1.04-11.64 2.22-3.16 1.23-5.84 2.87-8.5 5.54-2.67 2.67-4.3 5.35-5.54 8.5-1.2 3.05-2 6.54-2.23 11.65C18.06 51.33 18 52.96 18 66s.06 14.67.3 19.78c.22 5.12 1.03 8.6 2.22 11.66 1.22 3.15 2.86 5.83 5.53 8.5 2.67 2.67 5.35 4.3 8.5 5.53 3.06 1.2 6.55 2 11.65 2.23 5.12.23 6.76.3 19.8.3 13 0 14.66-.07 19.78-.3 5.12-.23 8.6-1.03 11.66-2.23 3.15-1.23 5.83-2.87 8.5-5.53 2.67-2.67 4.3-5.35 5.53-8.5 1.2-3.06 2-6.54 2.23-11.66.23-5.1.3-6.75.3-19.78 0-13.04-.07-14.68-.3-19.8-.23-5.1-1.04-8.6-2.22-11.64-1.23-3.16-2.87-5.84-5.54-8.5-2.67-2.67-5.35-4.3-8.5-5.54-3.06-1.18-6.55-2-11.66-2.22-5.12-.24-6.75-.3-19.8-.3zm-4.3 8.65c1.28 0 2.7 0 4.3 0 12.82 0 14.34.05 19.4.28 4.67.2 7.22 1 8.9 1.65 2.25.87 3.84 1.9 5.52 3.6 1.68 1.67 2.72 3.27 3.6 5.5.65 1.7 1.43 4.24 1.64 8.92.23 5.05.28 6.57.28 19.4s-.05 14.32-.28 19.4c-.2 4.67-1 7.2-1.64 8.9-.88 2.25-1.92 3.84-3.6 5.52-1.68 1.68-3.27 2.72-5.52 3.6-1.7.65-4.23 1.43-8.9 1.64-5.06.23-6.58.28-19.4.28-12.82 0-14.34-.05-19.4-.28-4.68-.2-7.22-1-8.9-1.64-2.25-.88-3.84-1.92-5.52-3.6-1.68-1.68-2.72-3.27-3.6-5.52-.65-1.7-1.43-4.23-1.64-8.9-.23-5.06-.28-6.58-.28-19.4s.05-14.34.28-19.4c.2-4.68 1-7.22 1.64-8.9.88-2.24 1.92-3.83 3.6-5.52 1.68-1.68 3.27-2.72 5.52-3.6 1.7-.65 4.23-1.43 8.9-1.65 4.43-.2 6.15-.26 15.1-.27zm30 8c-3.2 0-5.77 2.57-5.77 5.75 0 3.2 2.58 5.77 5.77 5.77 3.18 0 5.76-2.58 5.76-5.77 0-3.18-2.58-5.76-5.76-5.76zm-25.63 6.72c-13.6 0-24.64 11.04-24.64 24.65 0 13.6 11.03 24.64 24.64 24.64 13.6 0 24.65-11.03 24.65-24.64 0-13.6-11.04-24.64-24.65-24.64zm0 8.65c8.84 0 16 7.16 16 16 0 8.84-7.16 16-16 16-8.84 0-16-7.16-16-16 0-8.84 7.16-16 16-16z"
/>
</svg>
);
}
function Header({
author,
createdAt,
}: {
author: InstagramPostData["author"];
createdAt?: string;
}) {
return (
<header className="flex items-center gap-3 p-3">
<img
src={author.avatarUrl}
alt={`${author.name} avatar`}
width={32}
height={32}
className="size-8 rounded-full object-cover"
/>
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<span className="truncate text-sm font-semibold">{author.handle}</span>
{author.verified && (
<BadgeCheck
aria-label="Verified"
className="size-3.5 shrink-0 text-sky-500"
/>
)}
{createdAt && (
<>
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground text-sm">
{formatRelativeTime(createdAt)}
</span>
</>
)}
</div>
<InstagramLogo className="size-5" />
</header>
);
}
function MediaGrid({
media,
onOpen,
}: {
media: InstagramPostMedia[];
onOpen?: (index: number) => void;
}) {
if (media.length === 0) return null;
const renderItem = (item: InstagramPostMedia, index: number) => (
<button
key={index}
type="button"
className="bg-muted relative block size-full overflow-hidden"
onClick={() => onOpen?.(index)}
>
{item.type === "image" ? (
<img
src={item.url}
alt={item.alt}
className="size-full object-cover"
loading="lazy"
/>
) : (
<video src={item.url} playsInline className="size-full object-cover" />
)}
</button>
);
if (media.length === 1) {
return (
<div className="aspect-square w-full overflow-hidden">
{renderItem(media[0], 0)}
</div>
);
}
if (media.length === 2) {
return (
<div className="grid aspect-square w-full grid-cols-2 gap-0.5 overflow-hidden">
{media.map(renderItem)}
</div>
);
}
if (media.length === 3) {
return (
<div className="grid aspect-square w-full grid-cols-2 gap-0.5 overflow-hidden">
<div className="h-full">{renderItem(media[0], 0)}</div>
<div className="grid h-full grid-rows-2 gap-0.5">
{media.slice(1).map((item, i) => (
<div key={i + 1} className="h-full">
{renderItem(item, i + 1)}
</div>
))}
</div>
</div>
);
}
return (
<div className="grid aspect-square w-full grid-cols-2 gap-0.5 overflow-hidden">
{media.slice(0, 4).map((item, index) => (
<div key={index} className="relative h-full w-full">
{renderItem(item, index)}
{index === 3 && media.length > 4 && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/50">
<span className="text-2xl font-semibold text-white">
+{media.length - 4}
</span>
</div>
)}
</div>
))}
</div>
);
}
function PostBody({ text }: { text?: string }) {
if (!text) return null;
return (
<span className="text-sm leading-relaxed text-pretty wrap-break-word whitespace-pre-wrap">
{text}
</span>
);
}
function ActionButton({
icon: Icon,
label,
active,
hoverColor,
activeColor,
onClick,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
active?: boolean;
hoverColor: string;
activeColor?: string;
onClick: () => void;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onClick();
}}
className={cn("h-auto", hoverColor, active && activeColor)}
aria-label={label}
>
<Icon className="size-5" />
</Button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
function PostActions({
stats,
onAction,
}: {
stats?: InstagramPostData["stats"];
onAction: (action: string) => void;
}) {
return (
<TooltipProvider delayDuration={300}>
<div className="flex items-center gap-1">
<ActionButton
icon={Heart}
label="Like"
active={stats?.isLiked}
hoverColor="hover:opacity-60"
activeColor="text-red-500 fill-red-500"
onClick={() => onAction("like")}
/>
<ActionButton
icon={Share}
label="Share"
hoverColor="hover:opacity-60"
onClick={() => onAction("share")}
/>
</div>
</TooltipProvider>
);
}
export function InstagramPost({
post,
className,
onAction,
}: InstagramPostProps) {
return (
<div
className={cn("flex max-w-xl flex-col gap-3", className)}
data-tool-ui-id={post.id}
data-slot="instagram-post"
>
<article className="bg-card overflow-hidden rounded-lg border shadow-sm">
<Header author={post.author} createdAt={post.createdAt} />
{post.media && post.media.length > 0 && (
<MediaGrid media={post.media} />
)}
<div className="flex flex-col gap-2 p-3">
<PostActions
stats={post.stats}
onAction={(action) => onAction?.(action, post)}
/>
{post.text && (
<div>
<span className="text-sm font-semibold">
{post.author.handle}
</span>{" "}
<PostBody text={post.text} />
</div>
)}
</div>
</article>
</div>
);
}
@@ -0,0 +1,57 @@
import { z } from "zod";
import { defineToolUiContract } from "../shared/contract";
export const InstagramPostAuthorSchema = z.object({
name: z.string(),
handle: z.string(),
avatarUrl: z.string(),
verified: z.boolean().optional(),
});
export const InstagramPostMediaSchema = z.object({
type: z.enum(["image", "video"]),
url: z.string(),
alt: z.string(),
});
export const InstagramPostStatsSchema = z.object({
likes: z.number().optional(),
isLiked: z.boolean().optional(),
});
export interface InstagramPostData {
id: string;
author: z.infer<typeof InstagramPostAuthorSchema>;
text?: string;
media?: z.infer<typeof InstagramPostMediaSchema>[];
stats?: z.infer<typeof InstagramPostStatsSchema>;
createdAt?: string;
}
export const SerializableInstagramPostSchema: z.ZodType<InstagramPostData> =
z.object({
id: z.string(),
author: InstagramPostAuthorSchema,
text: z.string().optional(),
media: z.array(InstagramPostMediaSchema).optional(),
stats: InstagramPostStatsSchema.optional(),
createdAt: z.string().optional(),
});
export type InstagramPostAuthor = z.infer<typeof InstagramPostAuthorSchema>;
export type InstagramPostMedia = z.infer<typeof InstagramPostMediaSchema>;
export type InstagramPostStats = z.infer<typeof InstagramPostStatsSchema>;
const SerializableInstagramPostSchemaContract = defineToolUiContract(
"InstagramPost",
SerializableInstagramPostSchema,
);
export const parseSerializableInstagramPost: (
input: unknown,
) => InstagramPostData = SerializableInstagramPostSchemaContract.parse;
export const safeParseSerializableInstagramPost: (
input: unknown,
) => InstagramPostData | null =
SerializableInstagramPostSchemaContract.safeParse;
@@ -0,0 +1,19 @@
# Item Carousel
Implementation for the "item-carousel" Tool UI surface.
## Files
- public exports: components/tool-ui/item-carousel/index.tsx
- serializable schema + parse helpers: components/tool-ui/item-carousel/schema.ts
## Companion assets
- Docs page: app/docs/item-carousel/content.mdx
- Preset payload: lib/presets/item-carousel.ts
## Quick check
Run this after edits:
pnpm test
@@ -0,0 +1,12 @@
/**
* UI and utility re-exports for copy-standalone portability.
*
* This file centralizes dependencies so the component can be easily
* copied to another project by updating these imports to match the target
* project's paths.
*/
export { cn } from "@toolui/lib/utils";
export { Button } from "@toolui/ui/button";
export { Card } from "@toolui/ui/card";
export { ChevronLeft, ChevronRight } from "lucide-react";
@@ -0,0 +1,8 @@
export { ItemCarousel } from "./item-carousel";
export { ItemCard } from "./item-card";
export type {
Item,
ItemCarouselProps,
SerializableItem,
SerializableItemCarousel,
} from "./schema";
@@ -0,0 +1,110 @@
"use client";
import { cn, Button, Card } from "./_adapter";
import type { Item } from "./schema";
interface ItemCardProps {
item: Item;
onItemClick?: (itemId: string) => void;
onItemAction?: (itemId: string, actionId: string) => void;
}
export function ItemCard({ item, onItemClick, onItemAction }: ItemCardProps) {
const { id, name, subtitle, image, color, actions } = item;
const isCardInteractive = typeof onItemClick === "function";
const handleCardClick = () => {
if (!isCardInteractive) return;
onItemClick?.(id);
};
const handleActionClick = (actionId: string) => {
onItemAction?.(id, actionId);
};
return (
<Card
className={cn(
"group @container/card relative flex w-52 min-w-48 flex-col gap-0 self-stretch overflow-clip rounded-md p-0 @lg:w-56",
isCardInteractive && "cursor-pointer hover:shadow",
"touch-manipulation",
)}
>
{isCardInteractive && (
<button
type="button"
aria-label={`View item: ${name}`}
className={cn(
"absolute inset-0 z-10 rounded-md",
"cursor-pointer touch-manipulation",
"focus-visible:ring-ring focus-visible:ring-offset-background focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
)}
onClick={handleCardClick}
/>
)}
<div className="bg-muted relative aspect-square w-full overflow-hidden">
{image ? (
<img
src={image}
alt={name}
loading="lazy"
decoding="async"
draggable={false}
className={cn(
"h-full w-full object-cover transition-transform duration-200",
isCardInteractive && "group-hover:scale-105",
)}
/>
) : (
<div
className={cn(
"h-full w-full transition-transform duration-200",
isCardInteractive && "group-hover:scale-105",
)}
style={color ? { backgroundColor: color } : undefined}
role="img"
aria-label={name}
/>
)}
</div>
<div className="flex flex-1 flex-col gap-1 p-3">
<div className="flex flex-col gap-1">
<h3 className="line-clamp-2 text-sm leading-tight font-medium">
{name}
</h3>
{subtitle && (
<p className="text-muted-foreground line-clamp-1 text-sm">
{subtitle}
</p>
)}
</div>
{actions && actions.length > 0 && (
<div
className={cn(
"relative z-20 mt-auto flex flex-col-reverse gap-2 pt-2 @[176px]/card:flex-row",
)}
>
{actions.map((action) => (
<Button
key={action.id}
type="button"
variant={action.variant ?? "default"}
size="sm"
disabled={action.disabled}
className="min-h-11 w-full px-3 md:min-h-8 @[176px]/card:h-8 @[176px]/card:w-auto @[176px]/card:flex-1"
onClick={() => handleActionClick(action.id)}
>
{action.icon}
{action.label}
</Button>
))}
</div>
)}
</div>
</Card>
);
}
@@ -0,0 +1,404 @@
"use client";
import { useRef, useState, useEffect, useCallback } from "react";
import { cn, Button, Card, ChevronLeft, ChevronRight } from "./_adapter";
import { ItemCard } from "./item-card";
import { prefersReducedMotion } from "../shared/utils";
import type { ItemCarouselProps } from "./schema";
const SCROLL_PADDING_STYLE = { scrollPaddingInline: "1rem" };
const SCROLL_EDGE_THRESHOLD_PX = 8;
const SNAP_EPSILON_PX = 5;
const SCROLL_ANIMATION_DURATION_MS = 300;
const PAGE_SCROLL_RATIO = 0.8;
const PAGE_SCROLL_BREAKPOINT_PX = 640;
type ScrollDirection = "left" | "right";
interface ScrollAnimationState {
target: number;
start: number;
startTime: number;
duration: number;
onComplete?: () => void;
}
function useSmoothScroll() {
const animationRef = useRef<ScrollAnimationState | null>(null);
const frameRef = useRef<number | null>(null);
const cancelAnimation = useCallback(() => {
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
animationRef.current = null;
}, []);
useEffect(() => cancelAnimation, [cancelAnimation]);
const scrollTo = useCallback(
(
element: HTMLElement,
target: number,
duration = SCROLL_ANIMATION_DURATION_MS,
onComplete?: () => void,
) => {
if (prefersReducedMotion() || duration <= 0) {
element.scrollLeft = target;
onComplete?.();
return;
}
cancelAnimation();
animationRef.current = {
target,
start: element.scrollLeft,
startTime: performance.now(),
duration,
onComplete,
};
element.style.scrollSnapType = "none";
const step = () => {
const anim = animationRef.current;
if (!anim) return;
const elapsed = performance.now() - anim.startTime;
const progress = Math.min(elapsed / anim.duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
element.scrollLeft = anim.start + (anim.target - anim.start) * eased;
if (progress < 1) {
frameRef.current = requestAnimationFrame(step);
return;
}
element.scrollLeft = anim.target;
const callback = anim.onComplete;
cancelAnimation();
requestAnimationFrame(() => {
element.style.scrollSnapType = "";
callback?.();
});
};
frameRef.current = requestAnimationFrame(step);
},
[cancelAnimation],
);
const isAnimating = useCallback(
() => animationRef.current !== null && frameRef.current !== null,
[],
);
return { scrollTo, isAnimating, cancelAnimation };
}
function useScrollEdgeState(
scrollRef: React.RefObject<HTMLDivElement | null>,
itemCount: number,
) {
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const updateState = useCallback(() => {
const container = scrollRef.current;
if (!container) return;
const scrollLeft = Math.round(container.scrollLeft);
const maxScroll = Math.max(
0,
Math.round(container.scrollWidth - container.clientWidth),
);
setCanScrollLeft(scrollLeft > SCROLL_EDGE_THRESHOLD_PX);
setCanScrollRight(scrollLeft < maxScroll - SCROLL_EDGE_THRESHOLD_PX);
}, [scrollRef]);
useEffect(() => {
const container = scrollRef.current;
if (!container) return;
let rafId: number | null = null;
const scheduleUpdate = () => {
if (rafId !== null) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
rafId = null;
updateState();
});
};
scheduleUpdate();
container.addEventListener("scroll", scheduleUpdate, { passive: true });
const resizeObserver = new ResizeObserver(scheduleUpdate);
resizeObserver.observe(container);
return () => {
container.removeEventListener("scroll", scheduleUpdate);
resizeObserver.disconnect();
if (rafId !== null) cancelAnimationFrame(rafId);
};
}, [scrollRef, updateState, itemCount]);
return { canScrollLeft, canScrollRight };
}
function CarouselNavButton({
direction,
visible,
onClick,
}: {
direction: ScrollDirection;
visible: boolean;
onClick: () => void;
}) {
const isLeft = direction === "left";
const Icon = isLeft ? ChevronLeft : ChevronRight;
return (
<Button
type="button"
variant="secondary"
size="icon-sm"
className={cn(
"pointer-events-none scale-90 border-none opacity-0",
"bg-background/60 absolute inset-y-0 z-20 my-auto hidden h-[6cqh] min-h-[50px] rounded-2xl backdrop-blur-lg",
"transition-[opacity,transform] duration-250 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none",
"@md:flex",
isLeft ? "left-1.5" : "right-1.5",
visible &&
"pointer-events-auto scale-100 opacity-100 @md:group-focus-within:pointer-events-auto @md:group-focus-within:scale-100 @md:group-focus-within:opacity-100 @md:group-hover:pointer-events-auto @md:group-hover:scale-100 @md:group-hover:opacity-100",
)}
onClick={onClick}
aria-label={isLeft ? "Scroll left" : "Scroll right"}
tabIndex={visible ? 0 : -1}
aria-hidden={!visible}
>
<Icon className="h-4 w-4" />
</Button>
);
}
interface ItemCarouselHeaderProps {
title?: string;
description?: string;
}
function ItemCarouselHeader({ title, description }: ItemCarouselHeaderProps) {
if (!title && !description) return null;
return (
<div className="px-4 pt-4 pb-1">
{title && (
<h3 className="text-[15px] leading-tight font-semibold tracking-tight">
{title}
</h3>
)}
{description && (
<p className="text-muted-foreground mt-1 text-sm leading-snug">
{description}
</p>
)}
</div>
);
}
interface EmptyStateProps {
id: string;
className?: string;
}
function EmptyState({ id, className }: EmptyStateProps) {
return (
<Card
data-tool-ui-id={id}
data-slot="item-carousel"
className={cn("flex h-48 items-center justify-center", className)}
>
<p className="text-muted-foreground text-sm">No items to display</p>
</Card>
);
}
function ItemCarouselRoot({
id,
title,
description,
items,
className,
onItemClick,
onItemAction,
}: ItemCarouselProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const targetIndexRef = useRef<number | null>(null);
const { scrollTo, isAnimating } = useSmoothScroll();
const { canScrollLeft, canScrollRight } = useScrollEdgeState(
scrollRef,
items.length,
);
const scroll = useCallback(
(direction: ScrollDirection) => {
const container = scrollRef.current;
if (!container) return;
const paddingValue = window.getComputedStyle(container).scrollPaddingLeft;
const scrollPaddingLeft = Number.isFinite(Number.parseFloat(paddingValue))
? Number.parseFloat(paddingValue)
: 0;
const itemElements = Array.from(
container.querySelectorAll<HTMLElement>("[data-carousel-item]"),
);
if (itemElements.length === 0) return;
const snapPositions = itemElements.map((el) =>
Math.max(0, el.offsetLeft - scrollPaddingLeft),
);
const scrollLeft = Math.round(container.scrollLeft);
let currentIndex: number;
if (isAnimating()) {
currentIndex = Math.min(
targetIndexRef.current ?? 0,
snapPositions.length - 1,
);
} else {
currentIndex = snapPositions.length - 1;
for (let i = 0; i < snapPositions.length; i++) {
const snap = snapPositions[i];
if (Math.abs(snap - scrollLeft) < SNAP_EPSILON_PX) {
currentIndex = i;
break;
}
if (snap > scrollLeft) {
currentIndex = Math.max(0, i - 1);
break;
}
}
}
const itemStep =
itemElements.length > 1
? itemElements[1].offsetLeft - itemElements[0].offsetLeft
: 0;
const safeStep =
itemStep > 0 ? itemStep : itemElements[0].offsetWidth || 1;
const pageIndexStep =
container.clientWidth >= PAGE_SCROLL_BREAKPOINT_PX
? Math.max(
1,
Math.floor(
(container.clientWidth * PAGE_SCROLL_RATIO) / safeStep,
),
)
: 1;
const targetIndex =
direction === "right"
? Math.min(currentIndex + pageIndexStep, itemElements.length - 1)
: Math.max(currentIndex - pageIndexStep, 0);
targetIndexRef.current = targetIndex;
const targetScrollLeft = snapPositions[targetIndex];
if (Math.abs(targetScrollLeft - container.scrollLeft) > 1) {
scrollTo(
container,
targetScrollLeft,
SCROLL_ANIMATION_DURATION_MS,
() => {
targetIndexRef.current = null;
},
);
}
},
[scrollTo, isAnimating],
);
const handleScrollLeft = useCallback(() => scroll("left"), [scroll]);
const handleScrollRight = useCallback(() => scroll("right"), [scroll]);
if (items.length === 0) {
return <EmptyState id={id} className={className} />;
}
return (
<div
data-tool-ui-id={id}
data-slot="item-carousel"
className={cn(
"bg-background @container relative isolate w-full gap-0 overflow-hidden rounded-2xl border p-0",
className,
)}
>
<ItemCarouselHeader title={title} description={description} />
<div className="group relative">
<CarouselNavButton
direction="left"
visible={canScrollLeft}
onClick={handleScrollLeft}
/>
<CarouselNavButton
direction="right"
visible={canScrollRight}
onClick={handleScrollRight}
/>
<div
ref={scrollRef}
className={cn(
"grid auto-cols-max grid-flow-col gap-4 overflow-x-auto overscroll-x-contain p-4",
"snap-x snap-mandatory",
)}
role="list"
style={SCROLL_PADDING_STYLE}
>
{items.map((item) => (
<div
key={item.id}
data-carousel-item
data-item-id={item.id}
role="listitem"
className="flex snap-start snap-always"
>
<ItemCard
item={item}
onItemClick={onItemClick}
onItemAction={onItemAction}
/>
</div>
))}
</div>
</div>
</div>
);
}
type ItemCarouselComponent = typeof ItemCarouselRoot & {
Root: typeof ItemCarouselRoot;
Header: typeof ItemCarouselHeader;
EmptyState: typeof EmptyState;
NavButton: typeof CarouselNavButton;
Card: typeof ItemCard;
};
export const ItemCarousel = Object.assign(ItemCarouselRoot, {
Root: ItemCarouselRoot,
Header: ItemCarouselHeader,
EmptyState,
NavButton: CarouselNavButton,
Card: ItemCard,
}) as ItemCarouselComponent;
@@ -0,0 +1,77 @@
import { z } from "zod";
import { defineToolUiContract } from "../shared/contract";
import {
ActionSchema,
SerializableActionSchema,
ToolUIIdSchema,
} from "../shared/schema";
export const ItemSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
subtitle: z.string().optional(),
image: z.url().optional(),
color: z.string().optional(),
actions: z.array(ActionSchema).optional(),
});
export const ItemCarouselPropsSchema = z.object({
id: ToolUIIdSchema,
title: z.string().optional(),
description: z.string().optional(),
items: z.array(ItemSchema),
className: z.string().optional(),
});
export type Item = z.infer<typeof ItemSchema>;
export type ItemCarouselProps = z.infer<typeof ItemCarouselPropsSchema> & {
onItemClick?: (itemId: string) => void;
onItemAction?: (itemId: string, actionId: string) => void;
};
export const SerializableItemSchema = ItemSchema.extend({
actions: z.array(SerializableActionSchema).optional(),
});
export const SerializableItemCarouselSchema = ItemCarouselPropsSchema.omit({
className: true,
})
.extend({
items: z.array(SerializableItemSchema),
})
.superRefine((payload, ctx) => {
const seenItemIds = new Map<string, number>();
payload.items.forEach((item, index) => {
const firstSeenAt = seenItemIds.get(item.id);
if (firstSeenAt !== undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["items", index, "id"],
message: `duplicate item id '${item.id}' (first seen at index ${firstSeenAt})`,
});
return;
}
seenItemIds.set(item.id, index);
});
});
export type SerializableItem = z.infer<typeof SerializableItemSchema>;
export type SerializableItemCarousel = z.infer<
typeof SerializableItemCarouselSchema
>;
const SerializableItemCarouselSchemaContract = defineToolUiContract(
"ItemCarousel",
SerializableItemCarouselSchema,
);
export const parseSerializableItemCarousel: (
input: unknown,
) => SerializableItemCarousel = SerializableItemCarouselSchemaContract.parse;
export const safeParseSerializableItemCarousel: (
input: unknown,
) => SerializableItemCarousel | null =
SerializableItemCarouselSchemaContract.safeParse;
@@ -0,0 +1,19 @@
# Link Preview
Implementation for the "link-preview" Tool UI surface.
## Files
- public exports: components/tool-ui/link-preview/index.ts
- serializable schema + parse helpers: components/tool-ui/link-preview/schema.ts
## Companion assets
- Docs page: app/docs/link-preview/content.mdx
- Preset payload: lib/presets/link-preview.ts
## Quick check
Run this after edits:
pnpm test
@@ -0,0 +1,6 @@
/**
* Adapter: UI and utility re-exports for copy-standalone portability.
*/
"use client";
export { cn } from "@toolui/lib/utils";
@@ -0,0 +1,3 @@
export { LinkPreview } from "./link-preview";
export type { LinkPreviewProps } from "./link-preview";
export type { SerializableLinkPreview } from "./schema";
@@ -0,0 +1,141 @@
"use client";
import { Globe } from "lucide-react";
import { cn } from "./_adapter";
import {
RATIO_CLASS_MAP,
getFitClass,
openSafeNavigationHref,
sanitizeHref,
} from "../shared/media";
import type { SerializableLinkPreview } from "./schema";
const FALLBACK_LOCALE = "en-US";
const CONTENT_SPACING = "px-5 py-4 gap-2";
export interface LinkPreviewProps extends SerializableLinkPreview {
className?: string;
onNavigate?: (href: string, preview: SerializableLinkPreview) => void;
}
export function LinkPreview(props: LinkPreviewProps) {
const { className, onNavigate, ...serializable } = props;
const {
id,
href: rawHref,
title,
description,
image,
domain,
favicon,
ratio = "16:9",
fit = "cover",
locale: providedLocale,
} = serializable;
const locale = providedLocale ?? FALLBACK_LOCALE;
const sanitizedHref = sanitizeHref(rawHref);
const previewData: SerializableLinkPreview = {
...serializable,
href: sanitizedHref ?? rawHref,
locale,
};
const handleClick = () => {
if (!sanitizedHref) return;
if (onNavigate) {
onNavigate(sanitizedHref, previewData);
} else {
openSafeNavigationHref(sanitizedHref);
}
};
return (
<article
className={cn("relative w-full max-w-md min-w-80", className)}
lang={locale}
data-tool-ui-id={id}
data-slot="link-preview"
>
<div
className={cn(
"group @container relative isolate flex w-full min-w-0 flex-col overflow-hidden rounded-xl",
"border-border bg-card border text-sm shadow-xs",
sanitizedHref && "cursor-pointer",
)}
onClick={sanitizedHref ? handleClick : undefined}
role={sanitizedHref ? "link" : undefined}
tabIndex={sanitizedHref ? 0 : undefined}
onKeyDown={
sanitizedHref
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleClick();
}
}
: undefined
}
>
<div className="flex flex-col">
{image && (
<div
className={cn(
"bg-muted relative w-full overflow-hidden",
ratio !== "auto" ? RATIO_CLASS_MAP[ratio] : "aspect-[5/3]",
)}
>
<img
src={image}
alt=""
loading="lazy"
decoding="async"
className={cn(
"absolute inset-0 h-full w-full",
getFitClass(fit),
"object-center transition-transform duration-200 group-hover:scale-[1.01]",
)}
/>
</div>
)}
<div className={cn("flex flex-col", CONTENT_SPACING)}>
{domain && (
<div className="text-muted-foreground flex items-center gap-2 text-xs">
{favicon ? (
<img
src={favicon}
alt=""
aria-hidden="true"
width={16}
height={16}
className="size-4 rounded-full object-cover"
loading="lazy"
decoding="async"
/>
) : (
<div className="border-border/60 bg-muted flex size-4 shrink-0 items-center justify-center rounded-full border">
<Globe className="h-2.5 w-2.5" aria-hidden="true" />
</div>
)}
<span>{domain}</span>
</div>
)}
{title && (
<h3 className="text-foreground text-base font-medium text-pretty">
<span className="line-clamp-2">{title}</span>
</h3>
)}
{description && (
<p className="text-muted-foreground leading-snug text-pretty">
<span className="line-clamp-2">{description}</span>
</p>
)}
</div>
</div>
</div>
</article>
);
}
@@ -0,0 +1,43 @@
import { z } from "zod";
import { defineToolUiContract } from "../shared/contract";
import {
ToolUIIdSchema,
ToolUIReceiptSchema,
ToolUIRoleSchema,
} from "../shared/schema";
import { AspectRatioSchema, MediaFitSchema } from "../shared/media";
export const SerializableLinkPreviewSchema = z.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
receipt: ToolUIReceiptSchema.optional(),
href: z.url(),
title: z.string().optional(),
description: z.string().optional(),
image: z.url().optional(),
domain: z.string().optional(),
favicon: z.url().optional(),
ratio: AspectRatioSchema.optional(),
fit: MediaFitSchema.optional(),
createdAt: z.string().datetime().optional(),
locale: z.string().optional(),
});
export type SerializableLinkPreview = z.infer<
typeof SerializableLinkPreviewSchema
>;
const SerializableLinkPreviewSchemaContract = defineToolUiContract(
"LinkPreview",
SerializableLinkPreviewSchema,
);
export const parseSerializableLinkPreview: (
input: unknown,
) => SerializableLinkPreview = SerializableLinkPreviewSchemaContract.parse;
export const safeParseSerializableLinkPreview: (
input: unknown,
) => SerializableLinkPreview | null =
SerializableLinkPreviewSchemaContract.safeParse;
@@ -0,0 +1,19 @@
# Linkedin Post
Implementation for the "linkedin-post" Tool UI surface.
## Files
- public exports: components/tool-ui/linkedin-post/index.ts
- serializable schema + parse helpers: components/tool-ui/linkedin-post/schema.ts
## Companion assets
- Docs page: app/docs/social-post/content.mdx
- Preset payload: lib/presets/linkedin-post.ts
## Quick check
Run this after edits:
pnpm test
@@ -0,0 +1,19 @@
/**
* 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., "@toolui/lib/utils", "~/lib/cn")
* Button → shadcn/ui Button
* Tooltip → shadcn/ui Tooltip
*/
export { cn } from "@toolui/lib/utils";
export { Button } from "@toolui/ui/button";
export {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@toolui/ui/tooltip";
@@ -0,0 +1,9 @@
export { LinkedInPost } from "./linkedin-post";
export type { LinkedInPostProps } from "./linkedin-post";
export type {
LinkedInPostData,
LinkedInPostAuthor,
LinkedInPostMedia,
LinkedInPostLinkPreview,
LinkedInPostStats,
} from "./schema";
@@ -0,0 +1,283 @@
"use client";
import * as React from "react";
import { ThumbsUp, Share } from "lucide-react";
import {
cn,
Button,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "./_adapter";
import { formatCount, formatRelativeTime, getDomain } from "../shared/utils";
import { resolveSafeNavigationHref } from "../shared/media";
import type {
LinkedInPostData,
LinkedInPostMedia,
LinkedInPostLinkPreview,
} from "./schema";
const TEXT_PREVIEW_LENGTH = 280;
export interface LinkedInPostProps {
post: LinkedInPostData;
className?: string;
onAction?: (action: string, post: LinkedInPostData) => void;
}
function LinkedInLogo({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 72 72"
className={className}
role="img"
aria-label="LinkedIn logo"
>
<g fill="none" fillRule="evenodd">
<path
d="M8 72h56c4.42 0 8-3.58 8-8V8c0-4.42-3.58-8-8-8H8C3.58 0 0 3.58 0 8v56c0 4.42 3.58 8 8 8z"
fill="currentColor"
/>
<path
d="M62 62H51.3V43.8c0-4.98-1.9-7.78-5.83-7.78-4.3 0-6.54 2.9-6.54 7.78V62H28.63V27.33h10.3v4.67c0 0 3.1-5.73 10.45-5.73 7.36 0 12.62 4.5 12.62 13.8V62zM16.35 22.8c-3.5 0-6.35-2.86-6.35-6.4 0-3.52 2.85-6.4 6.35-6.4 3.5 0 6.35 2.88 6.35 6.4 0 3.54-2.85 6.4-6.35 6.4zM11.03 62h10.74V27.33H11.03V62z"
fill="#FFF"
/>
</g>
</svg>
);
}
function Header({
author,
createdAt,
}: {
author: LinkedInPostData["author"];
createdAt?: string;
}) {
return (
<header className="flex items-start gap-3">
<img
src={author.avatarUrl}
alt={`${author.name} avatar`}
width={48}
height={48}
className="size-12 rounded-full object-cover"
/>
<div className="flex min-w-0 flex-1 flex-col leading-tight">
<span className="text-sm font-semibold">{author.name}</span>
{author.headline && (
<span className="text-muted-foreground line-clamp-1 text-xs">
{author.headline}
</span>
)}
{createdAt && (
<div className="text-muted-foreground mt-0.5 flex items-center gap-1 text-xs">
<span>{formatRelativeTime(createdAt)}</span>
<span>·</span>
<span>Edited</span>
</div>
)}
</div>
<LinkedInLogo className="size-5 text-[#0077b5]" />
</header>
);
}
function PostBody({ text }: { text?: string }) {
const [isExpanded, setIsExpanded] = React.useState(false);
const shouldTruncate = text && text.length > TEXT_PREVIEW_LENGTH;
if (!text) return null;
return (
<div className="text-sm leading-relaxed text-pretty wrap-break-word whitespace-pre-wrap">
{shouldTruncate && !isExpanded ? (
<>
{text.slice(0, TEXT_PREVIEW_LENGTH)}
...
<button
onClick={() => setIsExpanded(true)}
className="text-muted-foreground hover:text-foreground ml-1 font-medium hover:underline"
>
see more
</button>
</>
) : (
text
)}
</div>
);
}
function PostMedia({ media }: { media: LinkedInPostMedia }) {
return (
<div className="overflow-hidden rounded-lg">
{media.type === "image" ? (
<img
src={media.url}
alt={media.alt}
className="w-full object-cover"
style={{ aspectRatio: "16/9" }}
loading="lazy"
/>
) : (
<video
src={media.url}
controls
playsInline
className="w-full object-contain"
style={{ aspectRatio: "16/9" }}
/>
)}
</div>
);
}
function PostLinkPreview({ preview }: { preview: LinkedInPostLinkPreview }) {
const href = resolveSafeNavigationHref(preview.url);
const domain = preview.domain ?? getDomain(preview.url);
const content = (
<>
{preview.imageUrl && (
<img
src={preview.imageUrl}
alt=""
className="h-40 w-full object-cover"
loading="lazy"
/>
)}
<div className="p-3">
{preview.title && (
<div className="line-clamp-2 font-medium text-pretty">
{preview.title}
</div>
)}
{domain && (
<div className="text-muted-foreground mt-1 text-xs">{domain}</div>
)}
</div>
</>
);
if (!href) {
return (
<div className="block overflow-hidden rounded-lg border">{content}</div>
);
}
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="hover:bg-muted/50 block overflow-hidden rounded-lg border transition-colors"
>
{content}
</a>
);
}
function ActionButton({
icon: Icon,
label,
count,
active,
hoverColor,
activeColor,
onClick,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
count?: number;
active?: boolean;
hoverColor: string;
activeColor?: string;
onClick: () => void;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onClick();
}}
className={cn(
"h-auto gap-1.5 px-3 py-2",
hoverColor,
active && activeColor,
)}
aria-label={label}
>
<Icon className="size-4" />
<span className="text-xs font-medium">{label}</span>
{count !== undefined && (
<span className="text-muted-foreground text-xs">
({formatCount(count)})
</span>
)}
</Button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
function PostActions({
stats,
onAction,
}: {
stats?: LinkedInPostData["stats"];
onAction: (action: string) => void;
}) {
return (
<TooltipProvider delayDuration={300}>
<div className="mt-1 flex items-center gap-1 border-t pt-1.5">
<ActionButton
icon={ThumbsUp}
label="Like"
active={stats?.isLiked}
hoverColor="hover:bg-muted"
activeColor="text-blue-600 fill-blue-600"
onClick={() => onAction("like")}
/>
<ActionButton
icon={Share}
label="Share"
hoverColor="hover:bg-muted"
onClick={() => onAction("share")}
/>
</div>
</TooltipProvider>
);
}
export function LinkedInPost({ post, className, onAction }: LinkedInPostProps) {
return (
<div
className={cn("flex max-w-xl flex-col gap-3", className)}
data-tool-ui-id={post.id}
data-slot="linkedin-post"
>
<article className="bg-card flex flex-col gap-3 rounded-lg border p-3 shadow-sm">
<Header author={post.author} createdAt={post.createdAt} />
<PostBody text={post.text} />
{post.media && <PostMedia media={post.media} />}
{post.linkPreview && !post.media && (
<PostLinkPreview preview={post.linkPreview} />
)}
<PostActions
stats={post.stats}
onAction={(action) => onAction?.(action, post)}
/>
</article>
</div>
);
}
@@ -0,0 +1,59 @@
import { z } from "zod";
import { defineToolUiContract } from "../shared/contract";
export const LinkedInPostAuthorSchema = z.object({
name: z.string(),
avatarUrl: z.string(),
headline: z.string().optional(),
});
export const LinkedInPostMediaSchema = z.object({
type: z.enum(["image", "video"]),
url: z.string(),
alt: z.string(),
});
export const LinkedInPostLinkPreviewSchema = z.object({
url: z.string(),
title: z.string().optional(),
description: z.string().optional(),
imageUrl: z.string().optional(),
domain: z.string().optional(),
});
export const LinkedInPostStatsSchema = z.object({
likes: z.number().optional(),
isLiked: z.boolean().optional(),
});
export const SerializableLinkedInPostSchema = z.object({
id: z.string(),
author: LinkedInPostAuthorSchema,
text: z.string().optional(),
media: LinkedInPostMediaSchema.optional(),
linkPreview: LinkedInPostLinkPreviewSchema.optional(),
stats: LinkedInPostStatsSchema.optional(),
createdAt: z.string().optional(),
});
export type LinkedInPostData = z.infer<typeof SerializableLinkedInPostSchema>;
export type LinkedInPostAuthor = z.infer<typeof LinkedInPostAuthorSchema>;
export type LinkedInPostMedia = z.infer<typeof LinkedInPostMediaSchema>;
export type LinkedInPostLinkPreview = z.infer<
typeof LinkedInPostLinkPreviewSchema
>;
export type LinkedInPostStats = z.infer<typeof LinkedInPostStatsSchema>;
const SerializableLinkedInPostSchemaContract = defineToolUiContract(
"LinkedInPost",
SerializableLinkedInPostSchema,
);
export const parseSerializableLinkedInPost: (
input: unknown,
) => LinkedInPostData = SerializableLinkedInPostSchemaContract.parse;
export const safeParseSerializableLinkedInPost: (
input: unknown,
) => LinkedInPostData | null = SerializableLinkedInPostSchemaContract.safeParse;
@@ -0,0 +1,19 @@
# Message Draft
Implementation for the "message-draft" Tool UI surface.
## Files
- public exports: components/tool-ui/message-draft/index.tsx
- serializable schema + parse helpers: components/tool-ui/message-draft/schema.ts
## Companion assets
- Docs page: app/docs/message-draft/content.mdx
- Preset payload: lib/presets/message-draft.ts
## Quick check
Run this after edits:
pnpm test
@@ -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., "@toolui/lib/utils", "~/lib/cn")
* Button → shadcn/ui Button
*/
export { cn } from "@toolui/lib/utils";
export { Button } from "@toolui/ui/button";
@@ -0,0 +1,10 @@
export { MessageDraft } from "./message-draft";
export {
type SerializableMessageDraft,
type SerializableEmailDraft,
type SerializableSlackDraft,
type MessageDraftChannel,
type MessageDraftOutcome,
type SlackTarget,
type MessageDraftProps,
} from "./schema";
@@ -0,0 +1,511 @@
"use client";
import * as React from "react";
import { cn, Button } from "./_adapter";
import type {
MessageDraftProps,
SerializableEmailDraft,
SerializableSlackDraft,
} from "./schema";
import { ActionButtons } from "../shared/action-buttons";
import type { Action } from "../shared/schema";
import { Check, ChevronDown } from "lucide-react";
type DraftState = "review" | "sending" | "sent" | "cancelled";
type DraftOutcome = MessageDraftProps["outcome"];
const DEFAULT_GRACE_PERIOD = 5000;
const COLLAPSED_BODY_HEIGHT = 280;
interface RecipientRowProps {
label: string;
recipients: string[];
maxVisible?: number;
muted?: boolean;
}
function RecipientRow({
label,
recipients,
maxVisible = 3,
muted = false,
}: RecipientRowProps) {
const visibleRecipients = recipients.slice(0, maxVisible);
const overflowCount = recipients.length - maxVisible;
return (
<tr className="text-sm">
<td className="text-muted-foreground w-0 pr-4 pb-1 text-right align-top font-medium whitespace-nowrap">
{label}
</td>
<td className={cn("pb-1 align-top", muted && "text-muted-foreground")}>
{visibleRecipients.join(", ")}
{overflowCount > 0 && (
<span className="text-muted-foreground"> +{overflowCount} more</span>
)}
</td>
</tr>
);
}
interface SingleFieldRowProps {
label: string;
value: string;
}
function SingleFieldRow({ label, value }: SingleFieldRowProps) {
return (
<tr className="text-sm">
<td className="text-muted-foreground w-0 pr-4 pb-1 text-right align-top font-medium whitespace-nowrap">
{label}
</td>
<td className="pb-1 align-top">{value}</td>
</tr>
);
}
interface ExpandableBodyProps {
body: string;
isExpanded: boolean;
onNeedsExpansionChange?: (needsExpansion: boolean) => void;
}
function ExpandableBody({
body,
isExpanded,
onNeedsExpansionChange,
}: ExpandableBodyProps) {
const [needsExpansion, setNeedsExpansion] = React.useState<boolean | null>(
null,
);
const contentRef = React.useRef<HTMLDivElement>(null);
React.useLayoutEffect(() => {
if (contentRef.current) {
const needs = contentRef.current.scrollHeight > COLLAPSED_BODY_HEIGHT;
setNeedsExpansion(needs);
onNeedsExpansionChange?.(needs);
}
}, [body, onNeedsExpansionChange]);
return (
<div className="relative">
<div
ref={contentRef}
className={cn(
"overflow-hidden text-sm leading-relaxed",
needsExpansion !== null &&
"transition-[max-height] duration-300 ease-in-out",
)}
style={{
maxHeight:
needsExpansion === null
? `${COLLAPSED_BODY_HEIGHT}px`
: isExpanded || !needsExpansion
? `${contentRef.current?.scrollHeight ?? 1000}px`
: `${COLLAPSED_BODY_HEIGHT}px`,
}}
>
<p className="pt-1 whitespace-pre-wrap">{body}</p>
</div>
{needsExpansion && (
<div
className={cn(
"from-card pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t to-transparent transition-[height] duration-300 ease-in-out",
isExpanded ? "h-0" : "h-12",
)}
/>
)}
</div>
);
}
interface EmailDraftContentProps {
draft: SerializableEmailDraft;
titleId: string;
isExpanded: boolean;
onNeedsExpansionChange?: (needsExpansion: boolean) => void;
}
function EmailDraftContent({
draft,
titleId,
isExpanded,
onNeedsExpansionChange,
}: EmailDraftContentProps) {
return (
<>
<h2 id={titleId} className="pt-2 text-base leading-tight font-semibold">
{draft.subject}
</h2>
<table className="w-full">
<tbody>
{draft.from && <SingleFieldRow label="From" value={draft.from} />}
<RecipientRow label="To" recipients={draft.to} />
{draft.cc && draft.cc.length > 0 && (
<RecipientRow label="Cc" recipients={draft.cc} />
)}
{draft.bcc && draft.bcc.length > 0 && (
<RecipientRow label="Bcc" recipients={draft.bcc} muted />
)}
</tbody>
</table>
<div className="bg-border -mx-5 h-px" role="separator" />
<ExpandableBody
body={draft.body}
isExpanded={isExpanded}
onNeedsExpansionChange={onNeedsExpansionChange}
/>
</>
);
}
interface SlackDraftContentProps {
draft: SerializableSlackDraft;
titleId: string;
isExpanded: boolean;
onNeedsExpansionChange?: (needsExpansion: boolean) => void;
}
function SlackLogo({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden="true">
<path
fill="#E01E5A"
d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313z"
/>
<path
fill="#36C5F0"
d="M8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312z"
/>
<path
fill="#2EB67D"
d="M18.958 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.52 2.521h-2.522V8.834zm-1.271 0a2.528 2.528 0 0 1-2.521 2.521 2.528 2.528 0 0 1-2.521-2.521V2.522A2.528 2.528 0 0 1 15.165 0a2.528 2.528 0 0 1 2.522 2.522v6.312z"
/>
<path
fill="#ECB22E"
d="M15.165 18.958a2.528 2.528 0 0 1 2.522 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.521-2.52v-2.522h2.521zm0-1.271a2.527 2.527 0 0 1-2.521-2.521 2.526 2.526 0 0 1 2.521-2.521h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.521h-6.313z"
/>
</svg>
);
}
function SlackDraftContent({
draft,
titleId,
isExpanded,
onNeedsExpansionChange,
}: SlackDraftContentProps) {
const { target } = draft;
const isChannel = target.type === "channel";
const targetDisplay = isChannel
? `#${target.name}`
: `Message to @${target.name}`;
const memberCount = isChannel ? target.memberCount : undefined;
return (
<>
<div
id={titleId}
className="flex items-center gap-1.5 text-sm font-medium"
>
<SlackLogo className="size-4" />
<span>{targetDisplay}</span>
{memberCount !== undefined && (
<span className="text-muted-foreground ml-auto text-sm font-normal">
{memberCount.toLocaleString()} members
</span>
)}
</div>
<div className="bg-border -mx-5 h-px" role="separator" />
<ExpandableBody
body={draft.body}
isExpanded={isExpanded}
onNeedsExpansionChange={onNeedsExpansionChange}
/>
</>
);
}
function formatSentTime(date: Date): string {
return date.toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
});
}
export function resolveStateFromOutcome(outcome: DraftOutcome): DraftState {
if (outcome === "sent") return "sent";
if (outcome === "cancelled") return "cancelled";
return "review";
}
export function resolveOutcomeTransition(
previousOutcome: DraftOutcome,
nextOutcome: DraftOutcome,
): DraftState | null {
if (previousOutcome === nextOutcome) {
return null;
}
return resolveStateFromOutcome(nextOutcome);
}
interface SentConfirmationProps {
sentAt: Date;
}
function SentConfirmation({ sentAt }: SentConfirmationProps) {
return (
<div
className="flex items-center justify-end gap-2 text-sm"
role="status"
aria-label="Message sent"
>
<span className="text-muted-foreground">
Sent at {formatSentTime(sentAt)}
</span>
<span className="bg-primary/10 text-primary flex size-6 shrink-0 items-center justify-center rounded-full">
<Check className="size-3.5" />
</span>
</div>
);
}
export function MessageDraft(props: MessageDraftProps) {
const {
id,
className,
outcome,
undoGracePeriod = DEFAULT_GRACE_PERIOD,
onSend,
onUndo,
onCancel,
} = props;
const [state, setState] = React.useState<DraftState>(() =>
resolveStateFromOutcome(outcome),
);
const [countdown, setCountdown] = React.useState(
Math.ceil(undoGracePeriod / 1000),
);
const [sentAt, setSentAt] = React.useState<Date | null>(() =>
outcome === "sent" ? new Date() : null,
);
const [isExpanded, setIsExpanded] = React.useState(false);
const [needsExpansion, setNeedsExpansion] = React.useState(false);
const undoButtonRef = React.useRef<HTMLButtonElement>(null);
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const countdownRef = React.useRef<ReturnType<typeof setInterval> | null>(
null,
);
const previousOutcomeRef = React.useRef<DraftOutcome>(outcome);
const clearTimers = React.useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
if (countdownRef.current) {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
}, []);
React.useEffect(() => {
return clearTimers;
}, [clearTimers]);
React.useEffect(() => {
const nextState = resolveOutcomeTransition(
previousOutcomeRef.current,
outcome,
);
previousOutcomeRef.current = outcome;
if (nextState === null) {
return;
}
clearTimers();
setState(nextState);
setCountdown(Math.ceil(undoGracePeriod / 1000));
setSentAt(nextState === "sent" ? new Date() : null);
}, [outcome, undoGracePeriod, clearTimers]);
React.useEffect(() => {
if (state === "sending") {
undoButtonRef.current?.focus();
setCountdown(Math.ceil(undoGracePeriod / 1000));
countdownRef.current = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
if (countdownRef.current) {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
return 0;
}
return prev - 1;
});
}, 1000);
timerRef.current = setTimeout(async () => {
clearTimers();
await onSend?.();
setSentAt(new Date());
setState("sent");
}, undoGracePeriod);
}
}, [state, undoGracePeriod, onSend, clearTimers]);
const handleSend = React.useCallback(() => {
setState("sending");
}, []);
const handleUndo = React.useCallback(() => {
clearTimers();
setState("review");
onUndo?.();
}, [clearTimers, onUndo]);
const handleCancel = React.useCallback(() => {
clearTimers();
setState("cancelled");
onCancel?.();
}, [clearTimers, onCancel]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent) => {
if (event.key === "Escape" && state === "review") {
event.preventDefault();
handleCancel();
}
},
[state, handleCancel],
);
const handleNeedsExpansionChange = React.useCallback((needs: boolean) => {
setNeedsExpansion(needs);
}, []);
const handleToggleExpand = React.useCallback(() => {
setIsExpanded((prev) => !prev);
}, []);
const handleAction = React.useCallback(
async (actionId: string) => {
if (actionId === "send") {
handleSend();
} else if (actionId === "cancel") {
handleCancel();
}
},
[handleSend, handleCancel],
);
const actions: Action[] = [
{
id: "cancel",
label: "Cancel",
variant: "ghost",
},
{
id: "send",
label: "Send",
variant: "default",
},
];
const expandButton = needsExpansion ? (
<Button
variant="ghost"
size="sm"
onClick={handleToggleExpand}
className="h-7 gap-1 px-2 text-sm"
>
{isExpanded ? "Show less" : "Read more"}
<ChevronDown className={cn("size-3", isExpanded && "rotate-180")} />
</Button>
) : null;
const renderActions = () => {
switch (state) {
case "sending":
return (
<div
className="flex items-center justify-end gap-3"
aria-live="polite"
>
<span className="text-muted-foreground text-sm">
Sending in {countdown}s
</span>
<Button
ref={undoButtonRef}
variant="outline"
size="sm"
onClick={handleUndo}
className="rounded-full"
>
Undo
</Button>
</div>
);
case "sent":
return <SentConfirmation sentAt={sentAt ?? new Date()} />;
case "cancelled":
return null;
default:
return <ActionButtons actions={actions} onAction={handleAction} />;
}
};
if (state === "cancelled") {
return null;
}
return (
<article
className={cn(
"flex w-full max-w-lg min-w-64 flex-col gap-3",
"text-foreground",
className,
)}
data-slot="message-draft"
data-tool-ui-id={id}
data-state={state}
aria-labelledby={`${id}-title`}
onKeyDown={handleKeyDown}
>
<div className="bg-card flex w-full flex-col gap-3 rounded-2xl border px-5 pt-3 pb-5 shadow-xs transition-none">
{props.channel === "email" ? (
<EmailDraftContent
draft={props}
titleId={`${id}-title`}
isExpanded={isExpanded}
onNeedsExpansionChange={handleNeedsExpansionChange}
/>
) : (
<SlackDraftContent
draft={props}
titleId={`${id}-title`}
isExpanded={isExpanded}
onNeedsExpansionChange={handleNeedsExpansionChange}
/>
)}
{expandButton}
</div>
<div className="@container/actions">{renderActions()}</div>
</article>
);
}
@@ -0,0 +1,83 @@
import { z } from "zod";
import { ToolUIIdSchema, ToolUIRoleSchema } from "../shared/schema";
import { defineToolUiContract } from "../shared/contract";
export const MessageDraftChannelSchema = z.enum(["email", "slack"]);
export type MessageDraftChannel = z.infer<typeof MessageDraftChannelSchema>;
export const MessageDraftOutcomeSchema = z.enum(["sent", "cancelled"]);
export type MessageDraftOutcome = z.infer<typeof MessageDraftOutcomeSchema>;
const SlackTargetSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("channel"),
name: z.string().min(1),
memberCount: z.number().optional(),
}),
z.object({ type: z.literal("dm"), name: z.string().min(1) }),
]);
export type SlackTarget = z.infer<typeof SlackTargetSchema>;
export const SerializableEmailDraftSchema = z.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
body: z.string().min(1),
outcome: MessageDraftOutcomeSchema.optional(),
channel: z.literal("email"),
subject: z.string().min(1),
from: z.string().optional(),
to: z.array(z.string()).min(1),
cc: z.array(z.string()).optional(),
bcc: z.array(z.string()).optional(),
});
export const SerializableSlackDraftSchema = z.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
body: z.string().min(1),
outcome: MessageDraftOutcomeSchema.optional(),
channel: z.literal("slack"),
target: SlackTargetSchema,
});
export const SerializableMessageDraftSchema = z.discriminatedUnion("channel", [
SerializableEmailDraftSchema,
SerializableSlackDraftSchema,
]);
export type SerializableMessageDraft = z.infer<
typeof SerializableMessageDraftSchema
>;
export type SerializableEmailDraft = z.infer<
typeof SerializableEmailDraftSchema
>;
export type SerializableSlackDraft = z.infer<
typeof SerializableSlackDraftSchema
>;
const SerializableMessageDraftSchemaContract = defineToolUiContract(
"MessageDraft",
SerializableMessageDraftSchema,
);
export const parseSerializableMessageDraft: (
input: unknown,
) => SerializableMessageDraft = SerializableMessageDraftSchemaContract.parse;
export const safeParseSerializableMessageDraft: (
input: unknown,
) => SerializableMessageDraft | null =
SerializableMessageDraftSchemaContract.safeParse;
export type MessageDraftProps = SerializableMessageDraft & {
className?: string;
undoGracePeriod?: number;
onSend?: () => void | Promise<void>;
onUndo?: () => void;
onCancel?: () => void;
};
@@ -0,0 +1,19 @@
# Option List
Implementation for the "option-list" Tool UI surface.
## Files
- public exports: components/tool-ui/option-list/index.tsx
- serializable schema + parse helpers: components/tool-ui/option-list/schema.ts
## Companion assets
- Docs page: app/docs/option-list/content.mdx
- Preset payload: lib/presets/option-list.ts
## Quick check
Run this after edits:
pnpm test
@@ -0,0 +1,14 @@
/**
* 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., "@toolui/lib/utils", "~/lib/cn")
* Button → shadcn/ui Button
* Separator → shadcn/ui Separator
*/
export { cn } from "@toolui/lib/utils";
export { Button } from "@toolui/ui/button";
export { Separator } from "@toolui/ui/separator";
@@ -0,0 +1,7 @@
export { OptionList } from "./option-list";
export type {
OptionListProps,
OptionListOption,
OptionListSelection,
SerializableOptionList,
} from "./schema";
@@ -0,0 +1,625 @@
"use client";
import {
useMemo,
useState,
useCallback,
useEffect,
useRef,
Fragment,
} from "react";
import type { KeyboardEvent } from "react";
import type {
OptionListProps,
OptionListSelection,
OptionListOption,
} from "./schema";
import {
normalizeSelectionForOptions,
parseSelectionToIdSet,
} from "./selection";
import { ActionButtons } from "../shared/action-buttons";
import { normalizeActionsConfig } from "../shared/actions-config";
import type { Action } from "../shared/schema";
import { cn, Button, Separator } from "./_adapter";
import { Check } from "lucide-react";
function convertIdSetToSelection(
selected: Set<string>,
mode: "multi" | "single",
): OptionListSelection {
if (mode === "single") {
const [first] = selected;
return first ?? null;
}
return Array.from(selected);
}
function areSetsEqual(a: Set<string>, b: Set<string>) {
if (a.size !== b.size) return false;
for (const val of a) {
if (!b.has(val)) return false;
}
return true;
}
interface SelectionIndicatorProps {
mode: "multi" | "single";
isSelected: boolean;
disabled?: boolean;
}
function SelectionIndicator({
mode,
isSelected,
disabled,
}: SelectionIndicatorProps) {
const shape = mode === "single" ? "rounded-full" : "rounded";
return (
<div
className={cn(
"flex size-4 shrink-0 items-center justify-center border-2 transition-colors",
shape,
isSelected && "border-primary bg-primary text-primary-foreground",
!isSelected && "border-muted-foreground/50",
disabled && "opacity-50",
)}
>
{mode === "multi" && isSelected && <Check className="size-3" />}
{mode === "single" && isSelected && (
<span className="size-2 rounded-full bg-current" />
)}
</div>
);
}
interface OptionItemProps {
option: OptionListOption;
isSelected: boolean;
isDisabled: boolean;
selectionMode: "multi" | "single";
isFirst: boolean;
isLast: boolean;
onToggle: () => void;
tabIndex?: number;
onFocus?: () => void;
buttonRef?: (el: HTMLButtonElement | null) => void;
}
function OptionItem({
option,
isSelected,
isDisabled,
selectionMode,
isFirst,
isLast,
onToggle,
tabIndex,
onFocus,
buttonRef,
}: OptionItemProps) {
const hasAdjacentOptions = !isFirst && !isLast;
return (
<Button
ref={buttonRef}
data-id={option.id}
variant="ghost"
size="lg"
role="option"
aria-selected={isSelected}
onClick={onToggle}
onFocus={onFocus}
tabIndex={tabIndex}
disabled={isDisabled}
className={cn(
"peer group relative h-auto min-h-[50px] w-full justify-start text-left text-sm font-medium",
"rounded-none border-0 bg-transparent px-0 py-2 text-base shadow-none transition-none hover:bg-transparent! @md/option-list:text-sm",
isFirst && "pb-2.5",
hasAdjacentOptions && "py-2.5",
)}
>
<span
className={cn(
"bg-primary/5 absolute inset-0 -mx-3 -my-0.5 rounded-xl opacity-0 transition-opacity group-hover:opacity-100",
)}
/>
<div className="relative flex items-start gap-3">
<span className="flex h-6 items-center">
<SelectionIndicator
mode={selectionMode}
isSelected={isSelected}
disabled={option.disabled}
/>
</span>
{option.icon && (
<span className="flex h-6 items-center">{option.icon}</span>
)}
<div className="flex flex-col text-left">
<span className="leading-6 text-pretty">{option.label}</span>
{option.description && (
<span className="text-muted-foreground text-sm font-normal text-pretty">
{option.description}
</span>
)}
</div>
</div>
</Button>
);
}
interface OptionListConfirmationProps {
id: string;
options: OptionListOption[];
selectedIds: Set<string>;
className?: string;
}
function OptionListConfirmation({
id,
options,
selectedIds,
className,
}: OptionListConfirmationProps) {
const confirmedOptions = options.filter((opt) => selectedIds.has(opt.id));
return (
<div
className={cn(
"@container/option-list flex w-full max-w-md min-w-80 flex-col",
"text-foreground",
"motion-safe:animate-in motion-safe:fade-in motion-safe:blur-in-sm motion-safe:zoom-in-95 motion-safe:duration-300 motion-safe:ease-[cubic-bezier(0.16,1,0.3,1)] motion-safe:fill-mode-both",
className,
)}
data-slot="option-list"
data-tool-ui-id={id}
data-receipt="true"
role="status"
aria-label="Confirmed selection"
>
<div
className={cn(
"bg-card/60 flex w-full flex-col overflow-hidden rounded-2xl border px-5 py-2.5 shadow-xs",
)}
>
{confirmedOptions.map((option, index) => (
<Fragment key={option.id}>
{index > 0 && (
<Separator className="my-1.5" orientation="horizontal" />
)}
<div className="flex items-start gap-3 py-1">
<span className="flex h-6 items-center">
<Check className="text-primary size-4 shrink-0" />
</span>
{option.icon && (
<span className="flex h-6 items-center">{option.icon}</span>
)}
<div className="flex flex-col text-left">
<span className="text-base leading-6 font-medium text-pretty @md/option-list:text-sm">
{option.label}
</span>
{option.description && (
<span className="text-muted-foreground text-sm font-normal text-pretty">
{option.description}
</span>
)}
</div>
</div>
</Fragment>
))}
</div>
</div>
);
}
export function OptionList({
id,
options,
selectionMode = "multi",
minSelections = 1,
maxSelections,
value,
defaultValue,
choice,
onChange,
actions,
onAction,
onBeforeAction,
className,
}: OptionListProps) {
if (process.env["NODE_ENV"] !== "production") {
if (value !== undefined && defaultValue !== undefined) {
console.warn(
"[OptionList] Both `value` (controlled) and `defaultValue` (uncontrolled) were provided. `defaultValue` is ignored when `value` is set.",
);
}
if (value !== undefined && !onChange) {
console.warn(
"[OptionList] `value` was provided without `onChange`. This makes OptionList controlled; selection will not update unless the parent updates `value`.",
);
}
}
const effectiveMaxSelections = selectionMode === "single" ? 1 : maxSelections;
const optionIds = useMemo(
() => new Set(options.map((option) => option.id)),
[options],
);
const [uncontrolledSelected, setUncontrolledSelected] = useState<Set<string>>(
() =>
normalizeSelectionForOptions(
parseSelectionToIdSet(
defaultValue,
selectionMode,
effectiveMaxSelections,
),
optionIds,
),
);
const selectedIds = useMemo(() => {
const parsed =
value !== undefined
? parseSelectionToIdSet(value, selectionMode, effectiveMaxSelections)
: uncontrolledSelected;
return normalizeSelectionForOptions(parsed, optionIds);
}, [
value,
uncontrolledSelected,
selectionMode,
effectiveMaxSelections,
optionIds,
]);
const selectedCount = selectedIds.size;
const optionStates = useMemo(() => {
return options.map((option) => {
const isSelected = selectedIds.has(option.id);
const isSelectionLocked =
selectionMode === "multi" &&
effectiveMaxSelections !== undefined &&
selectedCount >= effectiveMaxSelections &&
!isSelected;
const isDisabled = option.disabled || isSelectionLocked;
return { option, isSelected, isDisabled };
});
}, [
options,
selectedIds,
selectionMode,
effectiveMaxSelections,
selectedCount,
]);
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
const [activeIndex, setActiveIndex] = useState(() => {
const firstSelected = optionStates.findIndex(
(s) => s.isSelected && !s.isDisabled,
);
if (firstSelected >= 0) return firstSelected;
const firstEnabled = optionStates.findIndex((s) => !s.isDisabled);
return firstEnabled >= 0 ? firstEnabled : 0;
});
useEffect(() => {
if (optionStates.length === 0) return;
setActiveIndex((prev) => {
if (
prev < 0 ||
prev >= optionStates.length ||
optionStates[prev].isDisabled
) {
const firstEnabled = optionStates.findIndex((s) => !s.isDisabled);
return firstEnabled >= 0 ? firstEnabled : 0;
}
return prev;
});
}, [optionStates]);
const updateSelection = useCallback(
(next: Set<string>) => {
const normalizedNext = normalizeSelectionForOptions(
parseSelectionToIdSet(
Array.from(next),
selectionMode,
effectiveMaxSelections,
),
optionIds,
);
if (value === undefined) {
if (!areSetsEqual(uncontrolledSelected, normalizedNext)) {
setUncontrolledSelected(normalizedNext);
}
}
onChange?.(convertIdSetToSelection(normalizedNext, selectionMode));
},
[
effectiveMaxSelections,
selectionMode,
uncontrolledSelected,
value,
onChange,
optionIds,
],
);
const toggleSelection = useCallback(
(optionId: string) => {
const next = new Set(selectedIds);
const isSelected = next.has(optionId);
if (selectionMode === "single") {
if (isSelected) {
next.delete(optionId);
} else {
next.clear();
next.add(optionId);
}
} else {
if (isSelected) {
next.delete(optionId);
} else {
if (effectiveMaxSelections && next.size >= effectiveMaxSelections) {
return;
}
next.add(optionId);
}
}
updateSelection(next);
},
[effectiveMaxSelections, selectedIds, selectionMode, updateSelection],
);
const toSelectionState = useCallback(
(selected: Set<string>): OptionListSelection =>
convertIdSetToSelection(selected, selectionMode),
[selectionMode],
);
const handleCancel = useCallback((): OptionListSelection => {
const empty = new Set<string>();
updateSelection(empty);
return toSelectionState(empty);
}, [toSelectionState, updateSelection]);
const customActions = useMemo(
() => normalizeActionsConfig(actions),
[actions],
);
const handleFooterAction = useCallback(
async (actionId: string) => {
let nextState = toSelectionState(selectedIds);
if (actionId === "cancel") {
nextState = handleCancel();
}
await onAction?.(actionId, nextState);
},
[handleCancel, onAction, selectedIds, toSelectionState],
);
const normalizedFooterActions = useMemo(() => {
if (customActions) return customActions;
return {
items: [
{ id: "cancel", label: "Clear", variant: "ghost" as const },
{ id: "confirm", label: "Confirm", variant: "default" as const },
],
align: "right" as const,
} satisfies ReturnType<typeof normalizeActionsConfig>;
}, [customActions]);
const isConfirmDisabled =
selectedCount < minSelections || selectedCount === 0;
const hasNothingToClear = selectedCount === 0;
const focusOptionAt = useCallback((index: number) => {
const el = optionRefs.current[index];
if (el) el.focus();
setActiveIndex(index);
}, []);
const findFirstEnabledIndex = useCallback(() => {
const idx = optionStates.findIndex((s) => !s.isDisabled);
return idx >= 0 ? idx : 0;
}, [optionStates]);
const findLastEnabledIndex = useCallback(() => {
for (let i = optionStates.length - 1; i >= 0; i--) {
if (!optionStates[i].isDisabled) return i;
}
return 0;
}, [optionStates]);
const findNextEnabledIndex = useCallback(
(start: number, direction: 1 | -1) => {
const len = optionStates.length;
if (len === 0) return 0;
for (let step = 1; step <= len; step++) {
const idx = (start + direction * step + len) % len;
if (!optionStates[idx].isDisabled) return idx;
}
return start;
},
[optionStates],
);
const handleListboxKeyDown = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
if (optionStates.length === 0) return;
const key = e.key;
if (key === "ArrowDown") {
e.preventDefault();
e.stopPropagation();
focusOptionAt(findNextEnabledIndex(activeIndex, 1));
return;
}
if (key === "ArrowUp") {
e.preventDefault();
e.stopPropagation();
focusOptionAt(findNextEnabledIndex(activeIndex, -1));
return;
}
if (key === "Home") {
e.preventDefault();
e.stopPropagation();
focusOptionAt(findFirstEnabledIndex());
return;
}
if (key === "End") {
e.preventDefault();
e.stopPropagation();
focusOptionAt(findLastEnabledIndex());
return;
}
if (key === "Enter" || key === " ") {
e.preventDefault();
e.stopPropagation();
const current = optionStates[activeIndex];
if (!current || current.isDisabled) return;
toggleSelection(current.option.id);
return;
}
if (key === "Escape") {
e.preventDefault();
e.stopPropagation();
if (!hasNothingToClear) {
handleCancel();
}
}
},
[
activeIndex,
findFirstEnabledIndex,
findLastEnabledIndex,
findNextEnabledIndex,
focusOptionAt,
handleCancel,
hasNothingToClear,
optionStates,
toggleSelection,
],
);
const actionsWithDisabledState = useMemo((): Action[] => {
return normalizedFooterActions.items.map((action) => {
const isDisabledByValidation =
(action.id === "confirm" && isConfirmDisabled) ||
(action.id === "cancel" && hasNothingToClear);
return {
...action,
disabled: action.disabled || isDisabledByValidation,
label:
action.id === "confirm" &&
selectionMode === "multi" &&
selectedCount > 0
? `${action.label} (${selectedCount})`
: action.label,
};
});
}, [
normalizedFooterActions.items,
isConfirmDisabled,
hasNothingToClear,
selectionMode,
selectedCount,
]);
const isReceipt = choice !== undefined && choice !== null;
const viewKey = isReceipt ? `receipt-${String(choice)}` : "interactive";
return (
<div key={viewKey} className="contents">
{isReceipt ? (
<OptionListConfirmation
id={id}
options={options}
selectedIds={normalizeSelectionForOptions(
parseSelectionToIdSet(choice, selectionMode),
optionIds,
)}
className={className}
/>
) : (
<div
className={cn(
"@container/option-list flex w-full max-w-md min-w-80 flex-col gap-3",
"text-foreground",
className,
)}
data-slot="option-list"
data-tool-ui-id={id}
role="group"
aria-label="Option list"
>
<div
className={cn(
"group/list bg-card flex w-full flex-col overflow-hidden rounded-2xl border px-4 py-1.5 shadow-xs",
)}
role="listbox"
aria-multiselectable={selectionMode === "multi"}
onKeyDown={handleListboxKeyDown}
>
{optionStates.map(({ option, isSelected, isDisabled }, index) => {
return (
<Fragment key={option.id}>
{index > 0 && (
<Separator
className="transition-opacity [@media(hover:hover)]:[&:has(+_:hover)]:opacity-0 [@media(hover:hover)]:[.peer:hover+&]:opacity-0"
orientation="horizontal"
/>
)}
<OptionItem
option={option}
isSelected={isSelected}
isDisabled={isDisabled}
selectionMode={selectionMode}
isFirst={index === 0}
isLast={index === optionStates.length - 1}
tabIndex={index === activeIndex ? 0 : -1}
onFocus={() => setActiveIndex(index)}
buttonRef={(el) => {
optionRefs.current[index] = el;
}}
onToggle={() => toggleSelection(option.id)}
/>
</Fragment>
);
})}
</div>
<div className="@container/actions">
<ActionButtons
actions={actionsWithDisabledState}
align={normalizedFooterActions.align}
confirmTimeout={normalizedFooterActions.confirmTimeout}
onAction={handleFooterAction}
onBeforeAction={
onBeforeAction
? (actionId) =>
onBeforeAction(actionId, toSelectionState(selectedIds))
: undefined
}
/>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,210 @@
import { z } from "zod";
import type { ReactNode } from "react";
import type { ActionsProp } from "../shared/actions-config";
import type { EmbeddedActionsProps } from "../shared/embedded-actions";
import {
ActionSchema,
SerializableActionSchema,
SerializableActionsConfigSchema,
ToolUIIdSchema,
ToolUIReceiptSchema,
ToolUIRoleSchema,
} from "../shared/schema";
import { defineToolUiContract } from "../shared/contract";
export const OptionListOptionSchema = z.object({
id: z.string().min(1),
label: z.string().min(1),
description: z.string().optional(),
icon: z.custom<ReactNode>().optional(),
disabled: z.boolean().optional(),
});
export type OptionListSelection = string[] | string | null;
const OptionListSelectionSchema = z
.union([z.array(z.string()), z.string(), z.null()])
.optional();
type OptionListSchemaInvariantInput = {
options: Array<{ id: string }>;
minSelections?: number;
maxSelections?: number;
value?: OptionListSelection;
defaultValue?: OptionListSelection;
choice?: OptionListSelection;
};
function selectionToIds(selection: OptionListSelection | undefined): string[] {
if (selection == null) return [];
if (typeof selection === "string") return [selection];
return Array.isArray(selection) ? selection : [];
}
function validateOptionListInvariants(
data: OptionListSchemaInvariantInput,
ctx: z.RefinementCtx,
) {
if (
data.minSelections !== undefined &&
data.maxSelections !== undefined &&
data.minSelections > data.maxSelections
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["minSelections"],
message: "`minSelections` cannot be greater than `maxSelections`.",
});
}
const optionIds = new Set<string>();
for (let index = 0; index < data.options.length; index++) {
const optionId = data.options[index]?.id;
if (!optionId) continue;
if (optionIds.has(optionId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["options", index, "id"],
message: `Duplicate option id "${optionId}" is not allowed.`,
});
} else {
optionIds.add(optionId);
}
}
const selectionFields: Array<
["value" | "defaultValue" | "choice", OptionListSelection | undefined]
> = [
["value", data.value],
["defaultValue", data.defaultValue],
["choice", data.choice],
];
for (const [fieldName, selection] of selectionFields) {
if (selection == null) continue;
const ids = selectionToIds(selection);
ids.forEach((selectionId, index) => {
if (!optionIds.has(selectionId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path:
typeof selection === "string" ? [fieldName] : [fieldName, index],
message: `Selection id "${selectionId}" must exist in options.`,
});
}
});
}
}
const OptionListPropsSchemaBase = z.object({
/**
* Unique identifier for this tool UI instance in the conversation.
*
* Used for:
* - Assistant referencing ("the options above")
* - Receipt generation (linking selections to their source)
* - Narration context
*
* Should be stable across re-renders, meaningful, and unique within the conversation.
*
* @example "option-list-deploy-target", "format-selection"
*/
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
receipt: ToolUIReceiptSchema.optional(),
options: z.array(OptionListOptionSchema).min(1),
selectionMode: z.enum(["multi", "single"]).optional(),
/**
* Controlled selection value (advanced / runtime only).
*
* For Tool UI tool payloads, prefer `defaultValue` (initial selection) and
* `choice` (receipt state). Controlled `value` is intentionally excluded
* from `SerializableOptionListSchema` to avoid accidental "controlled but
* non-interactive" states when an LLM includes `value` in args.
*/
value: OptionListSelectionSchema,
defaultValue: OptionListSelectionSchema,
/**
* When set, renders the component in receipt state showing the user's choice.
*
* In receipt state:
* - Only the chosen option(s) are shown
* - Actions are hidden
* - The component is read-only
*
* Use this with assistant-ui's `addResult` to show the outcome of a decision.
*
* @example
* ```tsx
* // In a toolkit render function:
* if (result) {
* return <OptionList {...args} choice={result} />;
* }
* ```
*/
choice: OptionListSelectionSchema,
actions: z
.union([z.array(ActionSchema), SerializableActionsConfigSchema])
.optional(),
minSelections: z.number().min(0).optional(),
maxSelections: z.number().min(1).optional(),
});
export const OptionListPropsSchema = OptionListPropsSchemaBase.superRefine(
validateOptionListInvariants,
);
export type OptionListOption = z.infer<typeof OptionListOptionSchema>;
export type OptionListProps = Omit<
z.infer<typeof OptionListPropsSchema>,
"value" | "defaultValue" | "choice" | "actions"
> & {
/** @see OptionListPropsSchema.id */
id: string;
value?: OptionListSelection;
defaultValue?: OptionListSelection;
/** @see OptionListPropsSchema.choice */
choice?: OptionListSelection;
onChange?: (value: OptionListSelection) => void;
actions?: ActionsProp;
onAction?: EmbeddedActionsProps<OptionListSelection>["onAction"];
onBeforeAction?: EmbeddedActionsProps<OptionListSelection>["onBeforeAction"];
className?: string;
};
export const SerializableOptionListSchema = OptionListPropsSchemaBase.omit({
// Exclude controlled selection from tool/LLM payloads.
value: true,
})
.extend({
options: z.array(OptionListOptionSchema.omit({ icon: true })),
actions: z
.union([
z.array(SerializableActionSchema),
SerializableActionsConfigSchema,
])
.optional(),
})
.strict()
.superRefine(validateOptionListInvariants);
export type SerializableOptionList = z.infer<
typeof SerializableOptionListSchema
>;
const SerializableOptionListSchemaContract = defineToolUiContract(
"OptionList",
SerializableOptionListSchema,
);
export const parseSerializableOptionList: (
input: unknown,
) => SerializableOptionList = SerializableOptionListSchemaContract.parse;
export const safeParseSerializableOptionList: (
input: unknown,
) => SerializableOptionList | null =
SerializableOptionListSchemaContract.safeParse;
@@ -0,0 +1,35 @@
import type { OptionListSelection } from "./schema";
export function parseSelectionToIdSet(
value: OptionListSelection | undefined,
mode: "multi" | "single",
maxSelections?: number,
): Set<string> {
if (mode === "single") {
const single =
typeof value === "string"
? value
: Array.isArray(value)
? value[0]
: null;
return single ? new Set([single]) : new Set();
}
const arr =
typeof value === "string" ? [value] : Array.isArray(value) ? value : [];
return new Set(maxSelections ? arr.slice(0, maxSelections) : arr);
}
export function normalizeSelectionForOptions(
selection: Set<string>,
optionIds: Set<string>,
): Set<string> {
const normalized = new Set<string>();
for (const id of selection) {
if (optionIds.has(id)) {
normalized.add(id);
}
}
return normalized;
}
@@ -0,0 +1,19 @@
# Order Summary
Implementation for the "order-summary" Tool UI surface.
## Files
- public exports: components/tool-ui/order-summary/index.tsx
- serializable schema + parse helpers: components/tool-ui/order-summary/schema.ts
## Companion assets
- Docs page: app/docs/order-summary/content.mdx
- Preset payload: lib/presets/order-summary.ts
## Quick check
Run this after edits:
pnpm test
@@ -0,0 +1,16 @@
/**
* 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., "@toolui/lib/utils", "~/lib/cn")
* Button → shadcn/ui Button
* Separator → shadcn/ui Separator
* Skeleton → shadcn/ui Skeleton
*/
export { cn } from "@toolui/lib/utils";
export { Button } from "@toolui/ui/button";
export { Separator } from "@toolui/ui/separator";
export { Skeleton } from "@toolui/ui/skeleton";
@@ -0,0 +1,14 @@
export { OrderSummary } from "./order-summary";
export type {
OrderSummaryDisplayProps,
OrderSummaryReceiptProps,
OrderSummaryCompoundComponent,
} from "./order-summary";
export {
type SerializableOrderSummary,
type OrderSummaryProps,
type OrderSummaryVariant,
type OrderItem,
type Pricing,
type OrderDecision,
} from "./schema";
@@ -0,0 +1,296 @@
import { CheckCircle, Package } from "lucide-react";
import type { ReactElement } from "react";
import { cn, Separator } from "./_adapter";
import type {
OrderSummaryProps,
OrderItem,
Pricing,
OrderDecision,
OrderSummaryVariant,
} from "./schema";
function formatCurrency(amount: number, currency: string): string {
try {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency,
}).format(amount);
} catch {
return `${currency} ${amount.toFixed(2)}`;
}
}
function formatQuantity(quantity: number): string {
return quantity === 1 ? "" : `Qty: ${quantity}`;
}
function ItemImage({ src, alt }: { src?: string; alt: string }) {
if (!src) {
return (
<div className="bg-muted flex h-12 w-12 shrink-0 items-center justify-center rounded-md">
<Package
aria-hidden="true"
focusable="false"
className="text-muted-foreground h-5 w-5"
/>
</div>
);
}
return (
<img
src={src}
alt={alt}
width={48}
height={48}
className="h-12 w-12 shrink-0 rounded-md object-cover"
/>
);
}
function OrderItemRow({
item,
currency,
}: {
item: OrderItem;
currency: string;
}) {
const quantity = item.quantity ?? 1;
const quantityText = formatQuantity(quantity);
const hasDescription = item.description || quantityText;
const lineTotal = item.unitPrice * quantity;
return (
<div className="flex gap-3">
<ItemImage src={item.imageUrl} alt={item.name} />
<div className="flex min-w-0 flex-1 items-center justify-between">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-center justify-between">
<span className="truncate text-sm font-medium">{item.name}</span>
<span className="truncate text-sm tabular-nums">
{formatCurrency(lineTotal, currency)}
</span>
</div>
{hasDescription && (
<div className="text-muted-foreground truncate text-sm">
{[item.description, quantityText].filter(Boolean).join(" · ")}
</div>
)}
</div>
</div>
</div>
);
}
function PricingBreakdown({
pricing,
className,
}: {
pricing: Pricing;
className?: string;
}) {
const currency = pricing.currency ?? "USD";
return (
<dl className={cn("flex flex-col gap-2 text-sm", className)}>
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">Subtotal</dt>
<dd className="tabular-nums">
{formatCurrency(pricing.subtotal, currency)}
</dd>
</div>
{pricing.discount !== undefined && pricing.discount > 0 && (
<div className="flex justify-between gap-4 text-green-600 dark:text-green-500">
<dt>{pricing.discountLabel || "Discount"}</dt>
<dd className="tabular-nums">
-{formatCurrency(pricing.discount, currency)}
</dd>
</div>
)}
{pricing.shipping !== undefined && (
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">Shipping</dt>
<dd className="tabular-nums">
{pricing.shipping === 0
? "Free"
: formatCurrency(pricing.shipping, currency)}
</dd>
</div>
)}
{pricing.tax !== undefined && (
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">{pricing.taxLabel || "Tax"}</dt>
<dd className="tabular-nums">
{formatCurrency(pricing.tax, currency)}
</dd>
</div>
)}
<div className="flex justify-between gap-4">
<dt className="font-medium">Total</dt>
<dd className="font-semibold tabular-nums">
{formatCurrency(pricing.total, currency)}
</dd>
</div>
</dl>
);
}
function formatDate(isoString: string): string | undefined {
try {
const date = new Date(isoString);
if (isNaN(date.getTime())) return undefined;
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
} catch {
return undefined;
}
}
function ReceiptBadge({
orderId,
confirmedAt,
}: {
orderId?: string;
confirmedAt?: string;
}) {
const formattedDate = confirmedAt ? formatDate(confirmedAt) : undefined;
const parts = [orderId && `#${orderId}`, formattedDate].filter(Boolean);
if (parts.length === 0) return null;
return (
<p className="text-muted-foreground mt-1 text-sm">{parts.join(" · ")}</p>
);
}
function OrderSummaryRoot({
id,
title = "Order Summary",
variant,
items,
pricing,
choice,
className,
}: OrderSummaryProps) {
const titleId = `${id}-title`;
const resolvedVariant: OrderSummaryVariant =
variant ?? (choice === undefined ? "summary" : "receipt");
const isReceipt = resolvedVariant === "receipt";
const isMalformedPayload =
!Array.isArray(items) ||
items.length === 0 ||
pricing == null ||
(isReceipt && choice === undefined);
if (isMalformedPayload) {
return (
<article
data-slot="order-summary"
data-tool-ui-id={id}
aria-labelledby={titleId}
className={cn("flex max-w-md min-w-80 flex-col gap-3", className)}
>
<div className="text-card-foreground rounded-lg border bg-card p-4 shadow-sm">
<h2 id={titleId} className="text-base font-semibold">
{title}
</h2>
<p className="text-muted-foreground mt-2 text-sm">
Unable to render order summary
</p>
</div>
</article>
);
}
return (
<article
data-slot="order-summary"
data-tool-ui-id={id}
aria-labelledby={titleId}
className={cn("flex max-w-md min-w-80 flex-col gap-3", className)}
>
<div
className={cn(
"text-card-foreground rounded-lg border shadow-sm",
isReceipt ? "bg-card/60" : "bg-card",
)}
>
<div className={cn("space-y-4 p-4", isReceipt && "opacity-95")}>
<div>
<h2
id={titleId}
className="flex items-center gap-2 text-base font-semibold"
>
{isReceipt && (
<CheckCircle
aria-hidden="true"
focusable="false"
className="h-5 w-5 text-green-600 dark:text-green-500"
/>
)}
{title}
</h2>
{isReceipt && choice && (
<ReceiptBadge
orderId={choice.orderId}
confirmedAt={choice.confirmedAt}
/>
)}
</div>
<div className="space-y-3">
{items.map((item) => (
<OrderItemRow
key={item.id}
item={item}
currency={pricing.currency ?? "USD"}
/>
))}
</div>
<Separator />
<PricingBreakdown pricing={pricing} />
</div>
</div>
</article>
);
}
export type OrderSummaryDisplayProps = OrderSummaryProps;
function OrderSummaryDisplay(props: OrderSummaryDisplayProps) {
return <OrderSummaryRoot {...props} variant="summary" />;
}
export interface OrderSummaryReceiptProps extends Omit<
OrderSummaryProps,
"choice"
> {
choice: OrderDecision;
}
function OrderSummaryReceipt(props: OrderSummaryReceiptProps) {
return <OrderSummaryRoot {...props} variant="receipt" />;
}
export interface OrderSummaryCompoundComponent {
(props: OrderSummaryProps): ReactElement;
Display: (props: OrderSummaryDisplayProps) => ReactElement;
Receipt: (props: OrderSummaryReceiptProps) => ReactElement;
}
export const OrderSummary: OrderSummaryCompoundComponent = Object.assign(
OrderSummaryRoot,
{
Display: OrderSummaryDisplay,
Receipt: OrderSummaryReceipt,
},
);
@@ -0,0 +1,108 @@
import { z } from "zod";
import { defineToolUiContract } from "../shared/contract";
import { ToolUIIdSchema, ToolUIRoleSchema } from "../shared/schema";
export const OrderItemSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string().optional(),
imageUrl: z.string().url().optional(),
quantity: z.number().int().positive().optional(),
unitPrice: z.number(),
});
export type OrderItem = z.infer<typeof OrderItemSchema>;
const OrderItemsSchema = z
.array(OrderItemSchema)
.min(1)
.superRefine((items, ctx) => {
const seenIds = new Set<string>();
for (const [index, item] of items.entries()) {
if (seenIds.has(item.id)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate item id: "${item.id}"`,
path: [index, "id"],
});
}
seenIds.add(item.id);
}
});
export const PricingSchema = z.object({
subtotal: z.number(),
tax: z.number().optional(),
taxLabel: z.string().optional(),
shipping: z.number().optional(),
discount: z.number().nonnegative().optional(),
discountLabel: z.string().optional(),
total: z.number(),
currency: z.string().optional(),
});
export type Pricing = z.infer<typeof PricingSchema>;
export const OrderSummaryVariantSchema = z.enum(["summary", "receipt"]);
export type OrderSummaryVariant = z.infer<typeof OrderSummaryVariantSchema>;
export const OrderDecisionSchema = z.object({
action: z.literal("confirm"),
orderId: z.string().optional(),
confirmedAt: z.string().datetime().optional(),
});
export type OrderDecision = z.infer<typeof OrderDecisionSchema>;
export const SerializableOrderSummarySchema = z
.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
title: z.string().optional(),
variant: OrderSummaryVariantSchema.optional(),
items: OrderItemsSchema,
pricing: PricingSchema,
choice: OrderDecisionSchema.optional(),
})
.strict()
.superRefine((value, ctx) => {
if (value.variant === "receipt" && value.choice === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Receipt variant requires "choice".',
path: ["choice"],
});
}
if (value.variant === "summary" && value.choice !== undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Summary variant cannot include "choice".',
path: ["choice"],
});
}
});
export type SerializableOrderSummary = z.infer<
typeof SerializableOrderSummarySchema
>;
const SerializableOrderSummarySchemaContract = defineToolUiContract(
"OrderSummary",
SerializableOrderSummarySchema,
);
export const parseSerializableOrderSummary: (
input: unknown,
) => SerializableOrderSummary = SerializableOrderSummarySchemaContract.parse;
export const safeParseSerializableOrderSummary: (
input: unknown,
) => SerializableOrderSummary | null =
SerializableOrderSummarySchemaContract.safeParse;
export interface OrderSummaryProps extends SerializableOrderSummary {
className?: string;
}
@@ -0,0 +1,19 @@
# Parameter Slider
Implementation for the "parameter-slider" Tool UI surface.
## Files
- public exports: components/tool-ui/parameter-slider/index.tsx
- serializable schema + parse helpers: components/tool-ui/parameter-slider/schema.ts
## Companion assets
- Docs page: app/docs/parameter-slider/content.mdx
- Preset payload: lib/presets/parameter-slider.ts
## Quick check
Run this after edits:
pnpm test
@@ -0,0 +1,16 @@
/**
* 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., "@toolui/lib/utils", "~/lib/cn")
* Button → shadcn/ui Button
* Separator → shadcn/ui Separator
* Slider → shadcn/ui Slider
*/
export { cn } from "@toolui/lib/utils";
export { Button } from "@toolui/ui/button";
export { Separator } from "@toolui/ui/separator";
export { Slider } from "@toolui/ui/slider";
@@ -0,0 +1,7 @@
export { ParameterSlider } from "./parameter-slider";
export type {
ParameterSliderProps,
SliderConfig,
SliderValue,
SerializableParameterSlider,
} from "./schema";
@@ -0,0 +1,42 @@
import type { SliderConfig, SliderValue } from "./schema";
type SliderPercentInput = {
value: number;
min: number;
max: number;
};
function clampPercent(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(100, value));
}
export function sliderRangeToPercent({
value,
min,
max,
}: SliderPercentInput): number {
const range = max - min;
if (!Number.isFinite(range) || range <= 0) return 0;
return clampPercent(((value - min) / range) * 100);
}
export function createSliderValueSnapshot(
sliders: SliderConfig[],
): SliderValue[] {
return sliders.map((slider) => ({ id: slider.id, value: slider.value }));
}
export function createSliderSignature(sliders: SliderConfig[]): string {
return JSON.stringify(
sliders.map(({ id, min, max, step, value, unit, precision }) => ({
id,
min,
max,
step: step ?? 1,
value,
unit: unit ?? "",
precision: precision ?? null,
})),
);
}
@@ -0,0 +1,821 @@
"use client";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import type { ParameterSliderProps, SliderConfig, SliderValue } from "./schema";
import { ActionButtons } from "../shared/action-buttons";
import { normalizeActionsConfig } from "../shared/actions-config";
import { useControllableState } from "../shared/use-controllable-state";
import { useSignatureReset } from "../shared/use-signature-reset";
import { cn } from "./_adapter";
import {
createSliderSignature,
createSliderValueSnapshot,
sliderRangeToPercent,
} from "./math";
function formatSignedValue(
value: number,
min: number,
max: number,
precision?: number,
unit?: string,
): string {
const crossesZero = min < 0 && max > 0;
const fixed =
precision !== undefined ? value.toFixed(precision) : String(value);
const numericPart = crossesZero && value >= 0 ? `+${fixed}` : fixed;
return unit ? `${numericPart} ${unit}` : numericPart;
}
function getAriaValueText(
value: number,
min: number,
max: number,
unit?: string,
): string {
const crossesZero = min < 0 && max > 0;
if (crossesZero) {
if (value > 0) {
return unit ? `plus ${value} ${unit}` : `plus ${value}`;
} else if (value < 0) {
return unit
? `minus ${Math.abs(value)} ${unit}`
: `minus ${Math.abs(value)}`;
}
}
return unit ? `${value} ${unit}` : String(value);
}
const TICK_COUNT = 16;
const TEXT_PADDING_X = 4;
const TEXT_PADDING_X_OUTER = 0; // Less inset on outer-facing side (near edges)
const TEXT_PADDING_Y = 2;
const DETECTION_MARGIN_X = 12;
const DETECTION_MARGIN_X_OUTER = 4; // Small margin at edges for steep falloff - segments fully close at terminal positions
const DETECTION_MARGIN_Y = 12;
const TRACK_HEIGHT = 48;
const TEXT_RELEASE_INSET = 8;
const TRACK_EDGE_INSET = 4; // px from track edge - keeps elements visible at extremes
const THUMB_WIDTH = 12; // w-3
// Text vertical offset: raised slightly from center
// Positive = raised, negative = lowered
const TEXT_VERTICAL_OFFSET = 0.5;
function clampPercent(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(100, value));
}
// Convert a percentage (0-100) to an inset position string
// At 0%: 4px from left edge; at 100%: 4px from right edge
function toInsetPosition(percent: number): string {
const safePercent = clampPercent(percent);
return `calc(${TRACK_EDGE_INSET}px + (100% - ${TRACK_EDGE_INSET * 2}px) * ${safePercent / 100})`;
}
// Radix keeps the thumb in bounds by applying a percent-dependent px offset.
// Matching this for fill clipping prevents handle/fill drift near extremes.
function getRadixThumbInBoundsOffsetPx(percent: number): number {
const safePercent = clampPercent(percent);
const halfWidth = THUMB_WIDTH / 2;
return halfWidth - (safePercent * halfWidth) / 50;
}
function toRadixThumbPosition(percent: number): string {
const safePercent = clampPercent(percent);
const offsetPx = getRadixThumbInBoundsOffsetPx(safePercent);
return `calc(${safePercent}% + ${offsetPx}px)`;
}
function signedDistanceToRoundedRect(
px: number,
py: number,
left: number,
right: number,
top: number,
bottom: number,
radiusLeft: number,
radiusRight: number,
): number {
const innerLeft = left + radiusLeft;
const innerRight = right - radiusRight;
const innerTop = top + Math.max(radiusLeft, radiusRight);
const innerBottom = bottom - Math.max(radiusLeft, radiusRight);
const inLeftCorner = px < innerLeft;
const inRightCorner = px > innerRight;
const inCornerY = py < innerTop || py > innerBottom;
if ((inLeftCorner || inRightCorner) && inCornerY) {
const radius = inLeftCorner ? radiusLeft : radiusRight;
const cornerX = inLeftCorner ? innerLeft : innerRight;
const cornerY = py < innerTop ? top + radius : bottom - radius;
const distToCornerCenter = Math.hypot(px - cornerX, py - cornerY);
return distToCornerCenter - radius;
}
const dx = Math.max(left - px, px - right, 0);
const dy = Math.max(top - py, py - bottom, 0);
if (dx === 0 && dy === 0) {
return -Math.min(px - left, right - px, py - top, bottom - py);
}
return Math.max(dx, dy);
}
const OUTER_EDGE_RADIUS_FACTOR = 0.3; // Reduced radius on outer-facing sides for steeper falloff
function calculateGap(
thumbCenterX: number,
textRect: { left: number; right: number; height: number; centerY: number },
isLeftAligned: boolean,
): number {
const { left, right, height, centerY } = textRect;
// Asymmetric padding/margin: outer-facing side has less padding, more margin
const paddingLeft = isLeftAligned ? TEXT_PADDING_X_OUTER : TEXT_PADDING_X;
const paddingRight = isLeftAligned ? TEXT_PADDING_X : TEXT_PADDING_X_OUTER;
const marginLeft = isLeftAligned
? DETECTION_MARGIN_X_OUTER
: DETECTION_MARGIN_X;
const marginRight = isLeftAligned
? DETECTION_MARGIN_X
: DETECTION_MARGIN_X_OUTER;
const paddingY = TEXT_PADDING_Y;
const marginY = DETECTION_MARGIN_Y;
const thumbCenterY = centerY;
// Inner boundary (where max gap occurs)
const innerLeft = left - paddingLeft;
const innerRight = right + paddingRight;
const innerTop = centerY - height / 2 - paddingY;
const innerBottom = centerY + height / 2 + paddingY;
const innerHeight = height + paddingY * 2;
const innerRadius = innerHeight / 2;
// Smaller radius on outer-facing side (left for label, right for value)
const innerRadiusLeft = isLeftAligned
? innerRadius * OUTER_EDGE_RADIUS_FACTOR
: innerRadius;
const innerRadiusRight = isLeftAligned
? innerRadius
: innerRadius * OUTER_EDGE_RADIUS_FACTOR;
// Outer boundary (where effect starts) - proportionally larger
const outerLeft = left - paddingLeft - marginLeft;
const outerRight = right + paddingRight + marginRight;
const outerTop = centerY - height / 2 - paddingY - marginY;
const outerBottom = centerY + height / 2 + paddingY + marginY;
const outerHeight = height + paddingY * 2 + marginY * 2;
const outerRadius = outerHeight / 2;
const outerRadiusLeft = isLeftAligned
? outerRadius * OUTER_EDGE_RADIUS_FACTOR
: outerRadius;
const outerRadiusRight = isLeftAligned
? outerRadius
: outerRadius * OUTER_EDGE_RADIUS_FACTOR;
const outerDist = signedDistanceToRoundedRect(
thumbCenterX,
thumbCenterY,
outerLeft,
outerRight,
outerTop,
outerBottom,
outerRadiusLeft,
outerRadiusRight,
);
// Outside outer boundary - no gap
if (outerDist > 0) return 0;
const innerDist = signedDistanceToRoundedRect(
thumbCenterX,
thumbCenterY,
innerLeft,
innerRight,
innerTop,
innerBottom,
innerRadiusLeft,
innerRadiusRight,
);
// Inside inner boundary - max gap
const maxGap = height + paddingY * 2;
if (innerDist <= 0) return maxGap;
// Between boundaries - linear interpolation
// outerDist is negative (inside outer), innerDist is positive (outside inner)
const totalDist = Math.abs(outerDist) + innerDist;
const t = Math.abs(outerDist) / totalDist;
return maxGap * t;
}
interface SliderRowProps {
config: SliderConfig;
value: number;
onChange: (value: number) => void;
trackClassName?: string;
fillClassName?: string;
handleClassName?: string;
}
function SliderRow({
config,
value,
onChange,
trackClassName,
fillClassName,
handleClassName,
}: SliderRowProps) {
const { id, label, min, max, step = 1, unit, precision, disabled } = config;
// Per-slider theming overrides component-level theming
const resolvedTrackClassName = config.trackClassName ?? trackClassName;
const resolvedFillClassName = config.fillClassName ?? fillClassName;
const resolvedHandleClassName = config.handleClassName ?? handleClassName;
const crossesZero = min < 0 && max > 0;
const [isDragging, setIsDragging] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const trackRef = useRef<HTMLSpanElement>(null);
const labelRef = useRef<HTMLSpanElement>(null);
const valueRef = useRef<HTMLSpanElement>(null);
const [dragGap, setDragGap] = useState(0);
const [fullGap, setFullGap] = useState(0);
const [intersectsText, setIntersectsText] = useState(false);
const [layoutVersion, setLayoutVersion] = useState(0);
useEffect(() => {
if (!isDragging) return;
const handlePointerUp = () => setIsDragging(false);
document.addEventListener("pointerup", handlePointerUp);
return () => document.removeEventListener("pointerup", handlePointerUp);
}, [isDragging]);
useEffect(() => {
const track = trackRef.current;
const labelEl = labelRef.current;
const valueEl = valueRef.current;
if (!track || !labelEl || !valueEl) return;
const bumpLayoutVersion = () => setLayoutVersion((v) => v + 1);
if (typeof ResizeObserver !== "undefined") {
const observer = new ResizeObserver(() => {
bumpLayoutVersion();
});
observer.observe(track);
observer.observe(labelEl);
observer.observe(valueEl);
return () => observer.disconnect();
}
window.addEventListener("resize", bumpLayoutVersion);
return () => window.removeEventListener("resize", bumpLayoutVersion);
}, []);
useLayoutEffect(() => {
const track = trackRef.current;
const labelEl = labelRef.current;
const valueEl = valueRef.current;
if (!track || !labelEl || !valueEl) return;
const trackRect = track.getBoundingClientRect();
const labelRect = labelEl.getBoundingClientRect();
const valueRect = valueEl.getBoundingClientRect();
const trackWidth = trackRect.width;
const valuePercent = sliderRangeToPercent({ value, min, max });
// Use same inset coordinate system as visual elements
const thumbCenterPx =
(trackWidth * clampPercent(valuePercent)) / 100 +
getRadixThumbInBoundsOffsetPx(valuePercent);
const thumbHalfWidth = THUMB_WIDTH / 2;
// Text is raised by TEXT_VERTICAL_OFFSET from center
const trackCenterY = TRACK_HEIGHT / 2 - TEXT_VERTICAL_OFFSET;
const labelGap = calculateGap(
thumbCenterPx,
{
left: labelRect.left - trackRect.left,
right: labelRect.right - trackRect.left,
height: labelRect.height,
centerY: trackCenterY,
},
true,
); // label is left-aligned
const valueGap = calculateGap(
thumbCenterPx,
{
left: valueRect.left - trackRect.left,
right: valueRect.right - trackRect.left,
height: valueRect.height,
centerY: trackCenterY,
},
false,
); // value is right-aligned
setDragGap(Math.max(labelGap, valueGap));
// Tight intersection check for release state
// Inset by px-2 (8px) padding to check against actual text, not padded container
const labelLeft = labelRect.left - trackRect.left + TEXT_RELEASE_INSET;
const labelRight = labelRect.right - trackRect.left - TEXT_RELEASE_INSET;
const valueLeft = valueRect.left - trackRect.left + TEXT_RELEASE_INSET;
const valueRight = valueRect.right - trackRect.left - TEXT_RELEASE_INSET;
const thumbLeft = thumbCenterPx - thumbHalfWidth;
const thumbRight = thumbCenterPx + thumbHalfWidth;
const hitsLabel = thumbRight > labelLeft && thumbLeft < labelRight;
const hitsValue = thumbRight > valueLeft && thumbLeft < valueRight;
setIntersectsText(hitsLabel || hitsValue);
// Calculate full separation gap for release state
// Use the max gap of whichever text element(s) the handle intersects
const labelFullGap = labelRect.height + TEXT_PADDING_Y * 2;
const valueFullGap = valueRect.height + TEXT_PADDING_Y * 2;
const releaseGap =
hitsLabel && hitsValue
? Math.max(labelFullGap, valueFullGap)
: hitsLabel
? labelFullGap
: hitsValue
? valueFullGap
: 0;
setFullGap(releaseGap);
}, [value, min, max, layoutVersion]);
// While dragging: use distance-based separation, but never collapse below
// the release split when the thumb still intersects text.
const gap = isDragging
? Math.max(dragGap, intersectsText ? fullGap : 0)
: intersectsText
? fullGap
: 0;
const ticks = useMemo(() => {
// Generate equidistant ticks regardless of step value
const majorTickCount = TICK_COUNT;
const result: { percent: number; isCenter: boolean; isSubtick: boolean }[] =
[];
for (let i = 0; i <= majorTickCount; i++) {
const percent = (i / majorTickCount) * 100;
const isCenter = !crossesZero && percent === 50;
// Skip the center tick (50%) for crossesZero sliders
if (crossesZero && percent === 50) continue;
// Add subtick at midpoint before this tick (except for first)
if (i > 0) {
const prevPercent = ((i - 1) / majorTickCount) * 100;
// Don't add subtick if it would be at 50% for crossesZero
const midPercent = (prevPercent + percent) / 2;
if (!(crossesZero && midPercent === 50)) {
result.push({
percent: midPercent,
isCenter: false,
isSubtick: true,
});
}
}
result.push({ percent, isCenter, isSubtick: false });
}
return result;
}, [crossesZero]);
const zeroPercent = crossesZero
? sliderRangeToPercent({ value: 0, min, max })
: 0;
const valuePercent = sliderRangeToPercent({ value, min, max });
// Fill clip-path uses the same inset coordinate system as the handle.
// This keeps the collapsed stroke aligned with the fill edge near extremes.
const fillClipPath = useMemo(() => {
const toClipFromRightInset = (percent: number) =>
`calc(100% - ${toRadixThumbPosition(percent)})`;
const toClipFromLeftInset = (percent: number) =>
toRadixThumbPosition(percent);
const TERMINAL_EPSILON = 1e-6;
const snapLeftInset = (percent: number) => {
if (percent <= TERMINAL_EPSILON) return "0";
if (percent >= 100 - TERMINAL_EPSILON) return "100%";
return toClipFromLeftInset(percent);
};
const snapRightInset = (percent: number) => {
if (percent <= TERMINAL_EPSILON) return "100%";
if (percent >= 100 - TERMINAL_EPSILON) return "0";
return toClipFromRightInset(percent);
};
if (crossesZero) {
// Keep center anchor stable by always clipping the low/high pair,
// independent of sign branch, then snapping at terminal edges.
const lowPercent = Math.min(valuePercent, zeroPercent);
const highPercent = Math.max(valuePercent, zeroPercent);
return `inset(0 ${snapRightInset(highPercent)} 0 ${snapLeftInset(lowPercent)})`;
}
// Non-crossing: fill starts at left edge; snap right inset at terminals.
return `inset(0 ${snapRightInset(valuePercent)} 0 0)`;
}, [crossesZero, zeroPercent, valuePercent]);
const fillMaskImage = crossesZero
? "linear-gradient(to right, rgba(0,0,0,0.2) 0%, rgba(0,0,0,0.35) 50%, rgba(0,0,0,0.7) 100%)"
: "linear-gradient(to right, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.7) 100%)";
// Metallic reflection gradient that follows the handle position
// Visible while dragging OR when resting at edges (0%/100%)
const reflectionStyle = useMemo(() => {
const edgeThreshold = 3;
const nearEdge =
valuePercent <= edgeThreshold || valuePercent >= 100 - edgeThreshold;
// Narrower spread when stationary at edges (~35% narrower)
const spreadPercent = nearEdge && !isDragging ? 6.5 : 10;
const handlePos = toRadixThumbPosition(valuePercent);
const start = `clamp(0%, calc(${handlePos} - ${spreadPercent}%), 100%)`;
const end = `clamp(0%, calc(${handlePos} + ${spreadPercent}%), 100%)`;
const gradient = `linear-gradient(to right,
transparent ${start},
white ${handlePos},
transparent ${end})`;
return {
background: gradient,
WebkitMask:
"linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
WebkitMaskComposite: "xor",
maskComposite: "exclude",
padding: "1px",
};
}, [valuePercent, isDragging]);
// Opacity scales with handle size: rest → hover → drag
const reflectionOpacity = useMemo(() => {
const edgeThreshold = 3;
const atEdge =
valuePercent <= edgeThreshold || valuePercent >= 100 - edgeThreshold;
if (isDragging || atEdge) {
return 1;
}
if (isHovered) {
return 0.6;
}
return 0;
}, [valuePercent, isDragging, isHovered]);
const handleValueChange = useCallback(
(values: number[]) => {
if (values[0] !== undefined) {
onChange(values[0]);
}
},
[onChange],
);
return (
<div className="py-2">
<SliderPrimitive.Root
id={id}
className={cn(
"group/slider relative flex w-full touch-none items-center select-none",
"isolate h-12",
isDragging
? "[&>span]:transition-[left,transform] [&>span]:duration-45 [&>span]:ease-linear"
: "[&>span]:transition-[left,transform] [&>span]:duration-90 [&>span]:ease-[cubic-bezier(0.22,1,0.36,1)]",
"[&>span]:will-change-[left,transform]",
"motion-reduce:[&>span]:transition-none",
disabled && "pointer-events-none opacity-50",
)}
value={[value]}
onValueChange={handleValueChange}
onPointerDown={() => setIsDragging(true)}
onPointerUp={() => setIsDragging(false)}
onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)}
min={min}
max={max}
step={step}
disabled={disabled}
aria-valuetext={getAriaValueText(value, min, max, unit)}
>
<SliderPrimitive.Track
ref={trackRef}
className={cn(
"squircle relative h-12 w-full grow overflow-hidden rounded-sm",
"ring-border ring-1 ring-inset",
"dark:ring-white/10",
resolvedTrackClassName ?? "bg-muted",
)}
>
<div
className={cn(
"absolute inset-0 will-change-[clip-path]",
isDragging
? "transition-[clip-path] duration-45 ease-linear"
: "transition-[clip-path] duration-90 ease-[cubic-bezier(0.22,1,0.36,1)]",
"motion-reduce:transition-none",
resolvedFillClassName ?? "bg-primary/30 dark:bg-primary/40",
)}
style={{
maskImage: fillMaskImage,
WebkitMaskImage: fillMaskImage,
clipPath: fillClipPath,
}}
/>
{ticks.map((tick, i) => {
const isEdge =
!tick.isSubtick && (tick.percent === 0 || tick.percent === 100);
return (
<span
key={i}
className={cn(
"pointer-events-none absolute bottom-px w-px",
tick.isSubtick ? "h-1.5" : "h-2",
isEdge
? "bg-transparent"
: tick.isSubtick
? "bg-foreground/8 dark:bg-white/5"
: tick.isCenter
? "bg-foreground/30 dark:bg-white/25"
: "bg-foreground/15 dark:bg-white/8",
)}
style={{
left: toInsetPosition(tick.percent),
transform: "translateX(-50%)",
}}
/>
);
})}
</SliderPrimitive.Track>
{/* Metallic reflection overlay - follows handle, brightness scales with interaction */}
<div
className={cn(
"squircle pointer-events-none absolute inset-0 rounded-sm",
isDragging
? "transition-[opacity,background] duration-45 ease-linear"
: "transition-[opacity,background] duration-90 ease-[cubic-bezier(0.22,1,0.36,1)]",
"motion-reduce:transition-none",
)}
style={{
...reflectionStyle,
opacity: reflectionOpacity,
filter: "blur(1px)",
mixBlendMode: "overlay",
}}
/>
<SliderPrimitive.Thumb
className={cn(
"group/thumb z-0 block w-3 shrink-0 cursor-grab rounded-sm",
"relative bg-transparent outline-none",
"transition-[height,opacity] duration-150 ease-[var(--cubic-ease-in-out)]",
"focus-visible:outline-ring focus-visible:outline-2 focus-visible:outline-offset-1",
"active:cursor-grabbing",
"disabled:pointer-events-none disabled:opacity-50",
// Height morphs: rest (track height) → hover → active
isDragging ? "h-[56px]" : isHovered ? "h-[54px]" : "h-12",
)}
>
{(() => {
// Calculate morph state
const isActive = isHovered || isDragging;
// Indicator stays centered on the real thumb while CSS transitions
// smooth thumb wrapper and fill movement together.
const fillEdgeOffset = 0;
// Hide rest-state indicator at edges (0% or 100%) - the reflection gradient handles this
const edgeThreshold = 3;
const atEdge =
valuePercent <= edgeThreshold ||
valuePercent >= 100 - edgeThreshold;
const restOpacity = atEdge ? 0 : 0.25;
// Asymmetric segment heights: gap is shifted up to match raised text position
// Top segment is shorter, bottom segment is taller
const topHeight =
isActive && gap > 0
? `calc(50% - ${gap / 2 + TEXT_VERTICAL_OFFSET}px)`
: "50%";
const bottomHeight =
isActive && gap > 0
? `calc(50% - ${gap / 2 - TEXT_VERTICAL_OFFSET}px)`
: "50%";
return (
<>
<span
className={cn(
"absolute top-0 left-1/2",
"transition-all duration-100 ease-[var(--cubic-ease-in-out)]",
isActive
? gap > 0
? "rounded-full"
: "rounded-t-full"
: "rounded-t-sm",
isDragging ? "w-2" : isActive ? "w-1.5" : "w-px",
resolvedHandleClassName ?? "bg-primary",
)}
style={{
transform: `translateX(calc(-50% + ${fillEdgeOffset}px))`,
height: topHeight,
opacity: isActive ? 1 : restOpacity,
}}
/>
<span
className={cn(
"absolute bottom-0 left-1/2",
"transition-all duration-100 ease-[var(--cubic-ease-in-out)]",
isActive
? gap > 0
? "rounded-full"
: "rounded-b-full"
: "rounded-b-sm",
isDragging ? "w-2" : isActive ? "w-1.5" : "w-px",
resolvedHandleClassName ?? "bg-primary",
)}
style={{
transform: `translateX(calc(-50% + ${fillEdgeOffset}px))`,
height: bottomHeight,
opacity: isActive ? 1 : restOpacity,
}}
/>
</>
);
})()}
</SliderPrimitive.Thumb>
<div
className="pointer-events-none absolute inset-x-3 top-1/2 z-10 flex items-center justify-between"
style={{
transform: `translateY(calc(-50% - ${TEXT_VERTICAL_OFFSET}px))`,
}}
>
<span
ref={labelRef}
className="text-primary -mt-px rounded-full px-2 py-px text-sm font-normal tracking-wide"
>
{label}
</span>
<span
ref={valueRef}
className="text-foreground -mt-px -mb-0.5 flex h-6 items-center rounded-full px-2 font-mono text-xs tabular-nums"
>
{formatSignedValue(value, min, max, precision, unit)}
</span>
</div>
</SliderPrimitive.Root>
</div>
);
}
export function ParameterSlider({
id,
sliders,
values: controlledValues,
onChange,
actions,
onAction,
onBeforeAction,
className,
trackClassName,
fillClassName,
handleClassName,
}: ParameterSliderProps) {
const slidersSignature = useMemo(
() => createSliderSignature(sliders),
[sliders],
);
const sliderSnapshot = useMemo(
() => createSliderValueSnapshot(sliders),
[sliders],
);
const {
value: currentValues,
isControlled,
setValue,
setUncontrolledValue,
} = useControllableState<SliderValue[]>({
value: controlledValues,
defaultValue: sliderSnapshot,
onChange,
});
useSignatureReset(slidersSignature, () => {
if (!isControlled) {
setUncontrolledValue(sliderSnapshot);
}
});
const valueMap = useMemo(() => {
const map = new Map<string, number>();
for (const v of currentValues) {
map.set(v.id, v.value);
}
return map;
}, [currentValues]);
const updateValue = useCallback(
(sliderId: string, newValue: number) => {
setValue((prev) =>
prev.map((v) => (v.id === sliderId ? { ...v, value: newValue } : v)),
);
},
[setValue],
);
const handleReset = useCallback(() => {
setValue(sliderSnapshot);
}, [setValue, sliderSnapshot]);
const handleAction = useCallback(
async (actionId: string) => {
let nextValues = currentValues;
if (actionId === "reset") {
handleReset();
nextValues = sliderSnapshot;
}
await onAction?.(actionId, nextValues);
},
[currentValues, handleReset, onAction, sliderSnapshot],
);
const normalizedActions = useMemo(() => {
const normalized = normalizeActionsConfig(actions);
if (normalized) return normalized;
return {
items: [
{ id: "reset", label: "Reset", variant: "ghost" as const },
{ id: "apply", label: "Apply", variant: "default" as const },
],
align: "right" as const,
};
}, [actions]);
return (
<article
className={cn(
"@container/parameter-slider isolate flex w-full max-w-md min-w-80 flex-col gap-3",
"text-foreground",
className,
)}
data-slot="parameter-slider"
data-tool-ui-id={id}
>
<div
className={cn(
"bg-card flex w-full flex-col overflow-hidden rounded-2xl border px-5 py-3 shadow-xs",
)}
>
{sliders.map((slider) => (
<SliderRow
key={slider.id}
config={slider}
value={valueMap.get(slider.id) ?? slider.value}
onChange={(v) => updateValue(slider.id, v)}
trackClassName={trackClassName}
fillClassName={fillClassName}
handleClassName={handleClassName}
/>
))}
</div>
<div className="@container/actions">
<ActionButtons
actions={normalizedActions.items}
align={normalizedActions.align}
confirmTimeout={normalizedActions.confirmTimeout}
onAction={handleAction}
onBeforeAction={
onBeforeAction
? (actionId) => onBeforeAction(actionId, currentValues)
: undefined
}
/>
</div>
</article>
);
}
@@ -0,0 +1,114 @@
import { z } from "zod";
import { type ActionsProp } from "../shared/actions-config";
import type { EmbeddedActionsProps } from "../shared/embedded-actions";
import { defineToolUiContract } from "../shared/contract";
import {
SerializableActionSchema,
SerializableActionsConfigSchema,
ToolUIIdSchema,
ToolUIRoleSchema,
} from "../shared/schema";
export const SliderConfigSchema = z
.object({
id: z.string().min(1),
label: z.string().min(1),
min: z.number().finite(),
max: z.number().finite(),
step: z.number().finite().positive().optional(),
value: z.number().finite(),
unit: z.string().optional(),
precision: z.number().int().min(0).optional(),
disabled: z.boolean().optional(),
trackClassName: z.string().optional(),
fillClassName: z.string().optional(),
handleClassName: z.string().optional(),
})
.superRefine((slider, ctx) => {
if (slider.max <= slider.min) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["max"],
message: "max must be greater than min",
});
}
if (slider.value < slider.min || slider.value > slider.max) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["value"],
message: "value must be between min and max",
});
}
});
export type SliderConfig = z.infer<typeof SliderConfigSchema>;
export const SerializableParameterSliderSchema = z
.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
sliders: z.array(SliderConfigSchema).min(1),
actions: z
.union([
z.array(SerializableActionSchema),
SerializableActionsConfigSchema,
])
.optional(),
})
.strict()
.superRefine((payload, ctx) => {
const seenIds = new Map<string, number>();
payload.sliders.forEach((slider, index) => {
const firstSeenAt = seenIds.get(slider.id);
if (firstSeenAt !== undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["sliders", index, "id"],
message: `duplicate slider id '${slider.id}' (first seen at index ${firstSeenAt})`,
});
return;
}
seenIds.set(slider.id, index);
});
});
export type SerializableParameterSlider = z.infer<
typeof SerializableParameterSliderSchema
>;
const SerializableParameterSliderSchemaContract = defineToolUiContract(
"ParameterSlider",
SerializableParameterSliderSchema,
);
export const parseSerializableParameterSlider: (
input: unknown,
) => SerializableParameterSlider =
SerializableParameterSliderSchemaContract.parse;
export const safeParseSerializableParameterSlider: (
input: unknown,
) => SerializableParameterSlider | null =
SerializableParameterSliderSchemaContract.safeParse;
export interface SliderValue {
id: string;
value: number;
}
export interface ParameterSliderProps extends Omit<
SerializableParameterSlider,
"actions"
> {
className?: string;
values?: SliderValue[];
onChange?: (values: SliderValue[]) => void;
actions?: ActionsProp;
onAction?: EmbeddedActionsProps<SliderValue[]>["onAction"];
onBeforeAction?: EmbeddedActionsProps<SliderValue[]>["onBeforeAction"];
trackClassName?: string;
fillClassName?: string;
handleClassName?: string;
}
@@ -0,0 +1,19 @@
# Plan
Implementation for the "plan" Tool UI surface.
## Files
- public exports: components/tool-ui/plan/index.tsx
- serializable schema + parse helpers: components/tool-ui/plan/schema.ts
## Companion assets
- Docs page: app/docs/plan/content.mdx
- Preset payload: lib/presets/plan.ts
## Quick check
Run this after edits:
pnpm test
@@ -0,0 +1,32 @@
/**
* 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., "@toolui/lib/utils", "~/lib/cn")
* Accordion → shadcn/ui Accordion
* Card → shadcn/ui Card
* Collapsible → shadcn/ui Collapsible
*/
export { cn } from "@toolui/lib/utils";
export {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from "@toolui/ui/accordion";
export {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
} from "@toolui/ui/card";
export {
Collapsible,
CollapsibleTrigger,
CollapsibleContent,
} from "@toolui/ui/collapsible";
@@ -0,0 +1,7 @@
export { Plan, PlanCompact } from "./plan";
export type {
PlanProps,
PlanTodo,
PlanTodoStatus,
SerializablePlan,
} from "./schema";
@@ -0,0 +1,428 @@
"use client";
import * as React from "react";
import { useMemo, useState, useEffect, useRef, memo } from "react";
import { Loader2, Check, X, MoreHorizontal, ChevronRight } from "lucide-react";
import type { PlanProps, PlanTodo, PlanTodoStatus } from "./schema";
import {
cn,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
Collapsible,
CollapsibleTrigger,
CollapsibleContent,
} from "./_adapter";
import { calculatePlanProgress, shouldCelebrateProgress } from "./progress";
const INITIAL_VISIBLE_TODO_COUNT = 4;
const TodoIcon = memo(function TodoIcon({
status,
}: {
status: PlanTodoStatus;
}) {
if (status === "pending") {
return (
<span
className="border-border bg-card flex size-6 shrink-0 items-center justify-center rounded-full border motion-safe:transition-all motion-safe:duration-200"
aria-hidden="true"
/>
);
}
if (status === "in_progress") {
return (
<span
className="border-border bg-card flex size-6 shrink-0 items-center justify-center rounded-full border shadow-[0_0_0_4px_hsl(var(--primary)/0.1)] motion-safe:transition-all motion-safe:duration-300"
aria-hidden="true"
>
<Loader2 className="text-primary size-5 motion-safe:animate-spin" />
</span>
);
}
if (status === "completed") {
return (
<span
className="border-primary bg-primary flex size-6 shrink-0 items-center justify-center rounded-full border shadow-sm motion-safe:animate-in motion-safe:fade-in motion-safe:zoom-in-75 motion-safe:duration-300 motion-safe:ease-out"
aria-hidden="true"
>
<Check
className="text-primary-foreground size-4 motion-safe:animate-in motion-safe:fade-in motion-safe:zoom-in-75 motion-safe:delay-75 motion-safe:duration-200 motion-safe:fill-mode-both"
strokeWidth={3}
/>
</span>
);
}
if (status === "cancelled") {
return (
<span
className="border-destructive bg-destructive flex size-6 shrink-0 items-center justify-center rounded-full border shadow-sm motion-safe:animate-in motion-safe:fade-in motion-safe:zoom-in-75 motion-safe:duration-300 motion-safe:ease-out dark:border-red-600 dark:bg-red-600"
aria-hidden="true"
>
<X
className="size-4 text-white motion-safe:animate-in motion-safe:fade-in motion-safe:zoom-in-75 motion-safe:delay-75 motion-safe:duration-200 motion-safe:fill-mode-both"
strokeWidth={3}
/>
</span>
);
}
return null;
});
interface PlanTodoItemProps {
todo: PlanTodo;
className?: string;
style?: React.CSSProperties;
showConnector?: boolean;
}
function areTodoPropsEqual(
prev: PlanTodoItemProps,
next: PlanTodoItemProps,
): boolean {
if (prev.todo.id !== next.todo.id) return false;
if (prev.todo.label !== next.todo.label) return false;
if (prev.todo.status !== next.todo.status) return false;
if (prev.todo.description !== next.todo.description) return false;
if (prev.showConnector !== next.showConnector) return false;
if (prev.className !== next.className) return false;
const prevStyle = prev.style;
const nextStyle = next.style;
if (prevStyle === nextStyle) return true;
if (!prevStyle || !nextStyle) return false;
return (
prevStyle.animationDelay === nextStyle.animationDelay &&
prevStyle.animationFillMode === nextStyle.animationFillMode
);
}
const PlanTodoItem = memo(function PlanTodoItem({
todo,
className,
style,
showConnector,
}: PlanTodoItemProps) {
const [isOpen, setIsOpen] = React.useState(false);
const labelElement = (
<span
className={cn(
"text-sm leading-6 font-medium break-words",
todo.status === "pending" && "text-muted-foreground",
todo.status === "in_progress" &&
"motion-safe:shimmer shimmer-invert text-foreground",
(todo.status === "completed" || todo.status === "cancelled") &&
"text-muted-foreground",
)}
>
{todo.label}
</span>
);
if (!todo.description) {
return (
<li
className={cn(
"relative -mx-2 flex cursor-default items-start gap-3 rounded-md px-2 py-1.5",
className,
)}
style={style}
>
{showConnector && (
<div
className="bg-border absolute top-6 left-5 w-px"
style={{
height: "calc(100% + 0.25rem)",
}}
aria-hidden="true"
/>
)}
<div className="relative z-10">
<TodoIcon status={todo.status} />
</div>
<div className="min-w-0 flex-1">{labelElement}</div>
</li>
);
}
return (
<li
className={cn(
"relative -mx-2 min-w-0 cursor-default rounded-md",
className,
)}
style={style}
>
{showConnector && (
<div
className="bg-border absolute top-6 left-5 w-px"
style={{
height: "calc(100% + 0.25rem)",
}}
aria-hidden="true"
/>
)}
<Collapsible asChild open={isOpen} onOpenChange={setIsOpen}>
<div
className="data-[state=open]:bg-primary/5 min-w-0 rounded-md motion-safe:transition-all motion-safe:duration-200"
style={{
backdropFilter: isOpen ? "blur(2px)" : undefined,
}}
>
<CollapsibleTrigger className="group/todo flex w-full cursor-default items-start gap-3 px-2 py-1.5 text-left">
<div className="relative z-10">
<TodoIcon status={todo.status} />
</div>
<span className="min-w-0 flex-1">{labelElement}</span>
<ChevronRight className="text-muted-foreground/50 group-hover/todo:text-muted-foreground mt-0.5 size-4 shrink-0 rotate-90 group-data-[state=open]/todo:[transform:rotateY(180deg)] motion-safe:transition-transform motion-safe:duration-300 motion-safe:ease-[cubic-bezier(0.34,1.56,0.64,1)]" />
</CollapsibleTrigger>
<CollapsibleContent
className="group/content"
data-slot="collapsible-content"
>
<div className="min-w-0 motion-safe:group-data-[state=closed]/content:animate-out motion-safe:group-data-[state=closed]/content:fade-out motion-safe:group-data-[state=closed]/content:slide-out-to-top-1 motion-safe:group-data-[state=closed]/content:duration-150 motion-safe:group-data-[state=open]/content:animate-in motion-safe:group-data-[state=open]/content:fade-in motion-safe:group-data-[state=open]/content:slide-in-from-top-1 motion-safe:group-data-[state=open]/content:delay-75 motion-safe:group-data-[state=open]/content:duration-150 motion-safe:group-data-[state=open]/content:fill-mode-both">
<p className="text-muted-foreground min-w-0 pr-2 pb-1.5 pl-11 text-sm text-pretty break-words">
{todo.description}
</p>
</div>
</CollapsibleContent>
</div>
</Collapsible>
</li>
);
}, areTodoPropsEqual);
interface TodoListProps {
todos: PlanTodo[];
newTodoIds: Set<string>;
}
function TodoList({ todos, newTodoIds }: TodoListProps) {
return (
<>
{todos.map((todo, index) => {
const isNew = newTodoIds.has(todo.id);
const staggerDelay = isNew ? index * 50 : 0;
return (
<PlanTodoItem
key={todo.id}
todo={todo}
showConnector={index < todos.length - 1}
className={cn(
isNew &&
"motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-1 motion-safe:duration-300 motion-safe:ease-out",
)}
style={
isNew
? {
animationDelay: `${staggerDelay}ms`,
animationFillMode: "backwards",
}
: undefined
}
/>
);
})}
</>
);
}
interface ProgressBarProps {
progress: number;
isCelebrating: boolean;
}
const ProgressBar = memo(function ProgressBar({
progress,
isCelebrating,
}: ProgressBarProps) {
return (
<div
className="bg-muted relative mb-3 h-1.5 overflow-hidden rounded-full"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progress}
>
<div
className={cn(
"h-full rounded-full transition-all duration-500",
progress === 100
? "bg-gradient-to-r from-emerald-600 via-emerald-500 to-emerald-400 motion-safe:animate-in motion-safe:fade-in motion-safe:duration-500 motion-safe:ease-out"
: "bg-primary",
)}
style={{
width: `${progress}%`,
boxShadow:
"inset 0 1px 0 rgba(255,255,255,0.3), 0 1px 2px rgba(0,0,0,0.2)",
}}
/>
{isCelebrating && (
<div
className="pointer-events-none absolute inset-0 rounded-full motion-safe:animate-pulse"
style={{
boxShadow: "0 0 20px rgba(16, 185, 129, 0.6)",
}}
/>
)}
</div>
);
});
function PlanRoot({
id,
title,
description,
todos,
maxVisibleTodos = INITIAL_VISIBLE_TODO_COUNT,
className,
compact = false,
}: PlanProps & { compact?: boolean }) {
const seenTodoIds = useRef(new Set<string>());
const [newTodoIds, setNewTodoIds] = useState<Set<string>>(new Set());
const [isCelebrating, setIsCelebrating] = useState(false);
const prevProgressRef = useRef(0);
const { visibleTodos, hiddenTodos, completedCount, allComplete, progress } =
useMemo(() => {
const completed = todos.filter((t) => t.status === "completed").length;
return {
visibleTodos: todos.slice(0, maxVisibleTodos),
hiddenTodos: todos.slice(maxVisibleTodos),
completedCount: completed,
allComplete: completed === todos.length,
progress: calculatePlanProgress({
completedCount: completed,
totalCount: todos.length,
}),
};
}, [todos, maxVisibleTodos]);
useEffect(() => {
const newIds = new Set<string>();
todos.forEach((todo) => {
if (!seenTodoIds.current.has(todo.id)) {
newIds.add(todo.id);
seenTodoIds.current.add(todo.id);
}
});
if (newIds.size > 0) {
setNewTodoIds(newIds);
// Clear animation class after entrance completes
const timer = setTimeout(() => {
setNewTodoIds(new Set());
}, 500);
return () => clearTimeout(timer);
}
}, [todos]);
useEffect(() => {
const shouldCelebrate = shouldCelebrateProgress({
previous: prevProgressRef.current,
next: progress,
});
prevProgressRef.current = progress;
if (shouldCelebrate) {
setIsCelebrating(true);
const timer = setTimeout(() => setIsCelebrating(false), 1000);
return () => clearTimeout(timer);
}
}, [progress]);
const todoList = (
<ul className={cn("min-w-0 space-y-1", compact ? "mt-0" : "mt-4")}>
<TodoList todos={visibleTodos} newTodoIds={newTodoIds} />
{hiddenTodos.length > 0 && (
<li className="mt-1">
<Accordion type="single" collapsible>
<AccordionItem value="more" className="border-0">
<AccordionTrigger className="text-muted-foreground hover:text-primary flex cursor-default items-start justify-start gap-2 py-1 text-sm font-normal [&>svg:last-child]:hidden">
<MoreHorizontal className="text-muted-foreground/70 mt-0.5 size-4 shrink-0" />
<span>{hiddenTodos.length} more</span>
</AccordionTrigger>
<AccordionContent className="pt-2 pb-0">
<ul className="-mx-2 space-y-2 px-2">
<TodoList todos={hiddenTodos} newTodoIds={newTodoIds} />
</ul>
</AccordionContent>
</AccordionItem>
</Accordion>
</li>
)}
</ul>
);
return (
<Card
className={cn("isolate w-full max-w-xl min-w-80 gap-4 py-4", className)}
data-tool-ui-id={id}
data-slot="plan"
>
{!compact && (
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div className="space-y-1.5">
<CardTitle className="leading-5 font-medium text-pretty">
{title}
</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
</div>
{allComplete && (
<Check className="mt-0.5 size-5 shrink-0 text-emerald-500" />
)}
</CardHeader>
)}
<CardContent className="min-w-0 px-4">
<div
className={cn(
"min-w-0",
!compact && "bg-muted/70 rounded-lg px-6 py-4",
)}
>
{!compact && (
<>
<div className="text-muted-foreground mb-2 text-sm">
{completedCount} of {todos.length} complete
</div>
<ProgressBar progress={progress} isCelebrating={isCelebrating} />
</>
)}
{todoList}
</div>
</CardContent>
</Card>
);
}
function PlanComponent(props: PlanProps) {
return <PlanRoot key={props.id} {...props} />;
}
export function PlanCompact(props: PlanProps) {
return <PlanRoot key={props.id} {...props} compact />;
}
type PlanComponentType = typeof PlanComponent & {
Compact: typeof PlanCompact;
};
export const Plan = Object.assign(PlanComponent, {
Compact: PlanCompact,
}) as PlanComponentType;
@@ -0,0 +1,29 @@
type ProgressInput = {
completedCount: number;
totalCount: number;
};
type CelebrateProgressInput = {
previous: number;
next: number;
};
function clampProgress(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(100, value));
}
export function calculatePlanProgress({
completedCount,
totalCount,
}: ProgressInput): number {
if (totalCount <= 0) return 0;
return clampProgress((completedCount / totalCount) * 100);
}
export function shouldCelebrateProgress({
previous,
next,
}: CelebrateProgressInput): boolean {
return previous < 100 && next === 100;
}
@@ -0,0 +1,69 @@
import { z } from "zod";
import {
ToolUIIdSchema,
ToolUIReceiptSchema,
ToolUIRoleSchema,
} from "../shared/schema";
import { defineToolUiContract } from "../shared/contract";
export const PlanTodoStatusSchema = z.enum([
"pending",
"in_progress",
"completed",
"cancelled",
]);
export const PlanTodoSchema = z.object({
id: z.string().min(1),
label: z.string().min(1),
status: PlanTodoStatusSchema,
description: z.string().optional(),
});
export type PlanTodoStatus = z.infer<typeof PlanTodoStatusSchema>;
export type PlanTodo = z.infer<typeof PlanTodoSchema>;
export const PlanPropsSchema = z
.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
receipt: ToolUIReceiptSchema.optional(),
title: z.string().min(1),
description: z.string().optional(),
todos: z.array(PlanTodoSchema).min(1),
maxVisibleTodos: z.number().finite().int().min(1).optional(),
})
.superRefine((value, ctx) => {
const seenTodoIds = new Set<string>();
value.todos.forEach((todo, index) => {
if (seenTodoIds.has(todo.id)) {
ctx.addIssue({
code: "custom",
path: ["todos", index, "id"],
message: `Duplicate todo id "${todo.id}".`,
});
return;
}
seenTodoIds.add(todo.id);
});
});
export type PlanProps = z.infer<typeof PlanPropsSchema> & {
className?: string;
};
export const SerializablePlanSchema = PlanPropsSchema;
export type SerializablePlan = z.infer<typeof SerializablePlanSchema>;
const SerializablePlanSchemaContract = defineToolUiContract(
"Plan",
SerializablePlanSchema,
);
export const parseSerializablePlan: (input: unknown) => SerializablePlan =
SerializablePlanSchemaContract.parse;
export const safeParseSerializablePlan: (
input: unknown,
) => SerializablePlan | null = SerializablePlanSchemaContract.safeParse;
@@ -0,0 +1,19 @@
# Preferences Panel
Implementation for the "preferences-panel" Tool UI surface.
## Files
- public exports: components/tool-ui/preferences-panel/index.tsx
- serializable schema + parse helpers: components/tool-ui/preferences-panel/schema.ts
## Companion assets
- Docs page: app/docs/preferences-panel/content.mdx
- Preset payload: lib/presets/preferences-panel.ts
## Quick check
Run this after edits:
pnpm test
@@ -0,0 +1,28 @@
/**
* 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., "@toolui/lib/utils", "~/lib/cn")
* Button → shadcn/ui Button
* Switch → shadcn/ui Switch
* ToggleGroup → shadcn/ui ToggleGroup
* Select → shadcn/ui Select
* Separator → shadcn/ui Separator
* Label → shadcn/ui Label
*/
export { cn } from "@toolui/lib/utils";
export { Button } from "@toolui/ui/button";
export { Switch } from "@toolui/ui/switch";
export { ToggleGroup, ToggleGroupItem } from "@toolui/ui/toggle-group";
export {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@toolui/ui/select";
export { Separator } from "@toolui/ui/separator";
export { Label } from "@toolui/ui/label";
@@ -0,0 +1,10 @@
export { PreferencesPanel, PreferencesPanelReceipt } from "./preferences-panel";
export {
type SerializablePreferencesPanel,
type SerializablePreferencesPanelReceipt,
type PreferencesPanelProps,
type PreferencesPanelReceiptProps,
type PreferencesValue,
type PreferenceItem,
type PreferenceSection,
} from "./schema";
@@ -0,0 +1,681 @@
"use client";
import { useCallback, useMemo } from "react";
import type {
PreferencesPanelProps,
PreferencesPanelReceiptProps,
PreferencesValue,
PreferenceItem,
PreferenceSection,
} from "./schema";
import { ActionButtons } from "../shared/action-buttons";
import { normalizeActionsConfig } from "../shared/actions-config";
import { type Action } from "../shared/schema";
import { useControllableState } from "../shared/use-controllable-state";
import { useSignatureReset } from "../shared/use-signature-reset";
import {
cn,
Switch,
ToggleGroup,
ToggleGroupItem,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Separator,
Label,
} from "./_adapter";
import { Check, AlertCircle } from "lucide-react";
import { createPreferencesSectionSignature } from "./signature";
function getInitialValue(item: PreferenceItem): string | boolean {
switch (item.type) {
case "switch":
return item.defaultChecked ?? false;
case "toggle":
return item.defaultValue ?? item.options?.[0]?.value ?? "";
case "select":
return item.defaultSelected ?? item.selectOptions?.[0]?.value ?? "";
}
}
function formatDisplayValue(
item: PreferenceItem,
value: string | boolean,
): string {
if (item.type === "switch") {
return typeof value === "boolean" && value ? "On" : "Off";
}
const stringValue = typeof value === "string" ? value : "";
const options = item.type === "toggle" ? item.options : item.selectOptions;
const option = options?.find((opt) => opt.value === stringValue);
return option?.label ?? stringValue;
}
function computeInitialValues(sections: PreferenceSection[]): PreferencesValue {
return sections.reduce<PreferencesValue>((acc, section) => {
section.items.forEach((item) => {
acc[item.id] = getInitialValue(item);
});
return acc;
}, {});
}
interface PreferenceControlProps {
item: PreferenceItem;
value: string | boolean;
onChange: (value: string | boolean) => void;
disabled?: boolean;
}
function SwitchControl({
id,
checked,
onChange,
disabled,
label,
}: {
id: string;
checked: boolean;
onChange: (value: boolean) => void;
disabled?: boolean;
label: string;
}) {
return (
<Switch
id={id}
checked={checked}
onCheckedChange={onChange}
disabled={disabled}
aria-label={label}
/>
);
}
function ToggleControl({
value,
options,
onChange,
disabled,
label,
}: {
value: string;
options: Array<{ value: string; label: string }>;
onChange: (value: string) => void;
disabled?: boolean;
label: string;
}) {
return (
<ToggleGroup
type="single"
value={value}
onValueChange={(v) => v && onChange(v)}
disabled={disabled}
aria-label={label}
className="gap-1"
>
{options.map((opt) => (
<ToggleGroupItem
key={opt.value}
value={opt.value}
aria-label={opt.label}
className="!rounded-full px-3 py-1.5 text-sm"
>
{opt.label}
</ToggleGroupItem>
))}
</ToggleGroup>
);
}
function SelectControl({
id,
value,
options,
onChange,
disabled,
label,
}: {
id: string;
value: string;
options: Array<{ value: string; label: string }>;
onChange: (value: string) => void;
disabled?: boolean;
label: string;
}) {
return (
<Select value={value} onValueChange={onChange} disabled={disabled}>
<SelectTrigger id={id} className="w-[180px]" aria-label={label}>
<SelectValue placeholder="Select..." />
</SelectTrigger>
<SelectContent>
{options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function PreferenceControl({
item,
value,
onChange,
disabled,
}: PreferenceControlProps) {
const id = `preference-${item.id}`;
if (item.type === "switch") {
return (
<SwitchControl
id={id}
checked={typeof value === "boolean" ? value : false}
onChange={onChange}
disabled={disabled}
label={item.label}
/>
);
}
const stringValue = typeof value === "string" ? value : "";
if (item.type === "toggle" && item.options) {
return (
<ToggleControl
value={stringValue}
options={item.options}
onChange={onChange}
disabled={disabled}
label={item.label}
/>
);
}
if (item.type === "select" && item.selectOptions) {
return (
<SelectControl
id={id}
value={stringValue}
options={item.selectOptions}
onChange={onChange}
disabled={disabled}
label={item.label}
/>
);
}
return null;
}
interface PreferenceItemRowProps {
item: PreferenceItem;
value: string | boolean;
onChange?: (value: string | boolean) => void;
disabled?: boolean;
isReceipt?: boolean;
error?: string;
showSuccessIndicators?: boolean;
isFirstInSectionWithoutHeading?: boolean;
}
function ItemLabel({
item,
error,
isReceipt,
}: {
item: PreferenceItem;
error?: string;
isReceipt: boolean;
}) {
const htmlFor = `preference-${item.id}`;
if (isReceipt) {
return (
<>
<span className="text-sm leading-6 font-medium text-pretty">
{item.label}
</span>
{error ? (
<span className="text-destructive text-sm font-normal text-pretty">
{error}
</span>
) : item.description ? (
<span className="text-muted-foreground text-sm font-normal text-pretty">
{item.description}
</span>
) : null}
</>
);
}
return (
<>
<Label htmlFor={htmlFor} className="leading-6 font-medium text-pretty">
{item.label}
</Label>
{item.description && (
<p className="text-muted-foreground text-sm font-normal text-pretty">
{item.description}
</p>
)}
</>
);
}
function ItemValue({
item,
value,
error,
showSuccessIndicators,
}: {
item: PreferenceItem;
value: string | boolean;
error?: string;
showSuccessIndicators: boolean;
}) {
const displayValue = formatDisplayValue(item, value);
return (
<div className="flex shrink-0 items-center gap-2">
<span className="text-muted-foreground text-sm font-medium">
{displayValue}
</span>
{error ? (
<AlertCircle className="text-destructive size-3.5" />
) : showSuccessIndicators ? (
<Check className="size-3.5 text-emerald-600 dark:text-emerald-500" />
) : null}
</div>
);
}
function PreferenceItemRow({
item,
value,
onChange,
disabled,
isReceipt = false,
error,
showSuccessIndicators = false,
isFirstInSectionWithoutHeading = false,
}: PreferenceItemRowProps) {
const shouldStack = item.type !== "switch" && !isReceipt;
return (
<div
className={cn(
"flex items-start justify-between gap-4",
isFirstInSectionWithoutHeading ? "pt-0 pb-3" : "py-3",
shouldStack &&
"flex-col gap-3 @sm/preferences-panel:flex-row @sm/preferences-panel:gap-4",
)}
>
<div className="flex flex-col gap-1">
<ItemLabel item={item} error={error} isReceipt={isReceipt} />
</div>
{isReceipt ? (
<ItemValue
item={item}
value={value}
error={error}
showSuccessIndicators={showSuccessIndicators}
/>
) : (
<div className="flex shrink-0">
<PreferenceControl
item={item}
value={value}
onChange={onChange!}
disabled={disabled}
/>
</div>
)}
</div>
);
}
interface ItemListProps {
items: PreferenceItem[];
values: PreferencesValue;
onChangeValue?: (itemId: string, value: string | boolean) => void;
disabled?: boolean;
isReceipt?: boolean;
errors?: Record<string, string>;
showSuccessIndicators?: boolean;
hasHeading?: boolean;
hasTitle?: boolean;
}
function ItemList({
items,
values,
onChangeValue,
disabled,
isReceipt,
errors,
showSuccessIndicators,
hasHeading = false,
hasTitle = false,
}: ItemListProps) {
const shouldRemoveFirstPadding = !hasHeading && hasTitle;
return (
<div className="flex flex-col">
{items.map((item, index) => {
const isFirst = index === 0;
const itemValue = values[item.id] ?? getInitialValue(item);
const handleChange = onChangeValue
? (v: string | boolean) => onChangeValue(item.id, v)
: undefined;
return (
<div key={item.id}>
{!isFirst && <Separator className="my-1" />}
<PreferenceItemRow
item={item}
value={itemValue}
onChange={handleChange}
disabled={disabled}
isReceipt={isReceipt}
error={errors?.[item.id]}
showSuccessIndicators={showSuccessIndicators}
isFirstInSectionWithoutHeading={
isFirst && shouldRemoveFirstPadding
}
/>
</div>
);
})}
</div>
);
}
interface PreferencesSectionProps {
section: PreferenceSection;
values: PreferencesValue;
onChangeValue?: (itemId: string, value: string | boolean) => void;
disabled?: boolean;
isReceipt?: boolean;
errors?: Record<string, string>;
hasTitle?: boolean;
}
function PreferencesSection({
section,
values,
onChangeValue,
disabled,
isReceipt = false,
errors,
hasTitle = false,
}: PreferencesSectionProps) {
const hasErrors = !!(errors && Object.keys(errors).length > 0);
const content = (
<ItemList
items={section.items}
values={values}
onChangeValue={onChangeValue}
disabled={disabled}
isReceipt={isReceipt}
errors={errors}
showSuccessIndicators={hasErrors}
hasHeading={!!section.heading}
hasTitle={hasTitle}
/>
);
if (section.heading) {
return (
<fieldset className="flex flex-col">
<legend className="text-muted-foreground pb-1 text-xs tracking-widest uppercase">
{section.heading}
</legend>
{content}
</fieldset>
);
}
return content;
}
interface ReceiptHeaderProps {
title: string;
hasErrors: boolean;
}
function ReceiptHeader({ title, hasErrors }: ReceiptHeaderProps) {
return (
<>
<div className="flex items-center justify-between gap-3 px-5 py-4">
<h2 className="text-base leading-none font-semibold">{title}</h2>
{hasErrors === true ? (
<span className="text-destructive flex items-center gap-1.5 text-xs font-medium">
<AlertCircle className="size-3.5" />
Error
</span>
) : (
<span className="flex items-center gap-1.5 text-xs font-medium text-emerald-600 dark:text-emerald-500">
<Check className="size-3.5" />
Saved
</span>
)}
</div>
<Separator />
</>
);
}
export function PreferencesPanelReceipt({
id,
title,
sections,
choice,
error,
className,
}: PreferencesPanelReceiptProps) {
const hasErrors = error && Object.keys(error).length > 0;
return (
<article
data-slot="preferences-panel"
data-tool-ui-id={id}
data-receipt="true"
role="status"
aria-label={
hasErrors ? "Preferences with errors" : "Confirmed preferences"
}
className={cn(
"@container/preferences-panel flex w-full max-w-md min-w-80 flex-col",
className,
)}
>
<div className="bg-card/60 flex w-full flex-col overflow-hidden rounded-2xl border opacity-95 shadow-xs">
{title && <ReceiptHeader title={title} hasErrors={!!hasErrors} />}
<div
className={cn("flex flex-col gap-4 px-5", title ? "py-6" : "py-2")}
>
{sections.map((section, index) => (
<div key={index}>
<PreferencesSection
section={section}
values={choice}
errors={error}
isReceipt={true}
hasTitle={!!title}
/>
</div>
))}
</div>
</div>
</article>
);
}
function PreferencesPanelRoot({
id,
title,
sections,
value: controlledValue,
onChange,
actions,
onAction,
onBeforeAction,
className,
}: PreferencesPanelProps) {
const initialValues = useMemo(
() => computeInitialValues(sections),
[sections],
);
const sectionsSignature = useMemo(
() => createPreferencesSectionSignature(sections),
[sections],
);
const {
value: currentValue,
isControlled,
setValue,
setUncontrolledValue,
} = useControllableState<PreferencesValue>({
value: controlledValue,
defaultValue: initialValues,
onChange,
});
useSignatureReset(sectionsSignature, () => {
if (!isControlled) {
setUncontrolledValue(initialValues);
}
});
const updateValue = useCallback(
(itemId: string, newValue: string | boolean) => {
setValue((prev) => ({ ...prev, [itemId]: newValue }));
},
[setValue],
);
const isDirty = useMemo(() => {
return Object.keys(currentValue).some(
(key) => currentValue[key] !== initialValues[key],
);
}, [currentValue, initialValues]);
const handleCancel = useCallback((): PreferencesValue => {
setValue(initialValues);
return initialValues;
}, [initialValues, setValue]);
const handleAction = useCallback(
async (actionId: string) => {
let nextValue = currentValue;
if (actionId === "cancel") {
nextValue = handleCancel();
}
await onAction?.(actionId, nextValue);
},
[currentValue, handleCancel, onAction],
);
const normalizedActions = useMemo(() => {
const normalized = normalizeActionsConfig(actions);
if (normalized) {
return {
...normalized,
align: normalized.align ?? ("right" as const),
};
}
const defaultActions: Action[] = [
{ id: "cancel", label: "Cancel", variant: "ghost" },
{ id: "save", label: "Save Changes", variant: "default" },
];
return {
items: defaultActions,
align: "right" as const,
};
}, [actions]);
const actionsWithState = useMemo((): Action[] => {
return normalizedActions.items.map((action) => {
const isSaveAction = action.id === "save";
const baseDisabled = "disabled" in action ? action.disabled : false;
const shouldDisable = baseDisabled || (isSaveAction && !isDirty);
return {
...action,
disabled: shouldDisable,
};
});
}, [normalizedActions.items, isDirty]);
return (
<article
data-slot="preferences-panel"
data-tool-ui-id={id}
role="form"
className={cn(
"text-foreground @container/preferences-panel flex w-full max-w-md min-w-80 flex-col gap-3",
className,
)}
>
<div className="bg-card flex w-full flex-col overflow-hidden rounded-2xl border shadow-xs">
{title && (
<>
<div className="px-5 py-4">
<h2 className="text-base leading-none font-semibold">{title}</h2>
</div>
<Separator />
</>
)}
<div
className={cn("flex flex-col gap-4 px-5", title ? "py-6" : "py-2")}
>
{sections.map((section, sectionIndex) => (
<div key={sectionIndex}>
<PreferencesSection
section={section}
values={currentValue}
onChangeValue={updateValue}
isReceipt={false}
hasTitle={!!title}
/>
</div>
))}
</div>
</div>
<div className="@container/actions">
<ActionButtons
actions={actionsWithState}
align={normalizedActions.align}
confirmTimeout={normalizedActions.confirmTimeout}
onAction={handleAction}
onBeforeAction={
onBeforeAction
? (actionId) => onBeforeAction(actionId, currentValue)
: undefined
}
/>
</div>
</article>
);
}
type PreferencesPanelComponent = typeof PreferencesPanelRoot & {
Receipt: typeof PreferencesPanelReceipt;
};
export const PreferencesPanel = Object.assign(PreferencesPanelRoot, {
Receipt: PreferencesPanelReceipt,
}) as PreferencesPanelComponent;
@@ -0,0 +1,144 @@
import { z } from "zod";
import { type ActionsProp } from "../shared/actions-config";
import type { EmbeddedActionsProps } from "../shared/embedded-actions";
import { defineToolUiContract } from "../shared/contract";
import {
SerializableActionSchema,
SerializableActionsConfigSchema,
ToolUIIdSchema,
ToolUIReceiptSchema,
ToolUIRoleSchema,
} from "../shared/schema";
const PreferenceItemBaseSchema = z.object({
id: z.string().min(1),
label: z.string().min(1),
description: z.string().optional(),
});
const PreferenceSwitchSchema = PreferenceItemBaseSchema.extend({
type: z.literal("switch"),
defaultChecked: z.boolean().optional(),
});
const PreferenceToggleSchema = PreferenceItemBaseSchema.extend({
type: z.literal("toggle"),
options: z
.array(
z.object({
value: z.string().min(1),
label: z.string().min(1),
}),
)
.min(2),
defaultValue: z.string().optional(),
});
const PreferenceSelectSchema = PreferenceItemBaseSchema.extend({
type: z.literal("select"),
selectOptions: z
.array(
z.object({
value: z.string().min(1),
label: z.string().min(1),
}),
)
.min(5),
defaultSelected: z.string().optional(),
});
const PreferenceItemSchema = z.discriminatedUnion("type", [
PreferenceSwitchSchema,
PreferenceToggleSchema,
PreferenceSelectSchema,
]);
const PreferenceSectionSchema = z.object({
heading: z.string().min(1).optional(),
items: z.array(PreferenceItemSchema).min(1),
});
const PreferencesPanelBaseSchema = z.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
receipt: ToolUIReceiptSchema.optional(),
title: z.string().min(1).optional(),
sections: z.array(PreferenceSectionSchema).min(1),
});
export const SerializablePreferencesPanelSchema =
PreferencesPanelBaseSchema.extend({
actions: z
.union([
z.array(SerializableActionSchema),
SerializableActionsConfigSchema,
])
.optional(),
}).strict();
export const SerializablePreferencesPanelReceiptSchema =
PreferencesPanelBaseSchema.extend({
choice: z.record(z.string(), z.union([z.string(), z.boolean()])),
error: z.record(z.string(), z.string()).optional(),
}).strict();
export type SerializablePreferencesPanel = z.infer<
typeof SerializablePreferencesPanelSchema
>;
export type SerializablePreferencesPanelReceipt = z.infer<
typeof SerializablePreferencesPanelReceiptSchema
>;
const SerializablePreferencesPanelSchemaContract = defineToolUiContract(
"PreferencesPanel",
SerializablePreferencesPanelSchema,
);
const SerializablePreferencesPanelReceiptSchemaContract = defineToolUiContract(
"PreferencesPanelReceipt",
SerializablePreferencesPanelReceiptSchema,
);
export const parseSerializablePreferencesPanel: (
input: unknown,
) => SerializablePreferencesPanel =
SerializablePreferencesPanelSchemaContract.parse;
export const safeParseSerializablePreferencesPanel: (
input: unknown,
) => SerializablePreferencesPanel | null =
SerializablePreferencesPanelSchemaContract.safeParse;
export const parseSerializablePreferencesPanelReceipt: (
input: unknown,
) => SerializablePreferencesPanelReceipt =
SerializablePreferencesPanelReceiptSchemaContract.parse;
export const safeParseSerializablePreferencesPanelReceipt: (
input: unknown,
) => SerializablePreferencesPanelReceipt | null =
SerializablePreferencesPanelReceiptSchemaContract.safeParse;
export interface PreferencesValue {
[itemId: string]: string | boolean;
}
export interface PreferencesPanelProps extends Omit<
SerializablePreferencesPanel,
"actions"
> {
className?: string;
value?: PreferencesValue;
onChange?: (value: PreferencesValue) => void;
actions?: ActionsProp;
onAction?: EmbeddedActionsProps<PreferencesValue>["onAction"];
onBeforeAction?: EmbeddedActionsProps<PreferencesValue>["onBeforeAction"];
}
export interface PreferencesPanelReceiptProps extends SerializablePreferencesPanelReceipt {
className?: string;
}
export type PreferenceItem = z.infer<typeof PreferenceItemSchema>;
export type PreferenceSection = z.infer<typeof PreferenceSectionSchema>;
@@ -0,0 +1,37 @@
import type { PreferenceSection } from "./schema";
export function createPreferencesSectionSignature(
sections: PreferenceSection[],
): string {
return JSON.stringify(
sections.map((section) => ({
heading: section.heading ?? "",
items: section.items.map((item) => {
if (item.type === "switch") {
return {
id: item.id,
type: item.type,
defaultChecked: item.defaultChecked ?? false,
};
}
if (item.type === "toggle") {
return {
id: item.id,
type: item.type,
defaultValue: item.defaultValue ?? item.options[0]?.value ?? "",
options: item.options.map((option) => option.value),
};
}
return {
id: item.id,
type: item.type,
defaultSelected:
item.defaultSelected ?? item.selectOptions[0]?.value ?? "",
options: item.selectOptions.map((option) => option.value),
};
}),
})),
);
}
@@ -0,0 +1,19 @@
# Progress Tracker
Implementation for the "progress-tracker" Tool UI surface.
## Files
- public exports: components/tool-ui/progress-tracker/index.tsx
- serializable schema + parse helpers: components/tool-ui/progress-tracker/schema.ts
## Companion assets
- Docs page: app/docs/progress-tracker/content.mdx
- Preset payload: lib/presets/progress-tracker.ts
## Quick check
Run this after edits:
pnpm test
@@ -0,0 +1,10 @@
/**
* 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., "@toolui/lib/utils", "~/lib/cn")
*/
export { cn } from "@toolui/lib/utils";
@@ -0,0 +1,7 @@
export { ProgressTracker } from "./progress-tracker";
export {
type SerializableProgressTracker,
type ProgressTrackerProps,
type ProgressTrackerChoice,
type ProgressStep,
} from "./schema";
@@ -0,0 +1,381 @@
import { cn } from "./_adapter";
import type {
ProgressStep,
ProgressTrackerChoice,
ProgressTrackerProps,
} from "./schema";
import { Check, X, Loader2, Timer, AlertCircle } from "lucide-react";
import type { LucideIcon } from "lucide-react";
function formatElapsedTime(milliseconds: number): string {
const roundedSeconds = Math.round(Math.max(0, milliseconds) / 100) / 10;
if (roundedSeconds < 60) {
return `${roundedSeconds.toFixed(1)}s`;
}
const wholeSeconds = Math.floor(roundedSeconds);
const minutes = Math.floor(wholeSeconds / 60);
const remainingSeconds = wholeSeconds % 60;
return `${minutes}m ${remainingSeconds}s`;
}
function formatElapsedTimeDateTime(milliseconds: number): string {
const roundedSeconds = Math.round(Math.max(0, milliseconds) / 100) / 10;
if (roundedSeconds < 60) {
return `PT${Number(roundedSeconds.toFixed(1))}S`;
}
const wholeSeconds = Math.floor(roundedSeconds);
const hours = Math.floor(wholeSeconds / 3600);
const minutes = Math.floor((wholeSeconds % 3600) / 60);
const seconds = wholeSeconds % 60;
const hourPart = hours > 0 ? `${hours}H` : "";
const minutePart = minutes > 0 ? `${minutes}M` : "";
const secondPart = seconds > 0 ? `${seconds}S` : "";
if (!hourPart && !minutePart && !secondPart) {
return "PT0S";
}
return `PT${hourPart}${minutePart}${secondPart}`;
}
function getCurrentStepId(steps: ProgressStep[]): string | null {
const inProgressStep = steps.find((s) => s.status === "in-progress");
if (inProgressStep) return inProgressStep.id;
const failedStep = steps.find((s) => s.status === "failed");
if (failedStep) return failedStep.id;
const firstPendingStep = steps.find((s) => s.status === "pending");
if (firstPendingStep) return firstPendingStep.id;
return null;
}
function getReceiptState(outcome: ProgressTrackerChoice["outcome"]): {
toneClassName: string;
icon: LucideIcon;
} {
switch (outcome) {
case "success":
return {
toneClassName: "text-emerald-600 dark:text-emerald-500",
icon: Check,
};
case "partial":
return {
toneClassName: "text-amber-600 dark:text-amber-500",
icon: AlertCircle,
};
case "failed":
return {
toneClassName: "text-destructive",
icon: AlertCircle,
};
case "cancelled":
return {
toneClassName: "text-muted-foreground",
icon: X,
};
}
}
interface StepIndicatorProps {
status: "pending" | "in-progress" | "completed" | "failed";
}
function StepIndicator({ status }: StepIndicatorProps) {
if (status === "pending") {
return (
<span
className="bg-card border-border flex size-6 shrink-0 items-center justify-center rounded-full border motion-safe:transition-all motion-safe:duration-200"
aria-hidden="true"
/>
);
}
if (status === "in-progress") {
return (
<span
className="bg-card border-border flex size-6 shrink-0 items-center justify-center rounded-full border shadow-[0_0_0_4px_hsl(var(--primary)/0.1)] motion-safe:transition-all motion-safe:duration-300"
aria-hidden="true"
>
<Loader2 className="text-primary size-5 motion-safe:animate-spin" />
</span>
);
}
if (status === "completed") {
return (
<span
className="bg-primary text-primary-foreground border-primary flex size-6 shrink-0 items-center justify-center rounded-full border shadow-sm motion-safe:animate-in motion-safe:fade-in motion-safe:zoom-in-75 motion-safe:duration-300 motion-safe:ease-out"
aria-hidden="true"
>
<Check
className="size-4 motion-safe:animate-in motion-safe:fade-in motion-safe:zoom-in-75 motion-safe:delay-75 motion-safe:duration-200 motion-safe:fill-mode-both"
strokeWidth={3}
/>
</span>
);
}
if (status === "failed") {
return (
<span
className="bg-destructive border-destructive flex size-6 shrink-0 items-center justify-center rounded-full border text-white shadow-sm motion-safe:animate-in motion-safe:fade-in motion-safe:zoom-in-75 motion-safe:duration-300 motion-safe:ease-out dark:border-red-600 dark:bg-red-600"
aria-hidden="true"
>
<X
className="size-4 motion-safe:animate-in motion-safe:fade-in motion-safe:zoom-in-75 motion-safe:delay-75 motion-safe:duration-200 motion-safe:fill-mode-both"
strokeWidth={3}
/>
</span>
);
}
return null;
}
function ElapsedTimeBadge({ elapsedTime }: { elapsedTime?: number }) {
if (elapsedTime === undefined || elapsedTime <= 0) {
return null;
}
return (
<div className="text-muted-foreground flex items-center gap-1.5 font-mono text-xs">
<Timer className="-mt-px size-3.5" />
<time dateTime={formatElapsedTimeDateTime(elapsedTime)}>
{formatElapsedTime(elapsedTime)}
</time>
</div>
);
}
interface ProgressTrackerBaseProps {
id: ProgressTrackerProps["id"];
steps: ProgressTrackerProps["steps"];
elapsedTime?: ProgressTrackerProps["elapsedTime"];
className?: ProgressTrackerProps["className"];
}
function ProgressTrackerReceipt({
id,
steps,
elapsedTime,
className,
choice,
}: ProgressTrackerBaseProps & { choice: ProgressTrackerChoice }) {
const receiptState = getReceiptState(choice.outcome);
const ReceiptIcon = receiptState.icon;
return (
<div
className={cn(
"isolate flex w-full max-w-md min-w-80 flex-col",
"text-foreground select-none",
"motion-safe:animate-in motion-safe:fade-in motion-safe:blur-in-sm motion-safe:zoom-in-95 motion-safe:duration-300 motion-safe:ease-[cubic-bezier(0.16,1,0.3,1)] motion-safe:fill-mode-both",
className,
)}
data-slot="progress-tracker"
data-tool-ui-id={id}
data-receipt="true"
role="status"
aria-label={choice.summary}
>
<div className="bg-card/60 flex w-full flex-col gap-4 rounded-2xl border p-5 shadow-xs">
<div className="flex items-center justify-between">
<ElapsedTimeBadge elapsedTime={elapsedTime} />
<span
className={cn(
"flex items-center gap-1.5 text-xs font-medium",
receiptState.toneClassName,
)}
>
<ReceiptIcon className="size-3.5" />
{choice.summary}
</span>
</div>
<ol className="m-0 flex list-none flex-col gap-2 p-0">
{steps.map((step, index) => (
<li
key={step.id}
className="relative -mx-2 flex items-start gap-3 rounded-lg px-2 py-1.5"
>
{index < steps.length - 1 && (
<div
className="bg-border absolute top-8 left-5 w-px"
style={{
height: "calc(100% + 0.5rem)",
}}
aria-hidden="true"
/>
)}
<div className="relative z-10">
<StepIndicator status={step.status} />
</div>
<div className="flex flex-1 flex-col gap-0.5">
<span className="text-sm leading-6 font-medium">
{step.label}
</span>
{step.description && (
<span className="text-muted-foreground text-sm">
{step.description}
</span>
)}
</div>
</li>
))}
</ol>
</div>
</div>
);
}
function ProgressTrackerLive({
id,
steps,
elapsedTime,
className,
}: ProgressTrackerBaseProps) {
const hasInProgress = steps.some((step) => step.status === "in-progress");
const currentStepId = getCurrentStepId(steps);
return (
<article
className={cn(
"isolate flex w-full max-w-md min-w-80 flex-col gap-3",
"text-foreground select-none",
className,
)}
data-slot="progress-tracker"
data-tool-ui-id={id}
role="status"
aria-live="polite"
aria-busy={hasInProgress}
>
<div className="bg-card flex w-full flex-col gap-4 rounded-2xl border p-5 shadow-xs">
<ElapsedTimeBadge elapsedTime={elapsedTime} />
<ol className="m-0 flex list-none flex-col gap-3 p-0">
{steps.map((step, index) => {
const isCurrent = step.id === currentStepId;
const isActive = step.status === "in-progress";
const isFailed = step.status === "failed";
const hasDescription = !!step.description;
const shouldShowDescription = isActive || isFailed;
return (
<li
key={step.id}
className="relative -mx-2"
aria-current={isCurrent ? "step" : undefined}
>
{index < steps.length - 1 && (
<div
className={cn(
"bg-border absolute top-6 left-5 w-px",
"motion-safe:transition-all motion-safe:duration-300",
)}
style={{
height: "calc(100% + 0.25rem)",
}}
aria-hidden="true"
/>
)}
<div
className={cn(
"relative z-10 flex items-start gap-3 rounded-lg px-2 py-1.5",
"motion-safe:transition-all motion-safe:duration-300",
isCurrent && "bg-primary/5",
)}
style={{
backdropFilter: isCurrent ? "blur(2px)" : undefined,
}}
>
<div className="relative z-10">
<StepIndicator status={step.status} />
</div>
<div className="flex flex-1 flex-col">
<span
className={cn(
"text-sm leading-6 font-medium",
step.status === "pending" && "text-muted-foreground",
step.status === "in-progress" &&
"motion-safe:shimmer shimmer-invert text-foreground",
)}
>
{step.label}
</span>
{hasDescription && (
<div
className={cn(
"grid motion-safe:transition-[grid-template-rows,opacity] motion-safe:duration-300 motion-safe:ease-out",
shouldShowDescription
? "grid-rows-[1fr] opacity-100"
: "grid-rows-[0fr] opacity-0",
)}
aria-hidden={!shouldShowDescription}
>
<div className="overflow-hidden">
<span className="text-muted-foreground block pt-0.5 text-sm">
{step.description}
</span>
</div>
</div>
)}
</div>
</div>
</li>
);
})}
</ol>
</div>
</article>
);
}
function ProgressTrackerRoot({
id,
steps,
elapsedTime,
className,
choice,
}: ProgressTrackerProps) {
const viewKey = choice ? `receipt-${choice.outcome}` : "interactive";
return (
<div key={viewKey} className="contents">
{choice ? (
<ProgressTrackerReceipt
id={id}
steps={steps}
elapsedTime={elapsedTime}
className={className}
choice={choice}
/>
) : (
<ProgressTrackerLive
id={id}
steps={steps}
elapsedTime={elapsedTime}
className={className}
/>
)}
</div>
);
}
type ProgressTrackerComponent = typeof ProgressTrackerRoot & {
Live: typeof ProgressTrackerLive;
Receipt: typeof ProgressTrackerReceipt;
};
export const ProgressTracker = Object.assign(ProgressTrackerRoot, {
Live: ProgressTrackerLive,
Receipt: ProgressTrackerReceipt,
}) as ProgressTrackerComponent;
@@ -0,0 +1,76 @@
import { z } from "zod";
import {
ToolUISurfaceSchema,
ToolUIReceiptSchema,
type ToolUIReceipt,
} from "../shared/schema";
import { defineToolUiContract } from "../shared/contract";
/**
* Receipt state for ProgressTracker showing the outcome of a workflow.
*/
export type ProgressTrackerChoice = ToolUIReceipt;
export const ProgressStepSchema = z.object({
id: z.string().min(1),
label: z.string().min(1),
description: z.string().optional(),
status: z.enum(["pending", "in-progress", "completed", "failed"]),
});
export type ProgressStep = z.infer<typeof ProgressStepSchema>;
const ProgressStepsSchema = z
.array(ProgressStepSchema)
.min(1)
.superRefine((steps, ctx) => {
const seenIds = new Set<string>();
for (const [index, step] of steps.entries()) {
if (seenIds.has(step.id)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate step id: "${step.id}"`,
path: [index, "id"],
});
}
seenIds.add(step.id);
}
});
export const SerializableProgressTrackerSchema = ToolUISurfaceSchema.omit({
receipt: true,
})
.extend({
steps: ProgressStepsSchema,
elapsedTime: z.number().finite().nonnegative().optional(),
/**
* When set, renders the component in receipt state showing the workflow outcome.
*/
choice: ToolUIReceiptSchema.optional(),
})
.strict();
export type SerializableProgressTracker = z.infer<
typeof SerializableProgressTrackerSchema
>;
const SerializableProgressTrackerSchemaContract = defineToolUiContract(
"ProgressTracker",
SerializableProgressTrackerSchema,
);
export const parseSerializableProgressTracker: (
input: unknown,
) => SerializableProgressTracker =
SerializableProgressTrackerSchemaContract.parse;
export const safeParseSerializableProgressTracker: (
input: unknown,
) => SerializableProgressTracker | null =
SerializableProgressTrackerSchemaContract.safeParse;
export interface ProgressTrackerProps extends SerializableProgressTracker {
className?: string;
}

Some files were not shown because too many files have changed in this diff Show More