mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-06 17:57:43 +02:00
[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:
@@ -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 ""
|
||||
|
||||
|
||||
|
||||
Generated
+2821
-20
File diff suppressed because it is too large
Load Diff
+13
-1
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
@@ -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
Reference in New Issue
Block a user