[eric] apps: the full tool-ui widget set is vendored into every app workspace behind an @toolui alias, deps exact-pinned to the host's locked versions

This commit is contained in:
ciregenz
2026-08-08 01:22:43 -07:00
parent 610ba00a9d
commit a12047ae66
225 changed files with 25736 additions and 2 deletions
@@ -368,6 +368,11 @@ Auth is automatic (host-injected token); never hand-roll fetches against host ro
works in preview and installed apps; for features that must survive PUBLISHING to the public
web, use `window.OUTPUT_LLM` / `window.OUTPUT_COMPUTE` below instead.
The workspace also vendors the full tool-ui widget set at `src/toolui/` (data-table, chart,
code-block, code-diff, terminal, geo-map, media, social-post cards, and 15 more): reach for
`import { DataTable } from '@toolui/components/data-table'` before hand-building any table,
chart, or code view. The catalog + usage patterns are in `SDK.md`.
## Publishable AI + compute — `window.OUTPUT_LLM` / `window.OUTPUT_COMPUTE`
The FastAPI backend above runs in preview but is **not hosted when an app is
@@ -51,6 +51,35 @@ const state = await agentSession(sessionId); // {status, messages, ...} — pol
The agent is a real OpenSwarm agent card the user can watch and take over. Spawn sparingly:
one agent per user action, never in a loop.
## Tool UI components — ready-made rich widgets
The full OpenSwarm tool-ui component set is vendored at `src/toolui/` (import via the
`@toolui` alias). These are the same widgets agents render for rich results: use them
instead of hand-building tables, charts, code viewers, or media blocks.
Available components (each lives at `@toolui/components/<name>`): approval-card, audio,
chart, citation, code-block, code-diff, data-table, geo-map, image, image-gallery,
instagram-post, item-carousel, link-preview, linkedin-post, message-draft, option-list,
order-summary, parameter-slider, plan, preferences-panel, progress-tracker, question-flow,
stats-display, terminal, video, weather-widget, x-post.
Render through `VendoredToolUi` (it validates props against the component's zod schema,
loads the styles, and applies the required `.tool-ui-scope` wrapper + dark mode for you):
```tsx
import VendoredToolUi from '@toolui/VendoredToolUi';
<VendoredToolUi name="data-table" props={{ columns, rows, title: 'Leads' }} />
```
Direct imports (`import { DataTable } from '@toolui/components/data-table'`) work too, but
then YOU must import `@toolui/toolui.css` once and wrap the render in
`<div className="tool-ui-scope">` (add `dark` in dark mode) or the widget renders unstyled.
Each component folder carries its own README + zod schema (`@toolui/registry` maps
name -> schema). They style themselves (scoped Tailwind, no preflight), so they drop
into the MUI app without fights, and they follow the app's light/dark mode.
## What the SDK does NOT give you (yet)
- Direct calls to the user's connected tools/MCP connectors (Gmail, Slack, ...). That surface
@@ -13,17 +13,51 @@
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.9",
"@mui/material": "^7.3.9",
"@pierre/diffs": "1.0.11",
"@radix-ui/react-accordion": "^1.2.20",
"@radix-ui/react-avatar": "^1.2.6",
"@radix-ui/react-collapsible": "^1.1.20",
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-dropdown-menu": "^2.1.24",
"@radix-ui/react-label": "^2.1.15",
"@radix-ui/react-popover": "^1.1.23",
"@radix-ui/react-radio-group": "^1.4.7",
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-separator": "^1.1.15",
"@radix-ui/react-slider": "^1.4.7",
"@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-switch": "^1.3.7",
"@radix-ui/react-tabs": "^1.1.21",
"@radix-ui/react-toggle": "^1.1.18",
"@radix-ui/react-toggle-group": "^1.1.19",
"@radix-ui/react-tooltip": "^1.2.16",
"@reduxjs/toolkit": "^2.8.2",
"ansi-to-react": "6.2.6",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"framer-motion": "^12.36.0",
"leaflet": "1.9.4",
"lucide-react": "1.17.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-leaflet": "4.2.1",
"react-redux": "^9.2.0",
"react-router-dom": "^7.13.1"
"react-router-dom": "^7.13.1",
"recharts": "2.15.4",
"shiki": "3.23.0",
"supercluster": "8.0.1",
"tailwind-merge": "3.6.0",
"tw-animate-css": "1.4.0",
"zod": "4.4.3"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.0",
"@types/leaflet": "^1.9.21",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/supercluster": "^7.1.3",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.3.3",
"typescript": "^5.0.0",
"vite": "^6.3.5",
"vite-plugin-pages": "^0.33.3",
@@ -0,0 +1 @@
# Vendored tool-ui component library (pierre). Do not hand-edit; upstream drift lands wholesale.
@@ -0,0 +1,4 @@
This directory is a GENERATED copy of OpenSwarm's `frontend/src/toolui`
(vendored per app template so generated apps can use the tool-ui components).
Do not hand-edit; run `scripts/sync-toolui-template.sh` from the OpenSwarm
repo root to refresh it.
@@ -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,216 @@
import React, { Suspense, useEffect, useMemo, useRef, useState } from 'react';
import { useThemeMode } from '@/shared/styles/ThemeContext';
import { TOOL_UI_REGISTRY } from './registry';
interface GuardProps { name: string; quiet?: boolean; children: React.ReactNode }
// A component render throwing must cost exactly one quiet line, never the app: the top-level
// ErrorBoundary unmounts the whole shell for any uncaught child throw (the linkedin-post {post}
// mismatch took down the dashboard until this wall existed).
class ComponentGuard extends React.Component<GuardProps, { failed: boolean }> {
constructor(props: GuardProps) {
super(props);
this.state = { failed: false };
}
static getDerivedStateFromError(): { failed: boolean } {
return { failed: true };
}
render(): React.ReactNode {
if (this.state.failed) {
if (this.props.quiet) return null;
return (
<div style={{ fontSize: '0.75rem', opacity: 0.45, padding: '4px 0', fontStyle: 'italic' }}>
Couldn't draw the {this.props.name.replace(/-/g, ' ')} view
</div>
);
}
return this.props.children;
}
}
interface VendoredToolUiProps {
name: string;
props: Record<string, unknown>;
/** Non-serializable React props (callbacks, live overrides) merged AFTER validation of the wire props. */
extraProps?: Record<string, unknown>;
/** Ambient surfaces (the collapsed pill) show NOTHING on failure; a floating error line on the canvas is worse than absence. */
quietFail?: boolean;
}
const warnedShapes = new Set<string>();
type Gate =
| { state: 'pending' }
| { state: 'ok'; parsed: Record<string, unknown> }
| { state: 'bad'; problem: string };
/** Models pad payloads with invented keys; strip ONLY unrecognized-key issues and retry once, so
sloppiness self-heals while genuinely wrong shapes still fall back loudly. */
function slugFor(label: unknown, i: number): string {
const t = typeof label === 'string' ? label.trim().toLowerCase().replace(/\s+/g, '-').slice(0, 40) : '';
return t || `item-${i + 1}`;
}
// Mechanical repairs for the mistakes agents actually make (numeric ids, ranked priorities, nested
// row objects, bare action objects). Only ever applied when the strict parse FAILED, and the result
// is re-validated, so a repair can flip fail->pass but never corrupt a valid payload.
function repairCommonAgentShapes(props: Record<string, unknown>): Record<string, unknown> {
let out: Record<string, unknown>;
try {
out = JSON.parse(JSON.stringify(props ?? {}, (_k, v) => (v === undefined ? null : v)));
} catch {
return props;
}
const fixIdLabel = (arr: unknown): unknown => {
if (!Array.isArray(arr)) return arr;
return arr.map((o, i) => {
if (typeof o === 'string') return { id: slugFor(o, i), label: o };
if (o && typeof o === 'object' && !Array.isArray(o)) {
const obj = { ...(o as Record<string, unknown>) };
// Agents reach for value/name/key and title/text as synonyms; honor them before inventing a slug.
if (obj.id == null || obj.id === '') obj.id = obj.value ?? obj.key ?? obj.name ?? null;
if (obj.id == null || obj.id === '') obj.id = slugFor(obj.label ?? obj.title ?? obj.text, i);
else if (typeof obj.id !== 'string') obj.id = String(obj.id);
if (typeof obj.label !== 'string' || !obj.label) obj.label = String(obj.label ?? obj.title ?? obj.text ?? obj.name ?? obj.id);
return obj;
}
return o;
});
};
if ('options' in out) out.options = fixIdLabel(out.options);
if ('actions' in out) {
if (out.actions && !Array.isArray(out.actions) && typeof out.actions === 'object' && 'label' in (out.actions as object)) out.actions = [out.actions];
out.actions = fixIdLabel(out.actions);
}
const PRIORITY_SYNONYMS: Record<string, string> = { '1': 'primary', '2': 'secondary', '3': 'tertiary', high: 'primary', medium: 'secondary', low: 'tertiary', primary: 'primary', secondary: 'secondary', tertiary: 'tertiary' };
if (Array.isArray(out.columns)) {
out.columns = out.columns.map((c) => {
if (c && typeof c === 'object' && 'priority' in (c as object)) {
const mapped = PRIORITY_SYNONYMS[String((c as Record<string, unknown>).priority).toLowerCase()];
const copy = { ...(c as Record<string, unknown>) };
if (mapped) copy.priority = mapped; else delete copy.priority;
return copy;
}
return c;
});
}
if (Array.isArray(out.data)) {
// Row arrays (instead of keyed objects) zip against the column keys, in order.
const colKeys = Array.isArray(out.columns)
? (out.columns as Array<Record<string, unknown>>).map((c, i) => String((c && typeof c === 'object' ? (c.key ?? c.id ?? c.label) : c) ?? `col${i + 1}`))
: null;
out.data = out.data.map((row) => {
if (Array.isArray(row) && colKeys && colKeys.length > 0) {
return Object.fromEntries(row.map((v, i) => [colKeys[i] ?? `col${i + 1}`, v]));
}
if (!row || typeof row !== 'object' || Array.isArray(row)) return row;
return Object.fromEntries(Object.entries(row as Record<string, unknown>).map(([k, v]) => {
if (v !== null && typeof v === 'object' && !Array.isArray(v)) return [k, JSON.stringify(v)];
if (Array.isArray(v)) return [k, v.map((x) => (x !== null && typeof x === 'object' ? JSON.stringify(x) : x))];
return [k, v];
}));
});
}
return out;
}
function parseLeniently(schema: { safeParse: (v: unknown) => any }, props: Record<string, unknown>): Gate {
let result = schema.safeParse(props);
let base: Record<string, unknown> = props;
if (!result.success) {
const issues: Array<{ code: string; keys?: string[]; path: Array<string | number>; message: string }> = result.error.issues;
if (issues.every((i) => i.code === 'unrecognized_keys')) {
const cleaned: Record<string, unknown> = { ...props };
for (const issue of issues) {
for (const key of issue.keys || []) delete cleaned[key];
}
base = cleaned;
result = schema.safeParse(cleaned);
}
}
if (!result.success) {
const repaired = schema.safeParse(repairCommonAgentShapes(base));
if (repaired.success) return { state: 'ok', parsed: repaired.data as Record<string, unknown> };
}
if (result.success) return { state: 'ok', parsed: result.data as Record<string, unknown> };
const issues = result.error.issues.slice(0, 2).map((i: { path: Array<string | number>; message: string }) => `${i.path.join('.')}: ${i.message}`).join('; ');
return { state: 'bad', problem: issues };
}
// Rough resting height per component family so the loading skeleton reserves believable space
// (Lobe/Open WebUI pattern: a breathing block where the card will land, not a tiny sliver).
function skeletonHeightFor(name: string): number {
if (/table|chart|gallery|map|carousel|post|terminal/.test(name)) return 180;
if (/stats|weather|plan|order|preferences|question/.test(name)) return 110;
return 56;
}
const SkeletonBlock: React.FC<{ name: string }> = ({ name }) => (
<div
style={{
height: skeletonHeightFor(name),
width: '100%',
borderRadius: 12,
background: 'rgba(127,127,127,0.12)',
animation: 'toolui-skeleton-pulse 1.4s ease-in-out infinite',
}}
>
<style>{'@keyframes toolui-skeleton-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }'}</style>
</div>
);
/** Validates against the upstream zod contract, then renders the vendored component inside the scoped theme. */
function VendoredToolUi({ name, props, extraProps, quietFail = false }: VendoredToolUiProps): React.ReactElement | null {
const { mode } = useThemeMode();
const entry = TOOL_UI_REGISTRY[name];
const [gate, setGate] = useState<Gate>({ state: 'pending' });
// Parents rebuild the props object every render; keying the validation on identity re-ran an async zod parse per transcript render (real typing-lag cost in table-bearing chats). Content is the real dependency.
const propsKey = useMemo(() => { try { return JSON.stringify(props); } catch { return String(Math.random()); } }, [props]);
useEffect(() => {
let cancelled = false;
if (!entry) return undefined;
entry
.loadSchema()
.then((schema) => {
if (!cancelled) setGate(parseLeniently(schema, props));
})
.catch(() => { if (!cancelled) setGate({ state: 'bad', problem: 'component failed to load' }); });
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [entry, propsKey]);
if (!entry) return null;
if (gate.state === 'bad') {
// Schema jargon is for the console, once per component+issue SHAPE for the whole session; a
// transcript full of the same agent mistake used to print 37 copies of the identical warning.
const shapeKey = `${name}:${gate.problem}`;
if (!warnedShapes.has(shapeKey)) {
warnedShapes.add(shapeKey);
console.warn(`[tool-ui] ${name} payload didn't validate:`, gate.problem);
}
if (quietFail) return null;
return (
<div style={{ fontSize: '0.75rem', opacity: 0.45, padding: '4px 0', fontStyle: 'italic' }}>
Couldn't draw the {name.replace(/-/g, ' ')} view
</div>
);
}
if (gate.state === 'pending') {
return quietFail ? null : <SkeletonBlock name={name} />;
}
const Component = entry.Component;
return (
<div className={`tool-ui-scope${mode === 'dark' ? ' dark' : ''}`}>
<ComponentGuard name={name} quiet={quietFail}>
<Suspense fallback={<SkeletonBlock name={name} />}>
<Component {...gate.parsed} {...(extraProps || {})} />
</Suspense>
</ComponentGuard>
</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 @@
# Audio
Implementation for the "audio" Tool UI surface.
## Files
- public exports: components/tool-ui/audio/index.ts
- serializable schema + parse helpers: components/tool-ui/audio/schema.ts
## Companion assets
- Docs page: app/docs/audio/content.mdx
- Preset payload: lib/presets/audio.ts
## Quick check
Run this after edits:
pnpm test
@@ -0,0 +1,8 @@
/**
* Adapter: UI and utility re-exports for copy-standalone portability.
*/
"use client";
export { cn } from "@toolui/lib/utils";
export { Button } from "@toolui/ui/button";
export { Slider } from "@toolui/ui/slider";
@@ -0,0 +1,341 @@
"use client";
import * as React from "react";
import { Pause, Play } from "lucide-react";
import { cn, Button, Slider } from "./_adapter";
import { AudioProvider, useAudio } from "./context";
import type { SerializableAudio, AudioVariant } from "./schema";
const FALLBACK_LOCALE = "en-US";
function formatTime(seconds: number): string {
if (!Number.isFinite(seconds)) return "0:00";
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
export interface AudioProps extends SerializableAudio {
variant?: AudioVariant;
className?: string;
onMediaEvent?: (type: "play" | "pause" | "mute" | "unmute") => void;
}
export function Audio(props: AudioProps) {
return (
<AudioProvider>
<AudioInner {...props} />
</AudioProvider>
);
}
interface PlayerControls {
isPlaying: boolean;
currentTime: number;
duration: number;
onPlayPause: () => void;
onSeek: (value: number[]) => void;
onSeekStart: () => void;
onSeekEnd: () => void;
}
interface FullPlayerProps {
artwork?: string;
title?: string;
description?: string;
controls: PlayerControls;
}
function FullPlayer({
artwork,
title,
description,
controls,
}: FullPlayerProps) {
return (
<div className="flex w-full flex-col">
{artwork && (
<div className="bg-muted relative aspect-[4/3] w-full overflow-hidden">
<img
src={artwork}
alt=""
aria-hidden="true"
loading="lazy"
decoding="async"
className="absolute inset-0 h-full w-full object-cover"
/>
</div>
)}
<div className="flex flex-col gap-5 p-4">
{(title || description) && (
<div className="space-y-0.5">
{title && (
<div className="text-foreground line-clamp-2 font-semibold leading-snug">
{title}
</div>
)}
{description && (
<div className="text-muted-foreground line-clamp-2 text-sm leading-snug">
{description}
</div>
)}
</div>
)}
<div className="flex items-start gap-3">
<div className="flex flex-1 flex-col gap-2">
<Slider
value={[controls.currentTime]}
max={controls.duration || 100}
step={0.1}
onValueChange={controls.onSeek}
onPointerDown={controls.onSeekStart}
onPointerUp={controls.onSeekEnd}
className="cursor-pointer [&_[data-slot=range]]:bg-foreground [&_[data-slot=thumb]]:size-3 [&_[data-slot=thumb]]:border-2 [&_[data-slot=thumb]]:border-background [&_[data-slot=thumb]]:bg-foreground"
aria-label="Audio progress"
/>
<div className="text-muted-foreground flex items-center justify-between text-xs tabular-nums">
<span>{formatTime(controls.currentTime)}</span>
<span>{formatTime(controls.duration)}</span>
</div>
</div>
<Button
variant="default"
size="icon"
onClick={controls.onPlayPause}
className="-mt-4 size-10 shrink-0 rounded-full"
aria-label={controls.isPlaying ? "Pause" : "Play"}
>
{controls.isPlaying ? (
<Pause className="size-4" fill="currentColor" />
) : (
<Play className="size-4 ml-0.5" fill="currentColor" />
)}
</Button>
</div>
</div>
</div>
);
}
interface CompactPlayerProps {
artwork?: string;
title?: string;
description?: string;
controls: PlayerControls;
}
function CompactPlayer({
artwork,
title,
description,
controls,
}: CompactPlayerProps) {
const progress =
controls.duration > 0
? (controls.currentTime / controls.duration) * 100
: 0;
return (
<div className="relative flex w-full items-center gap-3 overflow-hidden p-3">
{artwork && (
<>
<img
src={artwork}
alt=""
aria-hidden="true"
className="pointer-events-none absolute -left-1/4 top-1/2 h-[200%] w-auto -translate-y-1/2 object-cover opacity-40 blur-2xl saturate-150"
/>
<div className="from-card/60 to-card/90 pointer-events-none absolute inset-0 bg-gradient-to-r" />
</>
)}
{artwork && (
<div className="ring-background/20 relative size-12 shrink-0 overflow-hidden rounded-lg shadow-lg ring-1">
<img
src={artwork}
alt=""
aria-hidden="true"
loading="lazy"
decoding="async"
className="absolute inset-0 h-full w-full object-cover"
/>
</div>
)}
<div className="relative flex min-w-0 flex-1 flex-col justify-center">
{title && (
<div className="text-foreground truncate text-sm font-semibold leading-tight">
{title}
</div>
)}
{description && (
<div className="text-muted-foreground mt-0.5 truncate text-xs leading-tight">
{description}
</div>
)}
{controls.duration > 0 && (
<div className="mt-1 flex items-center gap-2">
<div className="bg-foreground/20 relative h-1 flex-1 overflow-hidden rounded-full">
<div
className="bg-foreground absolute inset-y-0 left-0 rounded-full transition-all duration-150"
style={{ width: `${progress}%` }}
/>
</div>
<span className="text-muted-foreground text-xs tabular-nums">
{formatTime(controls.currentTime)}
</span>
</div>
)}
</div>
<Button
variant="default"
size="icon"
onClick={controls.onPlayPause}
className="relative size-10 shrink-0 rounded-full shadow-md"
aria-label={controls.isPlaying ? "Pause" : "Play"}
>
{controls.isPlaying ? (
<Pause className="size-4" fill="currentColor" />
) : (
<Play className="size-4 ml-0.5" fill="currentColor" />
)}
</Button>
</div>
);
}
function AudioInner(props: AudioProps) {
const { variant = "full", className, onMediaEvent, ...serializable } = props;
const {
id,
src,
title,
description,
artwork,
locale: providedLocale,
} = serializable;
const locale = providedLocale ?? FALLBACK_LOCALE;
const { state, setState, setAudioElement } = useAudio();
const audioRef = React.useRef<HTMLAudioElement | null>(null);
const [currentTime, setCurrentTime] = React.useState(0);
const [duration, setDuration] = React.useState(0);
const [isSeeking, setIsSeeking] = React.useState(false);
React.useEffect(() => {
setAudioElement(audioRef.current);
return () => setAudioElement(null);
}, [setAudioElement]);
React.useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
if (state.playing && audio.paused) {
void audio.play().catch(() => undefined);
} else if (!state.playing && !audio.paused) {
audio.pause();
}
}, [state.playing]);
const handlePlayPause = () => {
const audio = audioRef.current;
if (!audio) return;
if (audio.paused) {
void audio.play().catch(() => undefined);
} else {
audio.pause();
}
};
const handleSeek = (value: number[]) => {
const audio = audioRef.current;
if (!audio) return;
const newTime = value[0];
audio.currentTime = newTime;
setCurrentTime(newTime);
};
const handleSeekStart = () => {
setIsSeeking(true);
};
const handleSeekEnd = () => {
setIsSeeking(false);
};
const controls: PlayerControls = {
isPlaying: state.playing,
currentTime,
duration,
onPlayPause: handlePlayPause,
onSeek: handleSeek,
onSeekStart: handleSeekStart,
onSeekEnd: handleSeekEnd,
};
const isCompact = variant === "compact";
return (
<article
className={cn(
"@container/actions relative w-full",
isCompact ? "min-w-72 max-w-md" : "min-w-52 max-w-sm",
className,
)}
lang={locale}
data-tool-ui-id={id}
data-slot="audio"
>
<div
className={cn(
"group @container relative isolate flex w-full min-w-0 flex-col overflow-hidden",
"border-border bg-card border text-sm shadow-xs",
"rounded-xl",
)}
>
{isCompact ? (
<CompactPlayer
artwork={artwork}
title={title}
description={description}
controls={controls}
/>
) : (
<FullPlayer
artwork={artwork}
title={title}
description={description}
controls={controls}
/>
)}
<audio
ref={audioRef}
src={src}
preload="metadata"
className="hidden"
onPlay={() => {
setState({ playing: true });
onMediaEvent?.("play");
}}
onPause={() => {
setState({ playing: false });
onMediaEvent?.("pause");
}}
onTimeUpdate={(event) => {
if (!isSeeking) {
setCurrentTime(event.currentTarget.currentTime);
}
}}
onLoadedMetadata={(event) => {
setDuration(event.currentTarget.duration);
}}
onDurationChange={(event) => {
setDuration(event.currentTarget.duration);
}}
/>
</div>
</article>
);
}
@@ -0,0 +1,53 @@
"use client";
import * as React from "react";
export interface AudioPlaybackState {
playing: boolean;
muted: boolean;
}
export interface AudioContextValue {
state: AudioPlaybackState;
setState: (patch: Partial<AudioPlaybackState>) => void;
audioElement: HTMLAudioElement | null;
setAudioElement: (node: HTMLAudioElement | null) => void;
}
const AudioContext = React.createContext<AudioContextValue | null>(null);
export function useAudio() {
const ctx = React.useContext(AudioContext);
if (!ctx) {
throw new Error("useAudio must be used within an <AudioProvider />");
}
return ctx;
}
export interface AudioProviderProps {
children: React.ReactNode;
defaultState?: Partial<AudioPlaybackState>;
}
export function AudioProvider({ children, defaultState }: AudioProviderProps) {
const [state, setStateInternal] = React.useState<AudioPlaybackState>({
playing: defaultState?.playing ?? false,
muted: defaultState?.muted ?? false,
});
const [audioElement, setAudioElement] =
React.useState<HTMLAudioElement | null>(null);
const setState = React.useCallback((patch: Partial<AudioPlaybackState>) => {
setStateInternal((prev) => ({ ...prev, ...patch }));
}, []);
const value = React.useMemo(
() => ({ state, setState, audioElement, setAudioElement }),
[state, setState, audioElement],
);
return (
<AudioContext.Provider value={value}>{children}</AudioContext.Provider>
);
}
@@ -0,0 +1,5 @@
export { Audio } from "./audio";
export type { AudioProps } from "./audio";
export { AudioProvider, useAudio } from "./context";
export type { AudioPlaybackState, AudioContextValue } from "./context";
export type { SerializableAudio, Source, AudioVariant } from "./schema";
@@ -0,0 +1,46 @@
import { z } from "zod";
import { defineToolUiContract } from "../shared/contract";
import {
ToolUIIdSchema,
ToolUIReceiptSchema,
ToolUIRoleSchema,
} from "../shared/schema";
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 SerializableAudioSchema = z.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
receipt: ToolUIReceiptSchema.optional(),
assetId: z.string(),
src: z.url(),
title: z.string().optional(),
description: z.string().optional(),
artwork: z.url().optional(),
durationMs: z.number().int().positive().optional(),
fileSizeBytes: z.number().int().positive().optional(),
createdAt: z.string().datetime().optional(),
locale: z.string().optional(),
source: SourceSchema.optional(),
});
export type SerializableAudio = z.infer<typeof SerializableAudioSchema>;
const SerializableAudioSchemaContract = defineToolUiContract(
"Audio",
SerializableAudioSchema,
);
export const parseSerializableAudio: (input: unknown) => SerializableAudio =
SerializableAudioSchemaContract.parse;
export const safeParseSerializableAudio: (
input: unknown,
) => SerializableAudio | null = SerializableAudioSchemaContract.safeParse;
export type AudioVariant = "full" | "compact";
@@ -0,0 +1,19 @@
# Chart
Implementation for the "chart" Tool UI surface.
## Files
- public exports: components/tool-ui/chart/index.tsx
- serializable schema + parse helpers: components/tool-ui/chart/schema.ts
## Companion assets
- Docs page: app/docs/chart/content.mdx
- Preset payload: lib/presets/chart.ts
## Quick check
Run this after edits:
pnpm test
@@ -0,0 +1,27 @@
/**
* 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")
* Chart → shadcn/ui Chart (recharts wrapper)
* Card → shadcn/ui Card
*/
export { cn } from "@toolui/lib/utils";
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
type ChartConfig,
} from "@toolui/ui/chart";
export {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@toolui/ui/card";
@@ -0,0 +1,180 @@
"use client";
import { useMemo, useCallback, memo } from "react";
import {
BarChart,
LineChart,
Bar,
Line,
XAxis,
YAxis,
CartesianGrid,
} from "recharts";
import {
cn,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
type ChartConfig,
} from "./_adapter";
import type { ChartProps } from "./schema";
const DEFAULT_COLORS = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
];
export const Chart = memo(function Chart({
id,
type,
title,
description,
data,
xKey,
series,
colors,
showLegend = false,
showGrid = true,
className,
onDataPointClick,
}: ChartProps) {
const palette = colors?.length ? colors : DEFAULT_COLORS;
const seriesColors = useMemo(
() =>
series.map(
(seriesItem, index) =>
seriesItem.color ?? palette[index % palette.length],
),
[series, palette],
);
const chartConfig: ChartConfig = useMemo(
() =>
Object.fromEntries(
series.map((seriesItem, index) => [
seriesItem.key,
{
label: seriesItem.label,
color: seriesColors[index],
},
]),
),
[series, seriesColors],
);
const handleDataPointClick = useCallback(
(
seriesKey: string,
seriesLabel: string,
payload: Record<string, unknown>,
index: number,
) => {
onDataPointClick?.({
seriesKey,
seriesLabel,
xValue: payload[xKey],
yValue: payload[seriesKey],
index,
payload,
});
},
[onDataPointClick, xKey],
);
const ChartComponent = type === "bar" ? BarChart : LineChart;
const chartContent = (
<ChartContainer
config={chartConfig}
className="min-h-[200px] w-full"
data-tool-ui-id={id}
>
<ChartComponent data={data} accessibilityLayer>
{showGrid && <CartesianGrid vertical={false} />}
<XAxis
dataKey={xKey}
tickLine={false}
tickMargin={10}
axisLine={false}
/>
<YAxis tickLine={false} axisLine={false} tickMargin={10} />
<ChartTooltip content={<ChartTooltipContent />} />
{showLegend && <ChartLegend content={<ChartLegendContent />} />}
{type === "bar" &&
series.map((s, i) => (
<Bar
key={s.key}
dataKey={s.key}
fill={seriesColors[i]}
radius={4}
onClick={(data) =>
handleDataPointClick(s.key, s.label, data.payload, data.index)
}
cursor={onDataPointClick ? "pointer" : undefined}
/>
))}
{type === "line" &&
series.map((s, i) => (
<Line
key={s.key}
dataKey={s.key}
type="monotone"
stroke={seriesColors[i]}
strokeWidth={2}
dot={{ r: 4, cursor: onDataPointClick ? "pointer" : undefined }}
activeDot={{
r: 6,
cursor: onDataPointClick ? "pointer" : undefined,
// Recharts types are incorrect - onClick receives (event, dotData) at runtime
onClick: ((
_: unknown,
dotData: { payload: Record<string, unknown>; index: number },
) => {
handleDataPointClick(
s.key,
s.label,
dotData.payload,
dotData.index,
);
}) as unknown as React.MouseEventHandler,
}}
/>
))}
</ChartComponent>
</ChartContainer>
);
return (
<Card
className={cn("w-full min-w-80", className)}
data-tool-ui-id={id}
data-slot="chart"
>
{(title || description) && (
<CardHeader>
{title && <CardTitle className="text-pretty">{title}</CardTitle>}
{description && (
<CardDescription className="text-pretty">
{description}
</CardDescription>
)}
</CardHeader>
)}
<CardContent>{chartContent}</CardContent>
</Card>
);
});
@@ -0,0 +1,8 @@
export { Chart } from "./chart";
export {
type ChartProps,
type ChartSeries,
type ChartDataPoint,
type ChartClientProps,
type SerializableChart,
} from "./schema";
@@ -0,0 +1,121 @@
import { z } from "zod";
import { defineToolUiContract } from "../shared/contract";
import {
ToolUIIdSchema,
ToolUIReceiptSchema,
ToolUIRoleSchema,
} from "../shared/schema";
export const ChartSeriesSchema = z.object({
key: z.string().min(1),
label: z.string().min(1),
color: z.string().optional(),
});
export type ChartSeries = z.infer<typeof ChartSeriesSchema>;
export const ChartPropsSchema = z
.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
receipt: ToolUIReceiptSchema.optional(),
type: z.enum(["bar", "line"]),
title: z.string().optional(),
description: z.string().optional(),
data: z.array(z.record(z.string(), z.unknown())).min(1),
xKey: z.string().min(1),
series: z.array(ChartSeriesSchema).min(1),
/** Color palette applied to series in order. Individual series.color takes precedence. */
colors: z.array(z.string().min(1)).min(1).optional(),
showLegend: z.boolean().optional(),
showGrid: z.boolean().optional(),
})
.superRefine((value, ctx) => {
const seenSeriesKeys = new Set<string>();
value.series.forEach((series, index) => {
if (seenSeriesKeys.has(series.key)) {
ctx.addIssue({
code: "custom",
path: ["series", index, "key"],
message: `Duplicate series key "${series.key}".`,
});
return;
}
seenSeriesKeys.add(series.key);
});
value.data.forEach((row, rowIndex) => {
if (!(value.xKey in row)) {
ctx.addIssue({
code: "custom",
path: ["data", rowIndex, value.xKey],
message: `Missing xKey "${value.xKey}" in data row.`,
});
} else {
const xVal = row[value.xKey];
const isValidX = typeof xVal === "string" || typeof xVal === "number";
if (!isValidX) {
ctx.addIssue({
code: "custom",
path: ["data", rowIndex, value.xKey],
message: `Expected "${value.xKey}" to be a string or number.`,
});
}
}
value.series.forEach((series) => {
if (!(series.key in row)) {
ctx.addIssue({
code: "custom",
path: ["data", rowIndex, series.key],
message: `Missing series key "${series.key}" in data row.`,
});
return;
}
const yVal = row[series.key];
if (yVal === null) {
return;
}
if (typeof yVal !== "number" || !Number.isFinite(yVal)) {
ctx.addIssue({
code: "custom",
path: ["data", rowIndex, series.key],
message: `Expected "${series.key}" to be a finite number (or null).`,
});
}
});
});
});
export type ChartDataPoint = {
seriesKey: string;
seriesLabel: string;
xValue: unknown;
yValue: unknown;
index: number;
payload: Record<string, unknown>;
};
export type ChartClientProps = {
className?: string;
onDataPointClick?: (point: ChartDataPoint) => void;
};
export type ChartProps = z.infer<typeof ChartPropsSchema> & ChartClientProps;
export const SerializableChartSchema = ChartPropsSchema;
export type SerializableChart = z.infer<typeof SerializableChartSchema>;
const SerializableChartSchemaContract = defineToolUiContract(
"Chart",
SerializableChartSchema,
);
export const parseSerializableChart: (input: unknown) => SerializableChart =
SerializableChartSchemaContract.parse;
export const safeParseSerializableChart: (
input: unknown,
) => SerializableChart | null = SerializableChartSchemaContract.safeParse;
@@ -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 @@
# Code Block
Implementation for the "code-block" Tool UI surface.
## Files
- public exports: components/tool-ui/code-block/index.tsx
- serializable schema + parse helpers: components/tool-ui/code-block/schema.ts
## Companion assets
- Docs page: app/docs/code-block/content.mdx
- Preset payload: lib/presets/code-block.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
* Collapsible → shadcn/ui Collapsible
*/
export { cn } from "@toolui/lib/utils";
export { Button } from "@toolui/ui/button";
export { Collapsible, CollapsibleTrigger } from "@toolui/ui/collapsible";
@@ -0,0 +1,469 @@
"use client";
import {
useState,
useCallback,
useEffect,
createContext,
useContext,
type ReactNode,
} from "react";
import {
createHighlighter,
createJavaScriptRegexEngine,
type Highlighter,
} from "shiki";
import { Copy, Check, ChevronDown, ChevronUp } from "lucide-react";
import pierreDarkTheme from "../shared/pierre-dark-theme.js";
import pierreLightTheme from "../shared/pierre-light-theme.js";
import type { CodeBlockLineNumbersMode, CodeBlockProps } from "./schema";
import { useCopyToClipboard } from "../shared/use-copy-to-clipboard";
import { Button, cn, Collapsible, CollapsibleTrigger } from "./_adapter";
const COPY_ID = "codeblock-code";
const MAX_HTML_CACHE_ENTRIES = 64;
let highlighterPromise: Promise<Highlighter> | null = null;
function getHighlighter(): Promise<Highlighter> {
let pending = highlighterPromise;
if (!pending) {
pending = createHighlighter({
themes: [pierreDarkTheme as never, pierreLightTheme as never],
langs: [],
engine: createJavaScriptRegexEngine(),
});
highlighterPromise = pending;
}
return pending;
}
const htmlCache = new Map<string, string>();
function getCacheKey(
code: string,
language: string,
theme: string,
lineNumbers: CodeBlockLineNumbersMode,
highlightLines?: number[],
): string {
return JSON.stringify({
code,
language,
theme,
lineNumbers,
highlightLines: highlightLines ?? null,
});
}
function setCachedHtml(cacheKey: string, html: string): void {
if (htmlCache.has(cacheKey)) {
htmlCache.set(cacheKey, html);
return;
}
if (htmlCache.size >= MAX_HTML_CACHE_ENTRIES) {
const oldestKey = htmlCache.keys().next().value;
if (typeof oldestKey === "string") {
htmlCache.delete(oldestKey);
}
}
htmlCache.set(cacheKey, html);
}
const LANGUAGE_DISPLAY_NAMES: Record<string, string> = {
typescript: "TypeScript",
javascript: "JavaScript",
python: "Python",
tsx: "TSX",
jsx: "JSX",
json: "JSON",
bash: "Bash",
shell: "Shell",
css: "CSS",
html: "HTML",
markdown: "Markdown",
sql: "SQL",
yaml: "YAML",
go: "Go",
rust: "Rust",
text: "Plain Text",
};
function getLanguageDisplayName(lang: string): string {
return LANGUAGE_DISPLAY_NAMES[lang.toLowerCase()] || lang.toUpperCase();
}
function getSystemTheme(): "light" | "dark" {
if (typeof window === "undefined") return "light";
return window.matchMedia?.("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
function getDocumentTheme(): "light" | "dark" | null {
if (typeof document === "undefined") return null;
const root = document.documentElement;
const dataTheme = root.getAttribute("data-theme")?.toLowerCase();
if (dataTheme === "dark") return "dark";
if (dataTheme === "light") return "light";
if (root.classList.contains("dark")) return "dark";
if (root.classList.contains("light")) return "light";
return null;
}
function useResolvedTheme(): "light" | "dark" {
const [theme, setTheme] = useState<"light" | "dark">(() => {
return getDocumentTheme() ?? getSystemTheme();
});
useEffect(() => {
if (typeof window === "undefined" || typeof document === "undefined") {
return;
}
const update = () => setTheme(getDocumentTheme() ?? getSystemTheme());
const mql = window.matchMedia?.("(prefers-color-scheme: dark)");
mql?.addEventListener("change", update);
const observer = new MutationObserver(update);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class", "data-theme"],
});
return () => {
mql?.removeEventListener("change", update);
observer.disconnect();
};
}, []);
return theme;
}
export type CodeBlockRootProps = CodeBlockProps & {
children: ReactNode;
expanded?: boolean;
defaultExpanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
};
type CodeBlockSharedState = {
id: string;
code: string;
language: string;
filename?: string;
highlightedHtml: string | null;
isCopied: boolean;
copyCode: () => void;
lineCount: number;
isCollapsed: boolean;
shouldCollapse: boolean;
toggleExpanded: () => void;
};
const CodeBlockContext = createContext<CodeBlockSharedState | null>(null);
function useCodeBlock(): CodeBlockSharedState {
const context = useContext(CodeBlockContext);
if (!context) {
throw new Error(
"CodeBlock subcomponents must be used within <CodeBlock.Root>.",
);
}
return context;
}
function CodeBlockRoot({
id,
code,
language = "text",
lineNumbers = "visible",
filename,
highlightLines,
maxCollapsedLines,
className,
children,
expanded: expandedProp,
defaultExpanded = false,
onExpandedChange,
}: CodeBlockRootProps) {
const resolvedTheme = useResolvedTheme();
const [expandedState, setExpandedState] = useState(defaultExpanded);
const { copiedId, copy } = useCopyToClipboard();
const isCopied = copiedId === COPY_ID;
const expanded = expandedProp ?? expandedState;
const setExpanded = useCallback(
(nextExpanded: boolean) => {
if (expandedProp === undefined) {
setExpandedState(nextExpanded);
}
onExpandedChange?.(nextExpanded);
},
[expandedProp, onExpandedChange],
);
const theme = resolvedTheme === "dark" ? "pierre-dark" : "pierre-light";
const cacheKey = getCacheKey(
code,
language,
theme,
lineNumbers,
highlightLines,
);
const [highlightedHtml, setHighlightedHtml] = useState<string | null>(
() => htmlCache.get(cacheKey) ?? null,
);
useEffect(() => {
const cached = htmlCache.get(cacheKey);
if (cached) {
setHighlightedHtml(cached);
return;
}
let cancelled = false;
const showLineNumbers = lineNumbers === "visible";
async function highlight() {
if (!code) {
if (!cancelled) setHighlightedHtml("");
return;
}
try {
const highlighter = await getHighlighter();
const loadedLangs = highlighter.getLoadedLanguages();
if (!loadedLangs.includes(language)) {
await highlighter.loadLanguage(
language as Parameters<Highlighter["loadLanguage"]>[0],
);
}
const lineCount = code.split("\n").length;
const lineNumberWidth = `${String(lineCount).length + 0.5}ch`;
const html = highlighter.codeToHtml(code, {
lang: language,
theme,
transformers: [
{
line(node: any, line: number) {
node.properties["data-line"] = line;
if (highlightLines?.includes(line)) {
const highlightBg =
resolvedTheme === "dark"
? "rgba(255,255,255,0.1)"
: "rgba(0,0,0,0.05)";
node.properties.style = `background:${highlightBg};`;
}
if (showLineNumbers) {
node.children.unshift({
type: "element",
tagName: "span",
properties: {
style: `display:inline-block;width:${lineNumberWidth};text-align:right;margin-right:1.5em;user-select:none;opacity:0.5;`,
"aria-hidden": "true",
},
children: [{ type: "text", value: String(line) }],
});
}
},
},
],
});
if (!cancelled) {
setCachedHtml(cacheKey, html);
setHighlightedHtml(html);
}
} catch {
const escaped = code
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
if (!cancelled) {
setHighlightedHtml(`<pre><code>${escaped}</code></pre>`);
}
}
}
void highlight();
return () => {
cancelled = true;
};
}, [
cacheKey,
code,
language,
lineNumbers,
theme,
highlightLines,
resolvedTheme,
]);
const lineCount = code.split("\n").length;
const shouldCollapse = !!maxCollapsedLines && lineCount > maxCollapsedLines;
const isCollapsed = shouldCollapse && !expanded;
const copyCode = useCallback(() => {
void copy(code, COPY_ID);
}, [code, copy]);
const toggleExpanded = useCallback(() => {
setExpanded(!expanded);
}, [expanded, setExpanded]);
const state: CodeBlockSharedState = {
id,
code,
language,
filename,
highlightedHtml,
isCopied,
copyCode,
lineCount,
shouldCollapse,
isCollapsed,
toggleExpanded,
};
return (
<CodeBlockContext.Provider value={state}>
<div
className={cn(
"@container flex w-full min-w-80 flex-col gap-3",
className,
)}
data-tool-ui-id={id}
data-slot="code-block"
>
<div className="border-border bg-card overflow-hidden rounded-lg border shadow-xs">
<Collapsible open={!isCollapsed}>{children}</Collapsible>
</div>
</div>
</CodeBlockContext.Provider>
);
}
export type CodeBlockSectionProps = {
className?: string;
};
function CodeBlockHeader({ className }: CodeBlockSectionProps) {
const { language, filename, isCopied, copyCode } = useCodeBlock();
return (
<div
className={cn(
"bg-card flex items-center justify-between border-b px-4 py-2",
className,
)}
>
<div className="flex items-center gap-1">
<span className="text-muted-foreground text-sm">
{getLanguageDisplayName(language)}
</span>
{filename && (
<>
<span className="text-muted-foreground/50"></span>
<span className="text-foreground text-sm font-medium">
{filename}
</span>
</>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={copyCode}
className="h-7 w-7 p-0"
aria-label={isCopied ? "Copied" : "Copy code"}
>
{isCopied ? (
<Check className="h-4 w-4 text-green-700 dark:text-green-400" />
) : (
<Copy className="text-muted-foreground h-4 w-4" />
)}
</Button>
</div>
);
}
function CodeBlockContent({ className }: CodeBlockSectionProps) {
const { highlightedHtml, isCollapsed } = useCodeBlock();
return (
<div
className={cn(
"overflow-x-auto overflow-y-clip text-[13px] leading-[1.4] [&_pre]:bg-transparent [&_pre]:py-4",
isCollapsed && "max-h-[200px]",
className,
)}
>
{highlightedHtml && (
<div dangerouslySetInnerHTML={{ __html: highlightedHtml }} />
)}
</div>
);
}
function CodeBlockCollapseToggle({ className }: CodeBlockSectionProps) {
const { shouldCollapse, isCollapsed, toggleExpanded, lineCount } =
useCodeBlock();
if (!shouldCollapse) return null;
return (
<CollapsibleTrigger asChild>
<Button
variant="ghost"
onClick={toggleExpanded}
className={cn(
"text-muted-foreground w-full rounded-none border-t font-normal",
className,
)}
>
{isCollapsed ? (
<>
<ChevronDown className="mr-1 size-4" />
Show all {lineCount} lines
</>
) : (
<>
<ChevronUp className="mr-2 h-4 w-4" />
Collapse
</>
)}
</Button>
</CollapsibleTrigger>
);
}
export type CodeBlockComposedProps = Omit<CodeBlockRootProps, "children">;
function CodeBlockComposed(props: CodeBlockComposedProps) {
return (
<CodeBlockRoot {...props}>
<CodeBlockHeader />
<CodeBlockContent />
<CodeBlockCollapseToggle />
</CodeBlockRoot>
);
}
type CodeBlockComponent = typeof CodeBlockComposed & {
Root: typeof CodeBlockRoot;
Header: typeof CodeBlockHeader;
Content: typeof CodeBlockContent;
CollapseToggle: typeof CodeBlockCollapseToggle;
};
export const CodeBlock = Object.assign(CodeBlockComposed, {
Root: CodeBlockRoot,
Header: CodeBlockHeader,
Content: CodeBlockContent,
CollapseToggle: CodeBlockCollapseToggle,
}) as CodeBlockComponent;
@@ -0,0 +1,11 @@
export { CodeBlock } from "./code-block";
export type {
CodeBlockRootProps,
CodeBlockComposedProps,
CodeBlockSectionProps,
} from "./code-block";
export type {
CodeBlockProps,
CodeBlockLineNumbersMode,
SerializableCodeBlock,
} from "./schema";
@@ -0,0 +1,43 @@
import { z } from "zod";
import { defineToolUiContract } from "../shared/contract";
import {
ToolUIIdSchema,
ToolUIReceiptSchema,
ToolUIRoleSchema,
} from "../shared/schema";
export const CodeBlockPropsSchema = z.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
receipt: ToolUIReceiptSchema.optional(),
code: z.string(),
language: z.string().trim().min(1).default("text"),
lineNumbers: z.enum(["visible", "hidden"]).default("visible"),
filename: z.string().optional(),
highlightLines: z.array(z.number().int().positive()).optional(),
maxCollapsedLines: z.number().min(1).optional(),
className: z.string().optional(),
});
export type CodeBlockProps = z.infer<typeof CodeBlockPropsSchema>;
export type CodeBlockLineNumbersMode = CodeBlockProps["lineNumbers"];
export const SerializableCodeBlockSchema = CodeBlockPropsSchema.omit({
className: true,
});
export type SerializableCodeBlock = z.infer<typeof SerializableCodeBlockSchema>;
const SerializableCodeBlockSchemaContract = defineToolUiContract(
"CodeBlock",
SerializableCodeBlockSchema,
);
export const parseSerializableCodeBlock: (
input: unknown,
) => SerializableCodeBlock = SerializableCodeBlockSchemaContract.parse;
export const safeParseSerializableCodeBlock: (
input: unknown,
) => SerializableCodeBlock | null =
SerializableCodeBlockSchemaContract.safeParse;
@@ -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
* Collapsible -> shadcn/ui Collapsible
*/
export { cn } from "@toolui/lib/utils";
export { Button } from "@toolui/ui/button";
export { Collapsible, CollapsibleTrigger } from "@toolui/ui/collapsible";
@@ -0,0 +1,463 @@
"use client";
import {
useState,
useCallback,
useEffect,
useMemo,
createContext,
useContext,
type ReactNode,
} from "react";
import {
FileDiff as PierreFileDiff,
PatchDiff as PierrePatchDiff,
} from "@pierre/diffs/react";
import { parseDiffFromFile, RegisteredCustomThemes } from "@pierre/diffs";
import type { FileDiffMetadata, ThemesType } from "@pierre/diffs";
import { Copy, Check, ChevronDown, ChevronUp } from "lucide-react";
import type { CodeDiffProps } from "./schema";
import { useCopyToClipboard } from "../shared/use-copy-to-clipboard";
import { Button, cn, Collapsible, CollapsibleTrigger } from "./_adapter";
/*
* Pierre's shared_highlighter registers custom themes with dynamic imports
* (`import("../themes/pierre-dark.js")`) that fail under Turbopack because the
* package `exports` field doesn't include those subpaths. We override the
* RegisteredCustomThemes map entries with loaders that point to local vendored
* theme files in `components/tool-ui/shared`, which Turbopack can resolve.
*/
RegisteredCustomThemes.set("pierre-dark", () =>
import("../shared/pierre-dark-theme.js").then((m) => m.default as never),
);
RegisteredCustomThemes.set("pierre-light", () =>
import("../shared/pierre-light-theme.js").then((m) => m.default as never),
);
const COPY_ID = "codediff-code";
/* ── Theme detection (mirrors CodeBlock) ────────────────────────── */
function getSystemTheme(): "light" | "dark" {
if (typeof window === "undefined") return "light";
return window.matchMedia?.("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
function getDocumentTheme(): "light" | "dark" | null {
if (typeof document === "undefined") return null;
const root = document.documentElement;
const dataTheme = root.getAttribute("data-theme")?.toLowerCase();
if (dataTheme === "dark") return "dark";
if (dataTheme === "light") return "light";
if (root.classList.contains("dark")) return "dark";
if (root.classList.contains("light")) return "light";
return null;
}
function useResolvedTheme(): "light" | "dark" {
const [theme, setTheme] = useState<"light" | "dark">(() => {
return getDocumentTheme() ?? getSystemTheme();
});
useEffect(() => {
if (typeof window === "undefined" || typeof document === "undefined") {
return;
}
const update = () => setTheme(getDocumentTheme() ?? getSystemTheme());
const mql = window.matchMedia?.("(prefers-color-scheme: dark)");
mql?.addEventListener("change", update);
const observer = new MutationObserver(update);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class", "data-theme"],
});
return () => {
mql?.removeEventListener("change", update);
observer.disconnect();
};
}, []);
return theme;
}
/* ── Language display names (mirrors CodeBlock) ─────────────────── */
const LANGUAGE_DISPLAY_NAMES: Record<string, string> = {
typescript: "TypeScript",
javascript: "JavaScript",
python: "Python",
tsx: "TSX",
jsx: "JSX",
json: "JSON",
bash: "Bash",
shell: "Shell",
css: "CSS",
html: "HTML",
markdown: "Markdown",
sql: "SQL",
yaml: "YAML",
go: "Go",
rust: "Rust",
text: "Plain Text",
};
function getLanguageDisplayName(lang: string): string {
return LANGUAGE_DISPLAY_NAMES[lang.toLowerCase()] || lang.toUpperCase();
}
/* ── Shared context ─────────────────────────────────────────────── */
type CodeDiffSharedState = {
id: string;
isPatchMode: boolean;
language: string;
lineNumbers: "visible" | "hidden";
filename?: string;
diffStyle: "unified" | "split";
copyableCode: string;
isCopied: boolean;
copyCode: () => void;
isCollapsed: boolean;
shouldCollapse: boolean;
toggleExpanded: () => void;
resolvedTheme: "light" | "dark";
pierreThemes: ThemesType;
fileDiffMetadata: FileDiffMetadata | null;
patch: string | null;
additions: number;
deletions: number;
};
const CodeDiffContext = createContext<CodeDiffSharedState | null>(null);
function useCodeDiff(): CodeDiffSharedState {
const context = useContext(CodeDiffContext);
if (!context) {
throw new Error(
"CodeDiff subcomponents must be used within <CodeDiff.Root>.",
);
}
return context;
}
/* ── Subcomponents ──────────────────────────────────────────────── */
export type CodeDiffRootProps = CodeDiffProps & {
children: ReactNode;
expanded?: boolean;
defaultExpanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
};
function CodeDiffRoot({
id,
oldCode,
newCode,
patch,
language = "text",
filename,
lineNumbers = "visible",
diffStyle = "unified",
maxCollapsedLines,
className,
children,
expanded: expandedProp,
defaultExpanded = false,
onExpandedChange,
}: CodeDiffRootProps) {
const resolvedTheme = useResolvedTheme();
const [expandedState, setExpandedState] = useState(defaultExpanded);
const { copiedId, copy } = useCopyToClipboard();
const isCopied = copiedId === COPY_ID;
const expanded = expandedProp ?? expandedState;
const setExpanded = useCallback(
(nextExpanded: boolean) => {
if (expandedProp === undefined) {
setExpandedState(nextExpanded);
}
onExpandedChange?.(nextExpanded);
},
[expandedProp, onExpandedChange],
);
const pierreThemes: ThemesType = {
dark: "pierre-dark",
light: "pierre-light",
};
// Auto-detect mode: if `patch` is provided, use patch mode; otherwise files mode
const isPatchMode = !!patch;
const fileDiffMetadata = useMemo(() => {
if (isPatchMode) return null;
return parseDiffFromFile(
{
name: filename ?? "file",
contents: oldCode ?? "",
lang: language as never,
},
{
name: filename ?? "file",
contents: newCode ?? "",
lang: language as never,
},
);
}, [isPatchMode, oldCode, newCode, filename, language]);
const copyableCode = isPatchMode ? (patch ?? "") : (newCode ?? oldCode ?? "");
const lineCount = useMemo(() => {
if (isPatchMode) {
return (patch ?? "").split("\n").length;
}
if (fileDiffMetadata) {
return fileDiffMetadata.unifiedLineCount;
}
return 0;
}, [isPatchMode, patch, fileDiffMetadata]);
const { additions, deletions } = useMemo(() => {
if (!isPatchMode && fileDiffMetadata) {
let add = 0;
let del = 0;
for (const hunk of fileDiffMetadata.hunks) {
add += hunk.additionLines;
del += hunk.deletionLines;
}
return { additions: add, deletions: del };
}
if (isPatchMode && patch) {
let add = 0;
let del = 0;
for (const line of patch.split("\n")) {
if (line.startsWith("+") && !line.startsWith("+++ ")) add++;
else if (line.startsWith("-") && !line.startsWith("--- ")) del++;
}
return { additions: add, deletions: del };
}
return { additions: 0, deletions: 0 };
}, [isPatchMode, fileDiffMetadata, patch]);
const shouldCollapse = !!maxCollapsedLines && lineCount > maxCollapsedLines;
const isCollapsed = shouldCollapse && !expanded;
const copyCode = useCallback(() => {
void copy(copyableCode, COPY_ID);
}, [copyableCode, copy]);
const toggleExpanded = useCallback(() => {
setExpanded(!expanded);
}, [expanded, setExpanded]);
const state: CodeDiffSharedState = {
id,
isPatchMode,
language,
lineNumbers,
filename,
diffStyle,
copyableCode,
isCopied,
copyCode,
isCollapsed,
shouldCollapse,
toggleExpanded,
resolvedTheme,
pierreThemes,
fileDiffMetadata,
patch: isPatchMode ? (patch ?? null) : null,
additions,
deletions,
};
return (
<CodeDiffContext.Provider value={state}>
<div
className={cn(
"@container flex w-full min-w-80 flex-col gap-3",
className,
)}
data-tool-ui-id={id}
data-slot="code-diff"
>
<div className="border-border bg-card overflow-hidden rounded-lg border shadow-xs">
<Collapsible open={!isCollapsed}>{children}</Collapsible>
</div>
</div>
</CodeDiffContext.Provider>
);
}
export type CodeDiffSectionProps = {
className?: string;
};
function CodeDiffHeader({ className }: CodeDiffSectionProps) {
const { language, filename, isCopied, copyCode, additions, deletions } =
useCodeDiff();
const hasChanges = additions > 0 || deletions > 0;
return (
<div
className={cn(
"bg-card flex items-center justify-between gap-2 border-b px-4 py-2",
className,
)}
>
<div className="flex items-center gap-1">
<span className="text-muted-foreground text-sm">
{getLanguageDisplayName(language)}
</span>
{filename && (
<>
<span className="text-muted-foreground/50">&bull;</span>
<span className="text-foreground text-sm font-medium">
{filename}
</span>
</>
)}
</div>
{hasChanges && (
<span className="ml-auto text-xs font-mono tabular-nums">
{additions > 0 && (
<span style={{ color: "#00cab1" }}>+{additions}</span>
)}
{additions > 0 && deletions > 0 && " "}
{deletions > 0 && (
<span style={{ color: "#ff2e3f" }}>-{deletions}</span>
)}
</span>
)}
<Button
variant="ghost"
size="sm"
onClick={copyCode}
className="h-7 w-7 p-0"
aria-label={isCopied ? "Copied" : "Copy code"}
>
{isCopied ? (
<Check className="h-4 w-4 text-green-700 dark:text-green-400" />
) : (
<Copy className="text-muted-foreground h-4 w-4" />
)}
</Button>
</div>
);
}
function CodeDiffContent({ className }: CodeDiffSectionProps) {
const {
isPatchMode,
diffStyle,
lineNumbers,
isCollapsed,
resolvedTheme,
pierreThemes,
fileDiffMetadata,
patch,
} = useCodeDiff();
const disableLineNumbers = lineNumbers === "hidden";
return (
<div
className={cn(
"overflow-x-auto overflow-y-clip text-sm",
isCollapsed && "max-h-[200px]",
className,
)}
>
{!isPatchMode && fileDiffMetadata && (
<PierreFileDiff
fileDiff={fileDiffMetadata}
options={{
theme: pierreThemes,
themeType: resolvedTheme,
diffStyle,
disableFileHeader: true,
disableLineNumbers,
}}
/>
)}
{isPatchMode && patch && (
<PierrePatchDiff
patch={patch}
options={{
theme: pierreThemes,
themeType: resolvedTheme,
diffStyle,
disableFileHeader: true,
disableLineNumbers,
}}
/>
)}
</div>
);
}
function CodeDiffCollapseToggle({ className }: CodeDiffSectionProps) {
const { shouldCollapse, isCollapsed, toggleExpanded } = useCodeDiff();
if (!shouldCollapse) return null;
return (
<CollapsibleTrigger asChild>
<Button
variant="ghost"
onClick={toggleExpanded}
className={cn(
"text-muted-foreground w-full rounded-none border-t font-normal",
className,
)}
>
{isCollapsed ? (
<>
<ChevronDown className="mr-1 size-4" />
Show full diff
</>
) : (
<>
<ChevronUp className="mr-2 h-4 w-4" />
Collapse
</>
)}
</Button>
</CollapsibleTrigger>
);
}
/* ── Composed preset (callable as a flat component) ─────────────── */
export type CodeDiffComposedProps = Omit<CodeDiffRootProps, "children">;
function CodeDiffComposed(props: CodeDiffComposedProps) {
return (
<CodeDiffRoot {...props}>
<CodeDiffHeader />
<CodeDiffContent />
<CodeDiffCollapseToggle />
</CodeDiffRoot>
);
}
/* ── Compound export: CodeDiff is callable AND has subcomponents ── */
type CodeDiffComponent = typeof CodeDiffComposed & {
Root: typeof CodeDiffRoot;
Header: typeof CodeDiffHeader;
Content: typeof CodeDiffContent;
CollapseToggle: typeof CodeDiffCollapseToggle;
};
export const CodeDiff = Object.assign(CodeDiffComposed, {
Root: CodeDiffRoot,
Header: CodeDiffHeader,
Content: CodeDiffContent,
CollapseToggle: CodeDiffCollapseToggle,
}) as CodeDiffComponent;
@@ -0,0 +1,7 @@
export { CodeDiff } from "./code-diff";
export type {
CodeDiffRootProps,
CodeDiffComposedProps,
CodeDiffSectionProps,
} from "./code-diff";
export type { CodeDiffProps, SerializableCodeDiff } from "./schema";
@@ -0,0 +1,71 @@
import { z } from "zod";
import { defineToolUiContract } from "../shared/contract";
import {
ToolUIIdSchema,
ToolUIReceiptSchema,
ToolUIRoleSchema,
} from "../shared/schema";
const CodeDiffPropsSchemaBase = z.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
receipt: ToolUIReceiptSchema.optional(),
oldCode: z.string().optional(),
newCode: z.string().optional(),
patch: z.string().optional(),
language: z.string().trim().min(1).default("text"),
filename: z.string().optional(),
lineNumbers: z.enum(["visible", "hidden"]).default("visible"),
diffStyle: z.enum(["unified", "split"]).default("unified"),
maxCollapsedLines: z.number().min(1).optional(),
className: z.string().optional(),
});
function validateCodeDiffInputMode(
data: { patch?: string; oldCode?: string; newCode?: string },
ctx: z.RefinementCtx,
) {
const hasPatch = !!data.patch;
const hasFiles = !!data.oldCode || !!data.newCode;
if (!hasPatch && !hasFiles) {
ctx.addIssue({
code: "custom",
message:
"Provide either a patch string or at least one of oldCode/newCode",
});
}
if (hasPatch && hasFiles) {
ctx.addIssue({
code: "custom",
message:
"Cannot mix patch mode with oldCode/newCode — use one or the other",
});
}
}
export const CodeDiffPropsSchema = CodeDiffPropsSchemaBase.superRefine(
validateCodeDiffInputMode,
);
export type CodeDiffProps = z.infer<typeof CodeDiffPropsSchema>;
export const SerializableCodeDiffSchema = CodeDiffPropsSchemaBase.omit({
className: true,
}).superRefine(validateCodeDiffInputMode);
export type SerializableCodeDiff = z.infer<typeof SerializableCodeDiffSchema>;
const SerializableCodeDiffSchemaContract = defineToolUiContract(
"CodeDiff",
SerializableCodeDiffSchema,
);
export const parseSerializableCodeDiff: (
input: unknown,
) => SerializableCodeDiff = SerializableCodeDiffSchemaContract.parse;
export const safeParseSerializableCodeDiff: (
input: unknown,
) => SerializableCodeDiff | null = SerializableCodeDiffSchemaContract.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,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,358 @@
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(),
}),
]);
// Models routinely send columns as bare strings or as {key}-only / {label}-only objects; rejecting those threw away perfectly renderable tables, so coerce instead of failing.
export const serializableColumnSchema = z.preprocess(
(raw) => {
if (typeof raw === "string") return { key: raw, label: raw };
if (raw && typeof raw === "object") {
const o = raw as Record<string, unknown>;
const key = typeof o.key === "string" ? o.key : typeof o.label === "string" ? o.label : undefined;
const label = typeof o.label === "string" ? o.label : typeof o.key === "string" ? o.key : undefined;
if (key !== undefined || label !== undefined) return { ...o, key, label };
}
return raw;
},
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,265 @@
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;
colWidths?: Record<string, number>;
setColWidth?: (key: string, px: number) => void;
moveColumn?: (fromKey: string, toKey: string) => void;
}
@@ -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,24 @@
# Geo Map
Implementation for the "geo-map" Tool UI surface.
## Files
- public exports: components/tool-ui/geo-map/index.tsx
- serializable schema + parse helpers: components/tool-ui/geo-map/schema.ts
- public facade component: components/tool-ui/geo-map/geo-map.tsx
- internal Leaflet engine: components/tool-ui/geo-map/geo-map-engine.tsx
- colocated Leaflet shell theme styles: components/tool-ui/geo-map/geo-map-theme.module.css
- icon construction helpers: components/tool-ui/geo-map/geo-map-icons.ts
- popup/tooltip overlay renderer: components/tool-ui/geo-map/geo-map-overlays.tsx
## Companion assets
- Docs page: app/docs/geo-map/content.mdx
- Preset payload: lib/presets/geo-map.ts
## Quick check
Run this after edits:
pnpm test
@@ -0,0 +1,23 @@
/**
* 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")
* Leaflet → map primitives from react-leaflet
*/
export { cn } from "@toolui/lib/utils";
export {
CircleMarker,
MapContainer,
Marker,
Polyline,
Popup,
TileLayer,
Tooltip,
ZoomControl,
useMap,
useMapEvents,
} from "react-leaflet";
@@ -0,0 +1,756 @@
"use client";
import type { Map as LeafletMap } from "leaflet";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import Supercluster from "supercluster";
import {
CircleMarker,
MapContainer,
Marker,
Polyline,
TileLayer,
ZoomControl,
useMap,
useMapEvents,
} from "./_adapter";
import { createClusterIcon, resolveMarkerIcon } from "./geo-map-icons";
import { GeoMapOverlays } from "./geo-map-overlays";
import type {
GeoMapClustering,
GeoMapFitTarget,
GeoMapMarker,
GeoMapRoute,
GeoMapViewport,
} from "./schema";
const TILE_ATTRIBUTION =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>';
const ROUTE_DEFAULT_COLOR = "var(--primary)";
const ROUTE_DEFAULT_WEIGHT = 3;
const ROUTE_DEFAULT_OPACITY = 0.85;
const EMPTY_ROUTES: GeoMapRoute[] = [];
const CLUSTER_RADIUS_DEFAULT = 60;
const CLUSTER_MAX_ZOOM_DEFAULT = 16;
const CLUSTER_MIN_POINTS_DEFAULT = 2;
const DEFAULT_CENTER: [number, number] = [20, 0];
export const DEFAULT_VIEW_ZOOM = 2;
const SINGLE_LOCATION_ZOOM = 13;
const DEFAULT_VIEWPORT_PADDING = 32;
type LeafletRuntime = Pick<
typeof import("leaflet"),
"divIcon" | "latLngBounds"
>;
export type GeoMapBbox = [
west: number,
south: number,
east: number,
north: number,
];
export type GeoMapLatLng = [lat: number, lng: number];
export type GeoMapClusterProperties = {
cluster?: boolean;
cluster_id?: number;
point_count?: number;
markerId?: string;
};
export type GeoMapClusterFeature = GeoJSON.Feature<
GeoJSON.Point,
GeoMapClusterProperties
>;
type MarkerClusterPointProperties = GeoMapClusterProperties & {
markerId?: string;
marker?: GeoMapMarker;
};
type MapViewportState = {
bbox: GeoMapBbox;
zoom: number;
};
function roundCoordinate(value: number): number {
return Math.round(value * 1_000_000) / 1_000_000;
}
function normalizeViewportState(state: MapViewportState): MapViewportState {
return {
bbox: [
roundCoordinate(state.bbox[0]),
roundCoordinate(state.bbox[1]),
roundCoordinate(state.bbox[2]),
roundCoordinate(state.bbox[3]),
],
zoom: state.zoom,
};
}
function areViewportStatesEqual(
a: MapViewportState | null,
b: MapViewportState,
): boolean {
if (!a) {
return false;
}
return (
a.zoom === b.zoom &&
a.bbox[0] === b.bbox[0] &&
a.bbox[1] === b.bbox[1] &&
a.bbox[2] === b.bbox[2] &&
a.bbox[3] === b.bbox[3]
);
}
function serializeFitPoints(points: [number, number][]): string {
return points
.map(([lat, lng]) => `${roundCoordinate(lat)},${roundCoordinate(lng)}`)
.join("|");
}
function readViewportState(map: LeafletMap): MapViewportState {
const bounds = map.getBounds();
return normalizeViewportState({
bbox: [
bounds.getWest(),
bounds.getSouth(),
bounds.getEast(),
bounds.getNorth(),
],
zoom: Math.round(map.getZoom()),
});
}
export function collectFitPoints(
markers: GeoMapMarker[],
routes: GeoMapRoute[],
target: GeoMapFitTarget,
): GeoMapLatLng[] {
const markerPoints =
target === "markers" || target === "all"
? markers.map((marker) => [marker.lat, marker.lng] as GeoMapLatLng)
: [];
const routePoints =
target === "routes" || target === "all"
? routes.flatMap((route) =>
route.points.map((point) => [point.lat, point.lng] as GeoMapLatLng),
)
: [];
return [...markerPoints, ...routePoints];
}
export function resolveFitPointsWithFallback(
markers: GeoMapMarker[],
routes: GeoMapRoute[],
target: GeoMapFitTarget,
): GeoMapLatLng[] {
const selected = collectFitPoints(markers, routes, target);
if (selected.length > 0) {
return selected;
}
if (target !== "markers") {
return collectFitPoints(markers, routes, "markers");
}
return [];
}
export function splitDatelineBbox(bbox: GeoMapBbox): GeoMapBbox[] {
const [west, south, east, north] = bbox;
if (west <= east) {
return [bbox];
}
return [
[west, south, 180, north],
[-180, south, east, north],
];
}
function getClusterFeatureKey(feature: GeoMapClusterFeature): string {
const properties = feature.properties ?? {};
if (properties.cluster && typeof properties.cluster_id === "number") {
return `cluster:${properties.cluster_id}`;
}
if (
typeof properties.markerId === "string" &&
properties.markerId.length > 0
) {
return `marker:${properties.markerId}`;
}
if (feature.id !== undefined && feature.id !== null) {
return `id:${String(feature.id)}`;
}
const [lng, lat] = feature.geometry.coordinates;
return `point:${lat}:${lng}`;
}
function dedupeClusterFeatures(
features: GeoMapClusterFeature[],
): GeoMapClusterFeature[] {
const seen = new Set<string>();
const deduped: GeoMapClusterFeature[] = [];
features.forEach((feature) => {
const key = getClusterFeatureKey(feature);
if (seen.has(key)) {
return;
}
seen.add(key);
deduped.push(feature);
});
return deduped;
}
export function getClustersForDatelineAwareBbox(
bbox: GeoMapBbox,
zoom: number,
getClustersForBbox: (
candidateBbox: GeoMapBbox,
zoom: number,
) => GeoMapClusterFeature[],
): GeoMapClusterFeature[] {
const queried = splitDatelineBbox(bbox).flatMap((candidateBbox) =>
getClustersForBbox(candidateBbox, zoom),
);
return dedupeClusterFeatures(queried);
}
export function toSafeExpansionZoom(
zoom: number,
options?: { minZoom?: number; maxZoom?: number; fallback?: number },
): number {
const minZoom = options?.minZoom ?? 1;
const maxZoom = options?.maxZoom ?? 22;
const fallback = options?.fallback ?? 2;
if (!Number.isFinite(zoom)) {
return fallback;
}
return Math.min(maxZoom, Math.max(minZoom, Math.round(zoom)));
}
function resolveInitialView(
markers: GeoMapMarker[],
routes: GeoMapRoute[],
viewport: GeoMapViewport | undefined,
): { center: [number, number]; zoom: number } {
if (viewport?.mode === "center") {
return {
center: [viewport.center.lat, viewport.center.lng],
zoom: viewport.zoom,
};
}
const fitTarget = viewport?.target ?? "all";
const fitPoints = resolveFitPointsWithFallback(markers, routes, fitTarget);
if (fitPoints.length === 1) {
return {
center: [fitPoints[0][0], fitPoints[0][1]],
zoom: viewport?.maxZoom
? Math.min(SINGLE_LOCATION_ZOOM, viewport.maxZoom)
: SINGLE_LOCATION_ZOOM,
};
}
return { center: DEFAULT_CENTER, zoom: DEFAULT_VIEW_ZOOM };
}
function ViewportController({
markers,
routes,
viewport,
leafletRuntime,
}: {
markers: GeoMapMarker[];
routes: GeoMapRoute[];
viewport: GeoMapViewport | undefined;
leafletRuntime: LeafletRuntime;
}) {
const map = useMap();
const lastAppliedViewportRef = useRef<string | null>(null);
useEffect(() => {
lastAppliedViewportRef.current = null;
}, [map]);
useEffect(() => {
if (viewport?.mode === "center") {
const viewportKey = `center:${roundCoordinate(viewport.center.lat)}:${roundCoordinate(viewport.center.lng)}:${viewport.zoom}`;
if (lastAppliedViewportRef.current === viewportKey) {
return;
}
lastAppliedViewportRef.current = viewportKey;
map.setView([viewport.center.lat, viewport.center.lng], viewport.zoom);
return;
}
const fitTarget = viewport?.target ?? "all";
const fitPoints = resolveFitPointsWithFallback(markers, routes, fitTarget);
if (fitPoints.length === 0) {
return;
}
const maxZoom = viewport?.maxZoom;
if (fitPoints.length === 1) {
const [lat, lng] = fitPoints[0];
const zoom = maxZoom
? Math.min(SINGLE_LOCATION_ZOOM, maxZoom)
: SINGLE_LOCATION_ZOOM;
const viewportKey = `fit-single:${roundCoordinate(lat)}:${roundCoordinate(lng)}:${zoom}`;
if (lastAppliedViewportRef.current === viewportKey) {
return;
}
lastAppliedViewportRef.current = viewportKey;
map.setView([lat, lng], zoom);
return;
}
const padding = viewport?.padding ?? DEFAULT_VIEWPORT_PADDING;
const viewportKey = `fit:${fitTarget}:${padding}:${maxZoom ?? "none"}:${serializeFitPoints(fitPoints)}`;
if (lastAppliedViewportRef.current === viewportKey) {
return;
}
lastAppliedViewportRef.current = viewportKey;
const bounds = leafletRuntime.latLngBounds(fitPoints);
map.fitBounds(bounds, {
maxZoom,
padding: [padding, padding],
});
}, [leafletRuntime, map, markers, routes, viewport]);
return null;
}
function MapObserver({
onViewportChange,
onMapReady,
}: {
onViewportChange: (state: MapViewportState) => void;
onMapReady: (map: LeafletMap) => void;
}) {
const map = useMapEvents({
moveend: () => {
onViewportChange(readViewportState(map));
},
zoomend: () => {
onViewportChange(readViewportState(map));
},
});
useEffect(() => {
onMapReady(map);
onViewportChange(readViewportState(map));
}, [map, onMapReady, onViewportChange]);
return null;
}
function resolveMarkerAriaLabel(marker: GeoMapMarker): string {
if (marker.label && marker.description) {
return `${marker.label}. ${marker.description}`;
}
return (
marker.label ??
marker.description ??
`Marker at ${marker.lat.toFixed(4)}, ${marker.lng.toFixed(4)}`
);
}
export const GeoMapEngine = memo(function GeoMapEngine({
id,
markers,
routes,
clustering,
viewport,
showZoomControl,
tileUrl,
mapAriaLabel,
tooltipClassName,
popupClassName,
onMarkerClick,
onRouteClick,
onReadyChange,
}: {
id: string;
markers: GeoMapMarker[];
routes?: GeoMapRoute[];
clustering?: GeoMapClustering;
viewport?: GeoMapViewport;
showZoomControl: boolean;
tileUrl: string;
mapAriaLabel: string;
tooltipClassName?: string;
popupClassName?: string;
onMarkerClick?: (marker: GeoMapMarker) => void;
onRouteClick?: (route: GeoMapRoute) => void;
onReadyChange?: (isReady: boolean) => void;
}) {
const resolvedRoutes = routes ?? EMPTY_ROUTES;
const [leafletRuntime, setLeafletRuntime] = useState<LeafletRuntime | null>(
null,
);
const [mapInstance, setMapInstance] = useState<LeafletMap | null>(null);
const [viewportState, setViewportState] = useState<MapViewportState | null>(
null,
);
const handleViewportChange = useCallback((nextState: MapViewportState) => {
const normalized = normalizeViewportState(nextState);
setViewportState((previousState) =>
areViewportStatesEqual(previousState, normalized)
? previousState
: normalized,
);
}, []);
useEffect(() => {
let isActive = true;
void import("leaflet").then((module) => {
if (!isActive) {
return;
}
setLeafletRuntime({
divIcon: module.divIcon,
latLngBounds: module.latLngBounds,
});
});
return () => {
isActive = false;
};
}, []);
const isReady = leafletRuntime !== null;
useEffect(() => {
onReadyChange?.(isReady);
}, [isReady, onReadyChange]);
useEffect(() => {
if (!mapInstance) {
return;
}
const container = mapInstance.getContainer();
container.setAttribute("role", "region");
container.setAttribute("aria-label", mapAriaLabel);
}, [mapAriaLabel, mapInstance]);
useEffect(() => {
if (!mapInstance) {
return;
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
mapInstance.closePopup();
}
};
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("keydown", handleEscape);
};
}, [mapInstance]);
const initialView = useMemo(
() => resolveInitialView(markers, resolvedRoutes, viewport),
[markers, resolvedRoutes, viewport],
);
const markerById = useMemo(() => {
const map = new Map<string, GeoMapMarker>();
markers.forEach((marker, index) => {
map.set(marker.id ?? `marker-${index}`, marker);
});
return map;
}, [markers]);
const clusterConfig = useMemo(
() => ({
enabled: clustering?.enabled === true,
radius: clustering?.radius ?? CLUSTER_RADIUS_DEFAULT,
maxZoom: clustering?.maxZoom ?? CLUSTER_MAX_ZOOM_DEFAULT,
minPoints: clustering?.minPoints ?? CLUSTER_MIN_POINTS_DEFAULT,
}),
[clustering],
);
const clusterIndex = useMemo(() => {
if (!clusterConfig.enabled) {
return null;
}
const index = new Supercluster<MarkerClusterPointProperties>({
radius: clusterConfig.radius,
maxZoom: clusterConfig.maxZoom,
minPoints: clusterConfig.minPoints,
});
const points = markers.map((marker, index) => {
const markerId = marker.id ?? `marker-${index}`;
return {
type: "Feature" as const,
id: markerId,
geometry: {
type: "Point" as const,
coordinates: [marker.lng, marker.lat] as [number, number],
},
properties: {
markerId,
marker,
},
};
});
index.load(points);
return index;
}, [
clusterConfig.enabled,
clusterConfig.maxZoom,
clusterConfig.minPoints,
clusterConfig.radius,
markers,
]);
const clusteredFeatures = useMemo(() => {
if (!clusterConfig.enabled || !clusterIndex || !viewportState) {
return [] as GeoMapClusterFeature[];
}
return getClustersForDatelineAwareBbox(
viewportState.bbox,
viewportState.zoom,
(bbox, zoom) =>
clusterIndex.getClusters(bbox, zoom) as GeoMapClusterFeature[],
);
}, [clusterConfig.enabled, clusterIndex, viewportState]);
const renderMarker = useCallback(
(
marker: GeoMapMarker,
markerKey: string,
markerPositionOverride?: [number, number],
) => {
const markerPosition: [number, number] = markerPositionOverride ?? [
marker.lat,
marker.lng,
];
const tooltipMode = marker.tooltip ?? "hover";
const tooltipContent = marker.label ?? marker.description;
const icon = marker.icon;
const markerAriaLabel = resolveMarkerAriaLabel(marker);
if (!leafletRuntime) {
return null;
}
const leafletIcon = resolveMarkerIcon(icon, leafletRuntime);
if (leafletIcon) {
return (
<Marker
key={markerKey}
position={markerPosition}
icon={leafletIcon}
title={markerAriaLabel}
alt={markerAriaLabel}
eventHandlers={{
click: () => onMarkerClick?.(marker),
}}
>
<GeoMapOverlays
tooltipMode={tooltipMode}
tooltipContent={tooltipContent}
label={marker.label}
description={marker.description}
tooltipClassName={tooltipClassName}
popupClassName={popupClassName}
/>
</Marker>
);
}
const markerStroke =
icon?.type === "dot"
? (icon.borderColor ?? "var(--border)")
: "var(--border)";
const markerFill =
icon?.type === "dot"
? (icon.color ?? "var(--primary)")
: "var(--primary)";
const markerRadius = icon?.type === "dot" ? (icon.radius ?? 7) : 7;
return (
<CircleMarker
key={markerKey}
center={markerPosition}
radius={markerRadius}
pathOptions={{
color: markerStroke,
fillColor: markerFill,
fillOpacity: 0.95,
weight: 2,
}}
eventHandlers={{
click: () => onMarkerClick?.(marker),
}}
>
<GeoMapOverlays
tooltipMode={tooltipMode}
tooltipContent={tooltipContent}
label={marker.label}
description={marker.description}
tooltipClassName={tooltipClassName}
popupClassName={popupClassName}
/>
</CircleMarker>
);
},
[leafletRuntime, onMarkerClick, popupClassName, tooltipClassName],
);
if (!leafletRuntime) {
return null;
}
return (
<MapContainer
center={initialView.center}
zoom={initialView.zoom}
zoomControl={false}
className="h-full w-full"
scrollWheelZoom
>
<TileLayer attribution={TILE_ATTRIBUTION} url={tileUrl} />
{showZoomControl && <ZoomControl position="topright" />}
<MapObserver
onMapReady={setMapInstance}
onViewportChange={handleViewportChange}
/>
<ViewportController
leafletRuntime={leafletRuntime}
markers={markers}
routes={resolvedRoutes}
viewport={viewport}
/>
{resolvedRoutes.map((route, routeIndex) => {
const routeKey = route.id ?? `${id}-route-${routeIndex}`;
const positions = route.points.map((point) => [
point.lat,
point.lng,
]) as [number, number][];
const tooltipMode = route.tooltip ?? "hover";
const tooltipContent = route.label ?? route.description;
return (
<Polyline
key={routeKey}
positions={positions}
pathOptions={{
color: route.color ?? ROUTE_DEFAULT_COLOR,
weight: route.weight ?? ROUTE_DEFAULT_WEIGHT,
opacity: route.opacity ?? ROUTE_DEFAULT_OPACITY,
dashArray: route.dashArray,
}}
eventHandlers={{
click: () => onRouteClick?.(route),
}}
>
<GeoMapOverlays
tooltipMode={tooltipMode}
tooltipContent={tooltipContent}
label={route.label}
description={route.description}
tooltipClassName={tooltipClassName}
popupClassName={popupClassName}
/>
</Polyline>
);
})}
{clusterConfig.enabled && clusterIndex && viewportState
? clusteredFeatures.map((feature, index) => {
const [lng, lat] = feature.geometry.coordinates;
const properties = (feature.properties ??
{}) as MarkerClusterPointProperties;
if (
properties.cluster &&
typeof properties.cluster_id === "number"
) {
const pointCount = properties.point_count ?? 0;
const clusterId = properties.cluster_id;
const clusterIcon = createClusterIcon(pointCount, leafletRuntime);
const clusterAriaLabel = `Cluster containing ${pointCount} locations`;
return (
<Marker
key={`cluster-${clusterId}`}
position={[lat, lng]}
icon={clusterIcon}
title={clusterAriaLabel}
alt={clusterAriaLabel}
eventHandlers={{
click: () => {
if (!mapInstance) {
return;
}
const expansionZoom = toSafeExpansionZoom(
clusterIndex.getClusterExpansionZoom(clusterId),
{
maxZoom: 22,
fallback:
(viewportState.zoom ?? DEFAULT_VIEW_ZOOM) + 2,
},
);
mapInstance.flyTo([lat, lng], expansionZoom);
},
}}
/>
);
}
const marker =
properties.marker ??
markerById.get(properties.markerId ?? `marker-${index}`);
if (!marker) {
return null;
}
const markerKey =
marker.id ?? properties.markerId ?? `${id}-cluster-leaf-${index}`;
return renderMarker(marker, markerKey, [lat, lng]);
})
: markers.map((marker, index) =>
renderMarker(marker, marker.id ?? `${id}-marker-${index}`),
)}
</MapContainer>
);
});
@@ -0,0 +1,131 @@
import type { DivIcon } from "leaflet";
import type { GeoMapMarker } from "./schema";
type LeafletIconRuntime = Pick<typeof import("leaflet"), "divIcon">;
function isSafeHttpUrl(value: string | undefined): boolean {
if (!value) {
return false;
}
try {
const parsed = new URL(value);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function createEmojiIcon(
icon: Extract<NonNullable<GeoMapMarker["icon"]>, { type: "emoji" }>,
leafletRuntime: LeafletIconRuntime,
): DivIcon {
const size = icon.size ?? 24;
const background = icon.bgColor ?? "var(--card)";
const border = icon.borderColor ?? "var(--border)";
return leafletRuntime.divIcon({
className: "",
html: `<span style="
display:flex;
align-items:center;
justify-content:center;
width:${size}px;
height:${size}px;
border-radius:999px;
background:${background};
border:1px solid ${border};
font-size:${Math.round(size * 0.62)}px;
line-height:1;
box-shadow:0 1px 3px oklch(from var(--foreground) l c h / 0.22);
">${escapeHtml(icon.value)}</span>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
popupAnchor: [0, -Math.round(size / 2)],
tooltipAnchor: [0, -Math.round(size / 2)],
});
}
function createImageIcon(
icon: Extract<NonNullable<GeoMapMarker["icon"]>, { type: "image" }>,
leafletRuntime: LeafletIconRuntime,
): DivIcon {
const width = icon.width ?? 28;
const height = icon.height ?? 28;
const borderRadius = icon.borderRadius ?? Math.min(width, height) / 2;
const border = icon.borderColor ?? "var(--border)";
return leafletRuntime.divIcon({
className: "",
html: `<span style="
display:block;
width:${width}px;
height:${height}px;
border-radius:${borderRadius}px;
overflow:hidden;
border:1px solid ${border};
background:var(--card);
box-shadow:0 1px 3px oklch(from var(--foreground) l c h / 0.22);
"><img src="${escapeHtml(icon.url)}" alt="" style="width:100%;height:100%;object-fit:cover;display:block;" /></span>`,
iconSize: [width, height],
iconAnchor: [width / 2, height / 2],
popupAnchor: [0, -Math.round(height / 2)],
tooltipAnchor: [0, -Math.round(height / 2)],
});
}
export function createClusterIcon(
count: number,
leafletRuntime: LeafletIconRuntime,
): DivIcon {
const size = count >= 100 ? 42 : count >= 10 ? 38 : 34;
const background = "var(--primary)";
const border = "var(--background)";
return leafletRuntime.divIcon({
className: "",
html: `<span style="
display:flex;
align-items:center;
justify-content:center;
width:${size}px;
height:${size}px;
border-radius:999px;
background:${background};
border:2px solid ${border};
color:var(--primary-foreground);
font-size:12px;
font-weight:700;
line-height:1;
box-shadow:0 2px 6px oklch(from var(--foreground) l c h / 0.25);
">${count}</span>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
popupAnchor: [0, -Math.round(size / 2)],
tooltipAnchor: [0, -Math.round(size / 2)],
});
}
export function resolveMarkerIcon(
icon: GeoMapMarker["icon"] | undefined,
leafletRuntime: LeafletIconRuntime,
): DivIcon | null {
if (icon?.type === "emoji") {
return createEmojiIcon(icon, leafletRuntime);
}
if (icon?.type === "image" && isSafeHttpUrl(icon.url)) {
return createImageIcon(icon, leafletRuntime);
}
return null;
}
@@ -0,0 +1,86 @@
"use client";
import { useMemo, useState } from "react";
import { Popup, Tooltip, cn } from "./_adapter";
function GeoMapPopupContent({
label,
description,
}: {
label?: string;
description?: string;
}) {
return (
<div className="flex flex-col gap-0.5">
{label && (
<p className="block text-sm leading-tight font-semibold tracking-tight text-foreground">
{label}
</p>
)}
{description && (
<p className="block text-xs leading-relaxed text-muted-foreground">
{description}
</p>
)}
</div>
);
}
function GeoMapTooltipContent({ text }: { text: string }) {
return <span className="block">{text}</span>;
}
export function GeoMapOverlays({
tooltipMode,
tooltipContent,
label,
description,
tooltipClassName,
popupClassName,
}: {
tooltipMode: "none" | "hover" | "always";
tooltipContent?: string;
label?: string;
description?: string;
tooltipClassName?: string;
popupClassName?: string;
}) {
const hasPopup = Boolean(label || description);
const [isPopupOpen, setIsPopupOpen] = useState(false);
const shouldRenderTooltip =
tooltipMode !== "none" && tooltipContent && (!hasPopup || !isPopupOpen);
const popupEventHandlers = useMemo(
() => ({
add: () => setIsPopupOpen(true),
remove: () => setIsPopupOpen(false),
}),
[],
);
return (
<>
{shouldRenderTooltip && (
<Tooltip
direction="top"
permanent={tooltipMode === "always"}
className={cn("geo-map-tooltip", tooltipClassName)}
>
<GeoMapTooltipContent text={tooltipContent} />
</Tooltip>
)}
{hasPopup && (
<Popup
className={cn("geo-map-popup", popupClassName)}
closeButton
closeOnEscapeKey
minWidth={0}
maxWidth={288}
eventHandlers={popupEventHandlers}
>
<GeoMapPopupContent label={label} description={description} />
</Popup>
)}
</>
);
}
@@ -0,0 +1,216 @@
.root[data-slot="geo-map"] {
--geo-map-canvas-bg: var(--muted);
--geo-map-tooltip-bg: var(--foreground);
--geo-map-tooltip-fg: var(--background);
--geo-map-tooltip-shadow: 0 8px 20px
oklch(from var(--foreground) l c h / 0.18);
--geo-map-tooltip-radius: calc(var(--radius) - 2px);
--geo-map-tooltip-padding: 0.375rem 0.625rem;
--geo-map-tooltip-font-size: 0.75rem;
--geo-map-tooltip-font-weight: 500;
--geo-map-tooltip-line-height: 1.2;
--geo-map-popup-margin-bottom: 12px;
--geo-map-popup-border: var(--border);
--geo-map-popup-radius: calc(var(--radius) + 2px);
--geo-map-popup-bg: oklch(from var(--popover) l c h / 0.96);
--geo-map-popup-fg: var(--popover-foreground);
--geo-map-popup-shadow: 0 10px 30px oklch(from var(--foreground) l c h / 0.12);
--geo-map-popup-blur: 8px;
--geo-map-popup-content-padding: 0.625rem 0.75rem;
--geo-map-popup-max-width: min(80vw, 18rem);
--geo-map-popup-font-family: var(
--font-sans,
ui-sans-serif,
system-ui,
sans-serif
);
--geo-map-zoom-bg: oklch(from var(--background) l c h / 0.78);
--geo-map-zoom-fg: var(--foreground);
--geo-map-zoom-border: var(--border);
--geo-map-zoom-hover-bg: oklch(from var(--accent) l c h / 0.82);
--geo-map-zoom-hover-fg: var(--accent-foreground);
--geo-map-zoom-disabled-bg: oklch(from var(--muted) l c h / 0.72);
--geo-map-zoom-disabled-fg: var(--muted-foreground);
--geo-map-zoom-shadow: 0 1px 2px oklch(from var(--foreground) l c h / 0.08);
--geo-map-zoom-focus-ring: var(--ring);
--geo-map-zoom-radius: 0.5rem;
--geo-map-zoom-size: 2.25rem;
--geo-map-zoom-font-size: 1.125rem;
}
.root[data-slot="geo-map"] :global(.leaflet-container) {
background: var(--geo-map-canvas-bg);
}
.root[data-slot="geo-map"] :global(.leaflet-control-zoom) {
border: 1px solid var(--geo-map-zoom-border);
box-shadow: var(--geo-map-zoom-shadow);
background: var(--geo-map-zoom-bg);
backdrop-filter: blur(var(--geo-map-popup-blur));
-webkit-backdrop-filter: blur(var(--geo-map-popup-blur));
}
.root[data-slot="geo-map"] :global(.leaflet-control-zoom.leaflet-bar) {
border-radius: var(--geo-map-zoom-radius) !important;
}
.root[data-slot="geo-map"] :global(.leaflet-control-zoom a) {
display: flex;
align-items: center;
justify-content: center;
width: var(--geo-map-zoom-size);
height: var(--geo-map-zoom-size);
line-height: 1;
text-indent: 0;
border: 0;
background: transparent;
color: var(--geo-map-zoom-fg);
font-size: var(--geo-map-zoom-font-size);
font-weight: 500;
box-shadow: none;
cursor: default;
transition:
background-color 150ms ease,
color 150ms ease,
border-color 150ms ease,
box-shadow 150ms ease,
opacity 150ms ease;
border-radius: 0 !important;
}
.root[data-slot="geo-map"] :global(.leaflet-control-zoom a + a) {
border-top: 1px solid var(--geo-map-zoom-border);
}
.root[data-slot="geo-map"] :global(.leaflet-control-zoom a:first-child),
.root[data-slot="geo-map"]
:global(.leaflet-touch .leaflet-control-zoom a:first-child),
.root[data-slot="geo-map"]
:global(.leaflet-control-zoom .leaflet-control-zoom-in) {
border-radius: var(--geo-map-zoom-radius) var(--geo-map-zoom-radius) 0 0 !important;
}
.root[data-slot="geo-map"] :global(.leaflet-control-zoom a:last-child),
.root[data-slot="geo-map"]
:global(.leaflet-touch .leaflet-control-zoom a:last-child),
.root[data-slot="geo-map"]
:global(.leaflet-control-zoom .leaflet-control-zoom-out) {
border-top: 0;
border-radius: 0 0 var(--geo-map-zoom-radius) var(--geo-map-zoom-radius) !important;
}
.root[data-slot="geo-map"] :global(.leaflet-control-zoom a:hover) {
background: var(--geo-map-zoom-hover-bg);
color: var(--geo-map-zoom-hover-fg);
}
.root[data-slot="geo-map"] :global(.leaflet-control-zoom a:focus),
.root[data-slot="geo-map"] :global(.leaflet-control-zoom a:focus-visible) {
position: relative;
z-index: 1;
outline: 2px solid var(--geo-map-zoom-focus-ring);
outline-offset: 1px;
}
.root[data-slot="geo-map"] :global(.leaflet-control-zoom a.leaflet-disabled),
.root[data-slot="geo-map"]
:global(.leaflet-control-zoom a.leaflet-disabled:hover) {
background: var(--geo-map-zoom-disabled-bg);
color: var(--geo-map-zoom-disabled-fg);
opacity: 0.55;
}
.root[data-slot="geo-map"] :global(.leaflet-tooltip.geo-map-tooltip) {
border: 0;
border-radius: var(--geo-map-tooltip-radius);
background: var(--geo-map-tooltip-bg);
color: var(--geo-map-tooltip-fg);
box-shadow: var(--geo-map-tooltip-shadow);
font-size: var(--geo-map-tooltip-font-size);
font-weight: var(--geo-map-tooltip-font-weight);
line-height: var(--geo-map-tooltip-line-height);
padding: var(--geo-map-tooltip-padding);
}
.root[data-slot="geo-map"]
:global(.leaflet-tooltip-top.geo-map-tooltip::before) {
border-top-color: var(--geo-map-tooltip-bg);
}
.root[data-slot="geo-map"]
:global(.leaflet-tooltip-bottom.geo-map-tooltip::before) {
border-bottom-color: var(--geo-map-tooltip-bg);
}
.root[data-slot="geo-map"]
:global(.leaflet-tooltip-left.geo-map-tooltip::before) {
border-left-color: var(--geo-map-tooltip-bg);
}
.root[data-slot="geo-map"]
:global(.leaflet-tooltip-right.geo-map-tooltip::before) {
border-right-color: var(--geo-map-tooltip-bg);
}
.root[data-slot="geo-map"] :global(.leaflet-popup.geo-map-popup) {
margin-bottom: var(--geo-map-popup-margin-bottom);
}
.root[data-slot="geo-map"]
:global(.leaflet-popup.geo-map-popup .leaflet-popup-content-wrapper) {
border: 1px solid var(--geo-map-popup-border);
border-radius: var(--geo-map-popup-radius);
background: var(--geo-map-popup-bg);
color: var(--geo-map-popup-fg);
box-shadow: var(--geo-map-popup-shadow);
backdrop-filter: blur(var(--geo-map-popup-blur));
-webkit-backdrop-filter: blur(var(--geo-map-popup-blur));
padding: 0;
}
.root[data-slot="geo-map"]
:global(.leaflet-popup.geo-map-popup .leaflet-popup-content) {
margin: 0;
min-width: 0;
width: max-content;
max-width: var(--geo-map-popup-max-width);
padding: var(--geo-map-popup-content-padding);
font-family: var(--geo-map-popup-font-family);
}
.root[data-slot="geo-map"]
:global(.leaflet-popup.geo-map-popup .leaflet-popup-content p) {
margin: 0;
}
.root[data-slot="geo-map"]
:global(.leaflet-popup.geo-map-popup .leaflet-popup-tip-container) {
display: none;
}
.root[data-slot="geo-map"]
:global(.leaflet-popup.geo-map-popup .leaflet-popup-close-button) {
color: var(--geo-map-popup-fg);
opacity: 0.75;
top: 0.25rem;
right: 0.25rem;
width: 1.5rem;
height: 1.5rem;
font-size: 1rem;
line-height: 1.5rem;
border-radius: calc(var(--radius) - 2px);
}
.root[data-slot="geo-map"]
:global(.leaflet-popup.geo-map-popup .leaflet-popup-close-button:hover) {
opacity: 1;
background: oklch(from var(--muted) l c h / 0.65);
}
.root[data-slot="geo-map"]
:global(
.leaflet-popup.geo-map-popup .leaflet-popup-close-button:focus-visible
) {
outline: 2px solid var(--ring);
outline-offset: 1px;
}
@@ -0,0 +1,162 @@
"use client";
import { memo, useEffect, useState } from "react";
import { cn } from "./_adapter";
import { GeoMapEngine } from "./geo-map-engine";
import styles from "./geo-map-theme.module.css";
import type { GeoMapProps, GeoMapStyle } from "./schema";
const LIGHT_TILE_URL =
"https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png";
const DARK_TILE_URL =
"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png";
function getSystemTheme(): "light" | "dark" {
if (typeof window === "undefined") return "light";
return window.matchMedia?.("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
function getDocumentTheme(): "light" | "dark" | null {
if (typeof document === "undefined") return null;
const root = document.documentElement;
const dataTheme = root.getAttribute("data-theme")?.toLowerCase();
if (dataTheme === "dark") return "dark";
if (dataTheme === "light") return "light";
if (root.classList.contains("dark")) return "dark";
if (root.classList.contains("light")) return "light";
return null;
}
function useInheritedTheme(): "light" | "dark" {
const [theme, setTheme] = useState<"light" | "dark">(() => {
return getDocumentTheme() ?? getSystemTheme();
});
useEffect(() => {
if (typeof window === "undefined" || typeof document === "undefined") {
return;
}
const update = () => setTheme(getDocumentTheme() ?? getSystemTheme());
const mql = window.matchMedia?.("(prefers-color-scheme: dark)");
mql?.addEventListener("change", update);
const observer = new MutationObserver(update);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class", "data-theme"],
});
return () => {
mql?.removeEventListener("change", update);
observer.disconnect();
};
}, []);
return theme;
}
function resolveMapAriaLabel(title?: string, description?: string): string {
if (title && description) {
return `${title}. ${description}`;
}
return title ?? description ?? "Geographic map";
}
export const GeoMap = memo(function GeoMap({
id,
role: _role,
receipt: _receipt,
title,
description,
markers,
routes,
clustering,
viewport,
showZoomControl = true,
theme,
className,
style,
tooltipClassName,
popupClassName,
onMarkerClick,
onRouteClick,
}: GeoMapProps) {
const inheritedTheme = useInheritedTheme();
const resolvedTheme = theme ?? inheritedTheme;
const [isMapReady, setIsMapReady] = useState(false);
const tileUrl = resolvedTheme === "dark" ? DARK_TILE_URL : LIGHT_TILE_URL;
const mapAriaLabel = resolveMapAriaLabel(title, description);
const resolvedRootStyle: GeoMapStyle = {
"--geo-map-canvas-bg":
resolvedTheme === "dark" ? "var(--background)" : "var(--muted)",
...style,
};
return (
<div
className={cn("w-full min-w-80", styles.root, className)}
style={resolvedRootStyle}
data-slot="geo-map"
data-tool-ui-id={id}
>
<div
className="bg-muted/20 relative h-[320px] w-full overflow-hidden rounded-lg border"
role="region"
aria-label={mapAriaLabel}
>
<GeoMapEngine
id={id}
markers={markers}
routes={routes}
clustering={clustering}
viewport={viewport}
showZoomControl={showZoomControl}
tileUrl={tileUrl}
mapAriaLabel={mapAriaLabel}
tooltipClassName={tooltipClassName}
popupClassName={popupClassName}
onMarkerClick={onMarkerClick}
onRouteClick={onRouteClick}
onReadyChange={setIsMapReady}
/>
{(title || description) && (
<div
className={cn(
"pointer-events-none absolute top-3 left-3 z-[900]",
"max-w-[min(75%,22rem)] rounded-lg border border-border/70 bg-background/70 px-3 py-2",
"shadow-sm backdrop-blur-md",
)}
>
{title && (
<p className="text-foreground text-sm leading-tight font-semibold">
{title}
</p>
)}
{description && (
<p className="text-muted-foreground mt-1 text-xs leading-snug">
{description}
</p>
)}
</div>
)}
{!isMapReady && (
<div
data-slot="geo-map-loading"
className="bg-muted/30 text-muted-foreground pointer-events-none absolute inset-0 flex items-center justify-center"
>
<span data-slot="geo-map-loading-label">Loading map...</span>
</div>
)}
</div>
</div>
);
});
@@ -0,0 +1,16 @@
import "leaflet/dist/leaflet.css";
import "./leaflet-overrides.css";
export { GeoMap } from "./geo-map";
export {
type GeoMapClustering,
type GeoMapFitTarget,
type GeoMapMarker,
type GeoMapMarkerIcon,
type GeoMapStyle,
type GeoMapRoute,
type GeoMapViewport,
type GeoMapProps,
type GeoMapClientProps,
type SerializableGeoMap,
} from "./schema";
@@ -0,0 +1,37 @@
/* Leaflet overrides for theme integration */
.leaflet-container {
background: var(--muted);
/* Isolate Leaflet's high z-indices to prevent them from escaping the container */
isolation: isolate;
}
[data-theme="dark"] .leaflet-container {
background: hsl(240 0 10%);
}
.leaflet-control-attribution {
background: oklch(from var(--background) l c h / 0.8) !important;
color: var(--muted-foreground);
}
[data-theme="dark"] .leaflet-control-attribution {
background: hsl(240 10% 3.9% / 0.8) !important;
color: hsl(240 5% 64.9%);
}
.leaflet-control-attribution a {
color: var(--muted-foreground);
}
[data-theme="dark"] .leaflet-control-attribution a {
color: hsl(240 5% 64.9%);
}
.leaflet-control-attribution a:hover {
color: var(--foreground);
}
[data-theme="dark"] .leaflet-control-attribution a:hover {
color: hsl(0 0% 98%);
}
@@ -0,0 +1,198 @@
import { z } from "zod";
import type { CSSProperties } from "react";
import { defineToolUiContract } from "../shared/contract";
import {
ToolUIIdSchema,
ToolUIReceiptSchema,
ToolUIRoleSchema,
} from "../shared/schema";
const LatitudeSchema = z.number().finite().min(-90).max(90);
const LongitudeSchema = z.number().finite().min(-180).max(180);
const HttpUrlSchema = z
.string()
.url()
.refine((value) => /^https?:\/\//i.test(value), {
message: "Expected an http or https URL.",
});
const GeoMapMarkerIconDotSchema = z.object({
type: z.literal("dot"),
color: z.string().optional(),
borderColor: z.string().optional(),
radius: z.number().min(3).max(16).optional(),
});
const GeoMapMarkerIconEmojiSchema = z.object({
type: z.literal("emoji"),
value: z.string().min(1),
size: z.number().min(16).max(40).optional(),
bgColor: z.string().optional(),
borderColor: z.string().optional(),
});
const GeoMapMarkerIconImageSchema = z.object({
type: z.literal("image"),
url: HttpUrlSchema,
width: z.number().min(16).max(64).optional(),
height: z.number().min(16).max(64).optional(),
borderRadius: z.number().min(0).max(999).optional(),
borderColor: z.string().optional(),
});
export const GeoMapMarkerIconSchema = z.union([
GeoMapMarkerIconDotSchema,
GeoMapMarkerIconEmojiSchema,
GeoMapMarkerIconImageSchema,
]);
export type GeoMapMarkerIcon = z.infer<typeof GeoMapMarkerIconSchema>;
export const GeoMapMarkerSchema = z.object({
id: z.string().min(1).optional(),
lat: LatitudeSchema,
lng: LongitudeSchema,
label: z.string().optional(),
description: z.string().optional(),
tooltip: z.enum(["none", "hover", "always"]).optional(),
icon: GeoMapMarkerIconSchema.optional(),
});
export type GeoMapMarker = z.infer<typeof GeoMapMarkerSchema>;
export const GeoMapRoutePointSchema = z.object({
lat: LatitudeSchema,
lng: LongitudeSchema,
});
export const GeoMapRouteSchema = z.object({
id: z.string().min(1).optional(),
points: z.array(GeoMapRoutePointSchema).min(2),
label: z.string().optional(),
description: z.string().optional(),
tooltip: z.enum(["none", "hover", "always"]).optional(),
color: z.string().optional(),
weight: z.number().min(1).max(12).optional(),
opacity: z.number().min(0).max(1).optional(),
dashArray: z.string().optional(),
});
export type GeoMapRoute = z.infer<typeof GeoMapRouteSchema>;
export const GeoMapClusteringSchema = z.object({
enabled: z.boolean().optional(),
radius: z.number().min(20).max(120).optional(),
maxZoom: z.number().min(1).max(22).optional(),
minPoints: z.number().min(2).max(20).optional(),
});
export type GeoMapClustering = z.infer<typeof GeoMapClusteringSchema>;
export const GeoMapFitTargetSchema = z.enum(["markers", "routes", "all"]);
export type GeoMapFitTarget = z.infer<typeof GeoMapFitTargetSchema>;
const GeoMapFitViewportSchema = z.object({
mode: z.literal("fit"),
padding: z.number().nonnegative().optional(),
maxZoom: z.number().min(1).max(22).optional(),
target: GeoMapFitTargetSchema.optional(),
});
const GeoMapCenterViewportSchema = z.object({
mode: z.literal("center"),
center: z.object({
lat: LatitudeSchema,
lng: LongitudeSchema,
}),
zoom: z.number().min(1).max(22),
});
export const GeoMapViewportSchema = z.union([
GeoMapFitViewportSchema,
GeoMapCenterViewportSchema,
]);
export type GeoMapViewport = z.infer<typeof GeoMapViewportSchema>;
export const GeoMapPropsSchema = z
.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
receipt: ToolUIReceiptSchema.optional(),
title: z.string().optional(),
description: z.string().optional(),
markers: z.array(GeoMapMarkerSchema).min(1),
routes: z.array(GeoMapRouteSchema).optional(),
clustering: GeoMapClusteringSchema.optional(),
viewport: GeoMapViewportSchema.optional(),
showZoomControl: z.boolean().optional(),
theme: z.enum(["light", "dark"]).optional(),
})
.superRefine((value, ctx) => {
const seenMarkerIds = new Set<string>();
value.markers.forEach((marker, index) => {
if (!marker.id) {
return;
}
if (seenMarkerIds.has(marker.id)) {
ctx.addIssue({
code: "custom",
path: ["markers", index, "id"],
message: `Duplicate marker id "${marker.id}".`,
});
return;
}
seenMarkerIds.add(marker.id);
});
const seenRouteIds = new Set<string>();
value.routes?.forEach((route, index) => {
if (!route.id) {
return;
}
if (seenRouteIds.has(route.id)) {
ctx.addIssue({
code: "custom",
path: ["routes", index, "id"],
message: `Duplicate route id "${route.id}".`,
});
return;
}
seenRouteIds.add(route.id);
});
});
export type GeoMapStyle = CSSProperties &
Partial<Record<`--${string}`, string | number>>;
export type GeoMapClientProps = {
className?: string;
style?: GeoMapStyle;
tooltipClassName?: string;
popupClassName?: string;
onMarkerClick?: (marker: GeoMapMarker) => void;
onRouteClick?: (route: GeoMapRoute) => void;
};
export type GeoMapProps = z.infer<typeof GeoMapPropsSchema> & GeoMapClientProps;
export const SerializableGeoMapSchema = GeoMapPropsSchema;
export type SerializableGeoMap = z.infer<typeof SerializableGeoMapSchema>;
const SerializableGeoMapSchemaContract = defineToolUiContract(
"GeoMap",
SerializableGeoMapSchema,
);
export const parseSerializableGeoMap: (input: unknown) => SerializableGeoMap =
SerializableGeoMapSchemaContract.parse;
export const safeParseSerializableGeoMap: (
input: unknown,
) => SerializableGeoMap | null = SerializableGeoMapSchemaContract.safeParse;
@@ -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>
);
}

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