mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[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:
@@ -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;
|
||||
+19
@@ -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
|
||||
+11
@@ -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";
|
||||
+212
@@ -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>
|
||||
);
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export { ApprovalCard } from "./approval-card";
|
||||
export {
|
||||
type SerializableApprovalCard,
|
||||
type ApprovalCardProps,
|
||||
type ApprovalDecision,
|
||||
type MetadataItem,
|
||||
} from "./schema";
|
||||
+54
@@ -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
|
||||
+27
@@ -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;
|
||||
+19
@@ -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
|
||||
+18
@@ -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";
|
||||
+460
@@ -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>
|
||||
);
|
||||
}
|
||||
+259
@@ -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";
|
||||
+52
@@ -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;
|
||||
+19
@@ -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
|
||||
+14
@@ -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";
|
||||
+469
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
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;
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
export { CodeBlock } from "./code-block";
|
||||
export type {
|
||||
CodeBlockRootProps,
|
||||
CodeBlockComposedProps,
|
||||
CodeBlockSectionProps,
|
||||
} from "./code-block";
|
||||
export type {
|
||||
CodeBlockProps,
|
||||
CodeBlockLineNumbersMode,
|
||||
SerializableCodeBlock,
|
||||
} from "./schema";
|
||||
+43
@@ -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;
|
||||
+14
@@ -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";
|
||||
+463
@@ -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">•</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;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export { CodeDiff } from "./code-diff";
|
||||
export type {
|
||||
CodeDiffRootProps,
|
||||
CodeDiffComposedProps,
|
||||
CodeDiffSectionProps,
|
||||
} from "./code-diff";
|
||||
export type { CodeDiffProps, SerializableCodeDiff } from "./schema";
|
||||
+71
@@ -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;
|
||||
+19
@@ -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
|
||||
+44
@@ -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";
|
||||
+1019
File diff suppressed because it is too large
Load Diff
+473
@@ -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);
|
||||
}
|
||||
}
|
||||
+29
@@ -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";
|
||||
+358
@@ -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,
|
||||
};
|
||||
}
|
||||
+265
@@ -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;
|
||||
}
|
||||
+299
@@ -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
|
||||
+23
@@ -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";
|
||||
+756
@@ -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 =
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <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>
|
||||
);
|
||||
});
|
||||
+131
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
+86
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+216
@@ -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;
|
||||
}
|
||||
+162
@@ -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";
|
||||
+37
@@ -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%);
|
||||
}
|
||||
+198
@@ -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;
|
||||
+19
@@ -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
|
||||
+13
@@ -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";
|
||||
+184
@@ -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>
|
||||
);
|
||||
}
|
||||
+133
@@ -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>
|
||||
);
|
||||
}
|
||||
+146
@@ -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>
|
||||
);
|
||||
}
|
||||
+75
@@ -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>
|
||||
);
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
export { ImageGallery } from "./image-gallery";
|
||||
export type {
|
||||
ImageGalleryProps,
|
||||
ImageGalleryItem,
|
||||
SerializableImageGallery,
|
||||
} from "./schema";
|
||||
+59
@@ -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;
|
||||
+25
@@ -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
|
||||
+11
@@ -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;
|
||||
+19
@@ -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
|
||||
+19
@@ -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";
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
export { InstagramPost } from "./instagram-post";
|
||||
export type { InstagramPostProps } from "./instagram-post";
|
||||
export type {
|
||||
InstagramPostData,
|
||||
InstagramPostAuthor,
|
||||
InstagramPostMedia,
|
||||
InstagramPostStats,
|
||||
} from "./schema";
|
||||
+305
@@ -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>
|
||||
);
|
||||
}
|
||||
+57
@@ -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;
|
||||
+19
@@ -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
|
||||
+12
@@ -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";
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
export { ItemCarousel } from "./item-carousel";
|
||||
export { ItemCard } from "./item-card";
|
||||
export type {
|
||||
Item,
|
||||
ItemCarouselProps,
|
||||
SerializableItem,
|
||||
SerializableItemCarousel,
|
||||
} from "./schema";
|
||||
+110
@@ -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>
|
||||
);
|
||||
}
|
||||
+404
@@ -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;
|
||||
+77
@@ -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;
|
||||
+19
@@ -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
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Adapter: UI and utility re-exports for copy-standalone portability.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
export { cn } from "@toolui/lib/utils";
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export { LinkPreview } from "./link-preview";
|
||||
export type { LinkPreviewProps } from "./link-preview";
|
||||
export type { SerializableLinkPreview } from "./schema";
|
||||
+141
@@ -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>
|
||||
);
|
||||
}
|
||||
+43
@@ -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;
|
||||
+19
@@ -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
|
||||
+19
@@ -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";
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
export { LinkedInPost } from "./linkedin-post";
|
||||
export type { LinkedInPostProps } from "./linkedin-post";
|
||||
export type {
|
||||
LinkedInPostData,
|
||||
LinkedInPostAuthor,
|
||||
LinkedInPostMedia,
|
||||
LinkedInPostLinkPreview,
|
||||
LinkedInPostStats,
|
||||
} from "./schema";
|
||||
+283
@@ -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>
|
||||
);
|
||||
}
|
||||
+59
@@ -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;
|
||||
+19
@@ -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
|
||||
+12
@@ -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";
|
||||
+10
@@ -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";
|
||||
+511
@@ -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>
|
||||
);
|
||||
}
|
||||
+83
@@ -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;
|
||||
};
|
||||
+19
@@ -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
|
||||
+14
@@ -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";
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export { OptionList } from "./option-list";
|
||||
export type {
|
||||
OptionListProps,
|
||||
OptionListOption,
|
||||
OptionListSelection,
|
||||
SerializableOptionList,
|
||||
} from "./schema";
|
||||
+625
@@ -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
Reference in New Issue
Block a user