From dfe350f3daf651c5b3d2a74888ddfee8cbefeb2e Mon Sep 17 00:00:00 2001 From: Pete Petrash Date: Mon, 24 Nov 2025 13:57:43 -0800 Subject: [PATCH 1/2] refactor: replace DecisionPrompt with OptionList, add shared ActionButtons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add shared/ directory with reusable ActionButtons component - Add option-list component as replacement for decision-prompt - Add footerActions support to DataTable, MediaCard, and SocialPost - Rename action files for clarity (media-actions.tsx, post-actions.tsx) - Add documentation comments to _ui.tsx/_cn.ts for portability - Remove layout prop from ActionButtons (responsive by default) - Update docs, presets, and gallery for OptionList 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/components/home/chat-showcase.tsx | 27 +- app/docs/[component]/page.tsx | 6 +- .../previews/data-table-preview.tsx | 6 - .../previews/decision-prompt-preview.tsx | 123 ------- .../previews/media-card-preview.tsx | 7 - .../previews/option-list-preview.tsx | 100 +++++ .../previews/social-post-preview.tsx | 8 - app/docs/_components/code-panel.tsx | 105 ++---- app/docs/_components/preset-selector.tsx | 22 +- app/docs/contextual-actions/content.mdx | 120 ++++++ app/docs/contextual-actions/page.tsx | 10 + app/docs/decision-prompt/content.mdx | 153 -------- app/docs/gallery/page.tsx | 8 +- app/docs/option-list/content.mdx | 155 ++++++++ .../{decision-prompt => option-list}/page.tsx | 6 +- components/tool-ui/data-table/_cn.ts | 7 + components/tool-ui/data-table/_ui.tsx | 7 + components/tool-ui/data-table/data-table.tsx | 21 ++ components/tool-ui/data-table/types.ts | 5 + components/tool-ui/decision-prompt/_cn.ts | 1 - components/tool-ui/decision-prompt/_ui.tsx | 2 - .../tool-ui/decision-prompt/actions.tsx | 205 ----------- .../decision-prompt/decision-prompt.tsx | 69 ---- components/tool-ui/decision-prompt/index.tsx | 12 - .../decision-prompt/multi-select-actions.tsx | 198 ---------- .../tool-ui/decision-prompt/receipt.tsx | 83 ----- components/tool-ui/decision-prompt/schema.ts | 72 ---- .../tool-ui/decision-prompt/usage-docs.md | 345 ------------------ components/tool-ui/media-card/_cn.ts | 15 +- components/tool-ui/media-card/_ui.tsx | 7 + components/tool-ui/media-card/context.tsx | 12 +- components/tool-ui/media-card/footer.tsx | 2 +- .../{actions.tsx => media-actions.tsx} | 4 +- components/tool-ui/media-card/media-card.tsx | 28 +- components/tool-ui/option-list/_cn.ts | 14 + components/tool-ui/option-list/_ui.tsx | 9 + components/tool-ui/option-list/index.tsx | 13 + .../tool-ui/option-list/option-list.tsx | 303 +++++++++++++++ components/tool-ui/option-list/schema.ts | 68 ++++ components/tool-ui/shared/_cn.ts | 14 + components/tool-ui/shared/_ui.tsx | 10 + components/tool-ui/shared/action-buttons.tsx | 108 ++++++ components/tool-ui/shared/actions-config.ts | 23 ++ components/tool-ui/shared/index.ts | 22 ++ components/tool-ui/shared/schema.ts | 48 +++ .../tool-ui/shared/use-action-buttons.tsx | 127 +++++++ components/tool-ui/social-post/_cn.ts | 15 +- components/tool-ui/social-post/_ui.tsx | 9 +- components/tool-ui/social-post/context.tsx | 18 +- .../{actions.tsx => post-actions.tsx} | 4 +- .../renderers/linkedin-renderer.tsx | 2 +- .../social-post/renderers/x-renderer.tsx | 2 +- .../tool-ui/social-post/social-post.tsx | 113 +++--- lib/docs/component-registry.ts | 6 +- lib/presets/decision-prompt.ts | 115 ------ lib/presets/option-list.ts | 82 +++++ 56 files changed, 1502 insertions(+), 1574 deletions(-) delete mode 100644 app/docs/[component]/previews/decision-prompt-preview.tsx create mode 100644 app/docs/[component]/previews/option-list-preview.tsx create mode 100644 app/docs/contextual-actions/content.mdx create mode 100644 app/docs/contextual-actions/page.tsx delete mode 100644 app/docs/decision-prompt/content.mdx create mode 100644 app/docs/option-list/content.mdx rename app/docs/{decision-prompt => option-list}/page.tsx (51%) delete mode 100644 components/tool-ui/decision-prompt/_cn.ts delete mode 100644 components/tool-ui/decision-prompt/_ui.tsx delete mode 100644 components/tool-ui/decision-prompt/actions.tsx delete mode 100644 components/tool-ui/decision-prompt/decision-prompt.tsx delete mode 100644 components/tool-ui/decision-prompt/index.tsx delete mode 100644 components/tool-ui/decision-prompt/multi-select-actions.tsx delete mode 100644 components/tool-ui/decision-prompt/receipt.tsx delete mode 100644 components/tool-ui/decision-prompt/schema.ts delete mode 100644 components/tool-ui/decision-prompt/usage-docs.md rename components/tool-ui/media-card/{actions.tsx => media-actions.tsx} (98%) create mode 100644 components/tool-ui/option-list/_cn.ts create mode 100644 components/tool-ui/option-list/_ui.tsx create mode 100644 components/tool-ui/option-list/index.tsx create mode 100644 components/tool-ui/option-list/option-list.tsx create mode 100644 components/tool-ui/option-list/schema.ts create mode 100644 components/tool-ui/shared/_cn.ts create mode 100644 components/tool-ui/shared/_ui.tsx create mode 100644 components/tool-ui/shared/action-buttons.tsx create mode 100644 components/tool-ui/shared/actions-config.ts create mode 100644 components/tool-ui/shared/index.ts create mode 100644 components/tool-ui/shared/schema.ts create mode 100644 components/tool-ui/shared/use-action-buttons.tsx rename components/tool-ui/social-post/{actions.tsx => post-actions.tsx} (98%) delete mode 100644 lib/presets/decision-prompt.ts create mode 100644 lib/presets/option-list.ts diff --git a/app/components/home/chat-showcase.tsx b/app/components/home/chat-showcase.tsx index 3bf9ed1..6ae596e 100644 --- a/app/components/home/chat-showcase.tsx +++ b/app/components/home/chat-showcase.tsx @@ -20,10 +20,6 @@ import { SocialPost, type SerializableSocialPost, } from "@/components/tool-ui/social-post"; -import { - DecisionPrompt, - type DecisionPromptAction, -} from "@/components/tool-ui/decision-prompt"; type BubbleProps = { role: "user" | "assistant"; @@ -297,10 +293,10 @@ const SOCIAL_POST: SerializableSocialPost = { language: "en-US", }; -const DECISION_ACTIONS: DecisionPromptAction[] = [ - { id: "cancel", label: "Discard", variant: "ghost" }, - { id: "edit", label: "Revise", variant: "outline" }, - { id: "send", label: "Post Now", variant: "default" }, +const SOCIAL_POST_ACTIONS = [ + { id: "cancel", label: "Discard", variant: "ghost" as const }, + { id: "edit", label: "Revise", variant: "outline" as const }, + { id: "send", label: "Post Now", variant: "default" as const }, ]; function createSceneConfigs(): SceneConfig[] { @@ -327,18 +323,17 @@ function createSceneConfigs(): SceneConfig[] { toolUI: , toolFallbackHeight: 260, }, - // Scene 3: Open Source Release / SocialPost + DecisionPrompt + // Scene 3: Open Source Release / SocialPost with footerActions { userMessage: "Draft a tweet about our open-source release", preamble: "Here's a draft announcement:", toolUI: ( -
- - +
), diff --git a/app/docs/[component]/page.tsx b/app/docs/[component]/page.tsx index cb4a3ee..bef04c5 100644 --- a/app/docs/[component]/page.tsx +++ b/app/docs/[component]/page.tsx @@ -3,7 +3,7 @@ import { getComponentById } from "@/lib/docs/component-registry"; import { DataTablePreview } from "./previews/data-table-preview"; import { SocialPostPreview } from "./previews/social-post-preview"; import { MediaCardPreview } from "./previews/media-card-preview"; -import { DecisionPromptPreview } from "./previews/decision-prompt-preview"; +import { OptionListPreview } from "./previews/option-list-preview"; export default async function ComponentPage({ params, @@ -24,8 +24,8 @@ export default async function ComponentPage({ return ; case "media-card": return ; - case "decision-prompt": - return ; + case "option-list": + return ; default: notFound(); } diff --git a/app/docs/[component]/previews/data-table-preview.tsx b/app/docs/[component]/previews/data-table-preview.tsx index dbe6e87..e1aed4b 100644 --- a/app/docs/[component]/previews/data-table-preview.tsx +++ b/app/docs/[component]/previews/data-table-preview.tsx @@ -130,12 +130,6 @@ export function DataTablePreview({ className="h-full w-full" componentId="data-table" config={currentConfig} - socialPostConfig={undefined} - mediaCardConfig={undefined} - decisionPromptConfig={undefined} - decisionPromptSelectedAction={undefined} - decisionPromptSelectedActions={[]} - mediaCardMaxWidth={undefined} sort={sort} isLoading={loading} emptyMessage={emptyMessage} diff --git a/app/docs/[component]/previews/decision-prompt-preview.tsx b/app/docs/[component]/previews/decision-prompt-preview.tsx deleted file mode 100644 index db2a23a..0000000 --- a/app/docs/[component]/previews/decision-prompt-preview.tsx +++ /dev/null @@ -1,123 +0,0 @@ -"use client"; - -import { useCallback, useState, useEffect } from "react"; -import { useSearchParams, usePathname, useRouter } from "next/navigation"; -import { ComponentPreviewShell } from "../component-preview-shell"; -import { PresetSelector } from "../../_components/preset-selector"; -import { CodePanel } from "../../_components/code-panel"; -import { DecisionPrompt } from "@/components/tool-ui/decision-prompt"; -import { - DecisionPromptPresetName, - decisionPromptPresets, -} from "@/lib/presets/decision-prompt"; - -export function DecisionPromptPreview({ - withContainer = true, -}: { - withContainer?: boolean; -}) { - const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); - - const presetParam = searchParams.get("preset"); - const defaultPreset = "multi-choice"; - const initialPreset: DecisionPromptPresetName = - presetParam && presetParam in decisionPromptPresets - ? (presetParam as DecisionPromptPresetName) - : defaultPreset; - - const [currentPreset, setCurrentPreset] = - useState(initialPreset); - const [isLoading, setIsLoading] = useState(false); - const [selectedAction, setSelectedAction] = useState(); - const [selectedActions, setSelectedActions] = useState([]); - - useEffect(() => { - const presetParam = searchParams.get("preset"); - if ( - presetParam && - presetParam in decisionPromptPresets && - presetParam !== currentPreset - ) { - setCurrentPreset(presetParam as DecisionPromptPresetName); - setSelectedAction(undefined); - setSelectedActions([]); - setIsLoading(false); - } - }, [searchParams, currentPreset]); - - const currentConfig = decisionPromptPresets[currentPreset]; - - const handleSelectPreset = useCallback( - (preset: unknown) => { - const presetName = preset as DecisionPromptPresetName; - setCurrentPreset(presetName); - setSelectedAction(undefined); - setSelectedActions([]); - setIsLoading(false); - - const params = new URLSearchParams(searchParams.toString()); - params.set("preset", presetName); - router.push(`${pathname}?${params.toString()}`, { scroll: false }); - }, - [router, pathname, searchParams], - ); - - return ( - - } - renderPreview={(_loading) => ( -
- { - console.log("Decision prompt action:", actionId); - - if (actionId === "install" || actionId === "send") { - await new Promise((resolve) => setTimeout(resolve, 1500)); - } - - setSelectedAction(actionId); - }} - onMultiAction={async (actionIds) => { - console.log("Decision prompt multi-action:", actionIds); - - await new Promise((resolve) => setTimeout(resolve, 1500)); - - setSelectedActions(actionIds); - }} - /> -
- )} - renderCodePanel={(loading) => ( - - )} - /> - ); -} diff --git a/app/docs/[component]/previews/media-card-preview.tsx b/app/docs/[component]/previews/media-card-preview.tsx index 13f285b..d95b976 100644 --- a/app/docs/[component]/previews/media-card-preview.tsx +++ b/app/docs/[component]/previews/media-card-preview.tsx @@ -88,16 +88,9 @@ export function MediaCardPreview({ )} diff --git a/app/docs/[component]/previews/option-list-preview.tsx b/app/docs/[component]/previews/option-list-preview.tsx new file mode 100644 index 0000000..f3e5d2c --- /dev/null +++ b/app/docs/[component]/previews/option-list-preview.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useCallback, useState, useEffect } from "react"; +import { useSearchParams, usePathname, useRouter } from "next/navigation"; +import { ComponentPreviewShell } from "../component-preview-shell"; +import { PresetSelector } from "../../_components/preset-selector"; +import { CodePanel } from "../../_components/code-panel"; +import { OptionList } from "@/components/tool-ui/option-list"; +import { + OptionListPresetName, + optionListPresets, +} from "@/lib/presets/option-list"; + +export function OptionListPreview({ + withContainer = true, +}: { + withContainer?: boolean; +}) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const presetParam = searchParams.get("preset"); + const defaultPreset = "export"; + const initialPreset: OptionListPresetName = + presetParam && presetParam in optionListPresets + ? (presetParam as OptionListPresetName) + : defaultPreset; + + const [currentPreset, setCurrentPreset] = + useState(initialPreset); + const [isLoading, setIsLoading] = useState(false); + const [selection, setSelection] = useState(null); + + useEffect(() => { + const presetParam = searchParams.get("preset"); + if ( + presetParam && + presetParam in optionListPresets && + presetParam !== currentPreset + ) { + setCurrentPreset(presetParam as OptionListPresetName); + setIsLoading(false); + setSelection(null); + } + }, [searchParams, currentPreset]); + + const currentConfig = optionListPresets[currentPreset]; + + const handleSelectPreset = useCallback( + (preset: unknown) => { + const presetName = preset as OptionListPresetName; + setCurrentPreset(presetName); + setIsLoading(false); + setSelection(null); + + const params = new URLSearchParams(searchParams.toString()); + params.set("preset", presetName); + router.push(`${pathname}?${params.toString()}`, { scroll: false }); + }, + [router, pathname, searchParams], + ); + + return ( + + } + renderPreview={() => ( +
+ { + console.log("OptionList confirmed:", sel); + alert(`Selection confirmed: ${JSON.stringify(sel)}`); + }} + /> +
+ )} + renderCodePanel={() => ( + + )} + /> + ); +} diff --git a/app/docs/[component]/previews/social-post-preview.tsx b/app/docs/[component]/previews/social-post-preview.tsx index cfa5582..6c8d0f1 100644 --- a/app/docs/[component]/previews/social-post-preview.tsx +++ b/app/docs/[component]/previews/social-post-preview.tsx @@ -86,16 +86,8 @@ export function SocialPostPreview({ )} diff --git a/app/docs/_components/code-panel.tsx b/app/docs/_components/code-panel.tsx index a57044a..6d437c2 100644 --- a/app/docs/_components/code-panel.tsx +++ b/app/docs/_components/code-panel.tsx @@ -3,7 +3,7 @@ import { DataTableConfig } from "@/lib/presets/data-table"; import { SocialPostConfig } from "@/lib/presets/social-post"; import { MediaCardConfig } from "@/lib/presets/media-card"; -import { DecisionPromptConfig } from "@/lib/presets/decision-prompt"; +import { OptionListConfig } from "@/lib/presets/option-list"; import { DynamicCodeBlock } from "fumadocs-ui/components/dynamic-codeblock"; interface CodePanelProps { @@ -11,9 +11,8 @@ interface CodePanelProps { config?: DataTableConfig; socialPostConfig?: SocialPostConfig; mediaCardConfig?: MediaCardConfig; - decisionPromptConfig?: DecisionPromptConfig; - decisionPromptSelectedAction?: string; - decisionPromptSelectedActions?: string[]; + optionListConfig?: OptionListConfig; + optionListSelection?: string[] | string | null; mediaCardMaxWidth?: string; sort?: { by?: string; direction?: "asc" | "desc" }; isLoading?: boolean; @@ -27,9 +26,8 @@ export function CodePanel({ config, socialPostConfig, mediaCardConfig, - decisionPromptConfig, - decisionPromptSelectedAction, - decisionPromptSelectedActions, + optionListConfig, + optionListSelection, mediaCardMaxWidth, sort, isLoading, @@ -257,85 +255,42 @@ export function CodePanel({ return ``; }; - const generateDecisionPromptCode = () => { - if (!decisionPromptConfig) return ""; - const prompt = decisionPromptConfig.prompt; + const generateOptionListCode = () => { + if (!optionListConfig) return ""; + const list = optionListConfig.optionList; const props: string[] = []; - props.push(` prompt="${prompt.prompt}"`); - - if (prompt.description) { - props.push(` description="${prompt.description}"`); - } - props.push( - ` actions={${JSON.stringify(prompt.actions, null, 4).replace(/\n/g, "\n ")}}`, + ` options={${JSON.stringify(list.options, null, 4).replace(/\n/g, "\n ")}}`, ); - // Multi-select mode - if (prompt.multiSelect) { - if ( - decisionPromptSelectedActions && - decisionPromptSelectedActions.length > 0 - ) { - props.push( - ` selectedActions={${JSON.stringify(decisionPromptSelectedActions)}}`, - ); - } - - if (prompt.align && prompt.align !== "right") { - props.push(` align="${prompt.align}"`); - } - - if (prompt.layout && prompt.layout !== "inline") { - props.push(` layout="${prompt.layout}"`); - } - - props.push(` multiSelect={true}`); - - if (prompt.minSelections && prompt.minSelections !== 1) { - props.push(` minSelections={${prompt.minSelections}}`); - } - - if (prompt.maxSelections) { - props.push(` maxSelections={${prompt.maxSelections}}`); - } - - if (prompt.confirmLabel && prompt.confirmLabel !== "Confirm") { - props.push(` confirmLabel="${prompt.confirmLabel}"`); - } - - if (prompt.cancelLabel && prompt.cancelLabel !== "Cancel") { - props.push(` cancelLabel="${prompt.cancelLabel}"`); - } - - props.push( - ` onMultiAction={(actionIds) => {\n console.log("Selected actions:", actionIds);\n // Handle multi-action here\n }}`, - ); - } else { - // Single-select mode - if (decisionPromptSelectedAction) { - props.push(` selectedAction="${decisionPromptSelectedAction}"`); - } + if (list.selectionMode && list.selectionMode !== "multi") { + props.push(` selectionMode="${list.selectionMode}"`); + } - if (prompt.align && prompt.align !== "right") { - props.push(` align="${prompt.align}"`); - } + if (optionListSelection) { + props.push(` value={${JSON.stringify(optionListSelection)}}`); + } - if (prompt.layout && prompt.layout !== "inline") { - props.push(` layout="${prompt.layout}"`); - } + if (list.minSelections && list.minSelections !== 1) { + props.push(` minSelections={${list.minSelections}}`); + } - if (prompt.confirmTimeout && prompt.confirmTimeout !== 3000) { - props.push(` confirmTimeout={${prompt.confirmTimeout}}`); - } + if (list.maxSelections) { + props.push(` maxSelections={${list.maxSelections}}`); + } + if (list.footerActions) { props.push( - ` onAction={(actionId) => {\n console.log("Action:", actionId);\n // Handle action here\n }}`, + ` footerActions={${JSON.stringify(list.footerActions, null, 4).replace(/\n/g, "\n ")}}`, ); } - return ``; + props.push( + ` onConfirm={(selection) => {\n console.log("Selection:", selection);\n }}`, + ); + + return ``; }; const generateCode = () => { @@ -345,8 +300,8 @@ export function CodePanel({ return generateSocialPostCode(); } else if (componentId === "media-card") { return generateMediaCardCode(); - } else if (componentId === "decision-prompt") { - return generateDecisionPromptCode(); + } else if (componentId === "option-list") { + return generateOptionListCode(); } return ""; }; diff --git a/app/docs/_components/preset-selector.tsx b/app/docs/_components/preset-selector.tsx index 59ff1d6..1d2fbf0 100644 --- a/app/docs/_components/preset-selector.tsx +++ b/app/docs/_components/preset-selector.tsx @@ -18,16 +18,16 @@ import { mediaCardPresetDescriptions, } from "@/lib/presets/media-card"; import { - DecisionPromptPresetName, - decisionPromptPresetDescriptions, -} from "@/lib/presets/decision-prompt"; + OptionListPresetName, + optionListPresetDescriptions, +} from "@/lib/presets/option-list"; import { cn } from "@/lib/ui/cn"; type ComponentPreset = | PresetName | SocialPostPresetName | MediaCardPresetName - | DecisionPromptPresetName; + | OptionListPresetName; interface PresetSelectorProps { componentId: string; @@ -57,12 +57,10 @@ const mediaCardPresetNames: MediaCardPresetName[] = [ "audio", ]; -const decisionPromptPresetNames: DecisionPromptPresetName[] = [ - "multi-choice", - "binary", - "destructive", - "async", - "workflow", +const optionListPresetNames: OptionListPresetName[] = [ + "export", + "travel", + "notifications", ]; export function PresetSelector({ @@ -77,7 +75,7 @@ export function PresetSelector({ ? socialPostPresetNames : componentId === "media-card" ? mediaCardPresetNames - : decisionPromptPresetNames; + : optionListPresetNames; const descriptions = componentId === "data-table" @@ -86,7 +84,7 @@ export function PresetSelector({ ? socialPostPresetDescriptions : componentId === "media-card" ? mediaCardPresetDescriptions - : decisionPromptPresetDescriptions; + : optionListPresetDescriptions; return ( diff --git a/app/docs/contextual-actions/content.mdx b/app/docs/contextual-actions/content.mdx new file mode 100644 index 0000000..97851b5 --- /dev/null +++ b/app/docs/contextual-actions/content.mdx @@ -0,0 +1,120 @@ +import { DocsHeader } from "../_components/docs-header"; + + + +Lightweight, in-flow CTA rows attached to tool UIs—meant for time-bound, conversational decisions tied to the content above. + +## Shape + +`ActionsProp` accepts either an array of actions or an object with inline layout config: + +```ts +// Array form +[ + { id: "approve", label: "Approve", variant: "default" }, + { id: "reject", label: "Reject", variant: "destructive", confirmLabel: "Confirm" }, +] + +// Config form +{ + items: [ + { id: "export", label: "Export CSV", variant: "secondary" }, + { id: "sync", label: "Sync", variant: "default", confirmLabel: "Confirm" }, + ], + align: "right", // default "right" + confirmTimeout: 3000 // default 3000ms +} +``` + +### Action fields + +- `id` (required): unique identifier. +- `label` (required): button text. +- `variant`: `"default" | "secondary" | "ghost" | "destructive" | "outline"`. +- `confirmLabel`: triggers a two-step confirm pattern (label shown on second click). +- `disabled`, `loading`: disable or show spinner. +- `shortcut`: optional keyboard hint. +- `icon`: ReactNode (runtime only; omitted in serializable shape). + +## Handlers + +- `onAction(id)`: called after confirm/preflight. +- `onBeforeAction(id) => boolean | Promise`: return `false` to cancel (use for confirmations or auth gates). +- Confirmations: provided by `confirmLabel` + optional `confirmTimeout` (defaults to 3000ms). + +## Serialization + +Use `SerializableAction`/`SerializableActionsConfig` when actions come from tool/LLM output. Runtime code can provide React icons and handlers. + +- **Tool output:** stick to the serializable shapes so payloads survive `JSON.stringify` and hydrate cleanly. +- **Runtime overrides:** when you render the component, you can add icons or custom handlers on top of the serializable payload without mutating the tool contract. + +## Examples + +### Footer actions on DataTable + +```tsx + console.log(id)} +/> +``` + +### MediaCard footer + +```tsx + console.log(id)} +/> +``` + +### SocialPost footer CTA + +```tsx + console.log(id)} +/> +``` + +### Standalone ActionButtons + +```tsx +import { ActionButtons } from "@/components/tool-ui/shared"; + + console.log(id)} +/> +``` + +## Design guidance + +- Default to 2–3 CTAs tied directly to the content above; avoid toolbar sprawl. +- Use `confirmLabel` for destructive/high-impact actions; keep `confirmTimeout` short. +- ActionButtons are responsive by default using container queries—they stack vertically on narrow containers and display inline on wider ones. +- For richer controls, prefer dedicated components (OptionList, filters) and keep this strip focused on decisions. diff --git a/app/docs/contextual-actions/page.tsx b/app/docs/contextual-actions/page.tsx new file mode 100644 index 0000000..7f19eef --- /dev/null +++ b/app/docs/contextual-actions/page.tsx @@ -0,0 +1,10 @@ +import Content from "./content.mdx"; +import { DocsArticle } from "../_components/docs-article"; + +export default function ContextualActionsPage() { + return ( + + + + ); +} diff --git a/app/docs/decision-prompt/content.mdx b/app/docs/decision-prompt/content.mdx deleted file mode 100644 index da79fad..0000000 --- a/app/docs/decision-prompt/content.mdx +++ /dev/null @@ -1,153 +0,0 @@ -import { DocsHeader } from "../_components/docs-header"; -import { DecisionPrompt } from "@/components/tool-ui/decision-prompt"; - - - -A decision surface for binary and multi-option confirmations inside the chat stream. - - - -
- -
-
- - ```tsx - import { DecisionPrompt } from "@/components/tool-ui/decision-prompt"; - - export function Example() { - return ( - - ); - } - ``` - -
- - - -## When to Use Decision Prompt - -- **Good for:** Send/discard decisions, approval flows, choosing between clear options -- **Not for:** Complex forms, low-risk toggles - -## Key Features - -- **Human-in-the-loop**: Explicit user confirmation before actions -- **Multi-option support**: Binary or multiple choice prompts -- **Destructive safeguards**: Extra confirmation for dangerous actions -- **Loading states**: Show progress during async operations -- **Receipt pattern**: Collapse to summary after decision - - -## Source and Install - -Copy `components/decision-prompt` into your project. It should sit alongside your shadcn/ui components directory: - - - - - - - - - - - - - - - - - - - -### Download - -- [**Source on GitHub**](https://github.com/assistant-ui/tool-ui/tree/main/components/decision-prompt) -- [**Quick download (ZIP)**](https://download-directory.github.io/?url=https://github.com/assistant-ui/tool-ui/tree/main/components/decision-prompt) - - - -## Usage - -```tsx - -import { tool } from 'ai'; -import { serializableDecisionPromptSchema } from '@tool-ui/decision-prompt'; - -const confirmEmailSend = tool({ - description: 'Ask the user whether to send an email', - outputSchema: serializableDecisionPromptSchema, - async execute({ draftId }) { - return { - id: `confirm-email-${draftId}`, - title: 'Send this email?', - description: 'The assistant drafted this email based on your last message.', - actions: [ - { id: 'send', label: 'Send', tone: 'primary' }, - { id: 'edit', label: 'Edit first', tone: 'neutral' }, - { id: 'discard', label: 'Discard', tone: 'destructive' }, - ], - }; - }, -}); - -// Frontend with assistant-ui -import { makeAssistantToolUI } from '@assistant-ui/react'; -import { DecisionPrompt } from '@tool-ui/decision-prompt'; - -export const ConfirmEmailUI = makeAssistantToolUI({ - toolName: 'confirmEmailSend', - render: ({ result }) => ( - { - if (id === 'send') await sendDraft(result.id); - if (id === 'edit') openDraftEditor(result.id); - // Handle other actions - }} - /> - ), -}); -``` - - - -## Props - -", - required: true, - }, - defaultActionId: { description: "Primary action", type: "string" }, - onBeforeAction: { description: "Gate actions", type: "({ id }) => boolean | Promise" }, - onAction: { description: "Handle choice", type: "(id) => void | Promise" }, - }} -/> - -Follows standard lifecycle: Pending → Confirming → Executing → Receipt. diff --git a/app/docs/gallery/page.tsx b/app/docs/gallery/page.tsx index 7a2917b..b258be1 100644 --- a/app/docs/gallery/page.tsx +++ b/app/docs/gallery/page.tsx @@ -3,7 +3,7 @@ import { DocsBorderedShell } from "@/app/docs/_components/docs-bordered-shell"; import { DataTable } from "@/components/tool-ui/data-table"; import { MediaCard } from "@/components/tool-ui/media-card"; import { SocialPost } from "@/components/tool-ui/social-post"; -import { DecisionPrompt } from "@/components/tool-ui/decision-prompt"; +import { OptionList } from "@/components/tool-ui/option-list"; import { ZenField } from "@/app/components/visuals/zen-field"; import { sampleStocks, sampleMetrics } from "@/lib/presets/data-table"; import { mediaCardPresets } from "@/lib/presets/media-card"; @@ -12,7 +12,7 @@ import { sampleInstagram, sampleLinkedIn, } from "@/lib/presets/social-post"; -import { decisionPromptPresets } from "@/lib/presets/decision-prompt"; +import { optionListPresets } from "@/lib/presets/option-list"; import { ArrowRightIcon } from "lucide-react"; export default function ComponentsGalleryPage() { @@ -56,11 +56,11 @@ export default function ComponentsGalleryPage() {
- +
- +
diff --git a/app/docs/option-list/content.mdx b/app/docs/option-list/content.mdx new file mode 100644 index 0000000..00c171f --- /dev/null +++ b/app/docs/option-list/content.mdx @@ -0,0 +1,155 @@ +import { DocsHeader } from "../_components/docs-header"; +import { OptionList } from "@/components/tool-ui/option-list"; + + + +A decision surface for single or multi-select choices. Optimized for inline selection prompts in chat. + + + +
+ +
+
+ + ```tsx + import { OptionList } from "@/components/tool-ui/option-list"; + + export function Example() { + return ( + console.log(selection)} + /> + ); + } + ``` + +
+ +## When to Use Option List + +- **Good for:** File format selection, preference choices, confirmation prompts +- **Not for:** Long lists (use DataTable), complex forms, free-text input + +## Key Features + +- **Single or multi-select**: Radio-style or checkbox-style selection +- **Selection constraints**: Min/max selection limits +- **Configurable actions**: Customizable footer buttons +- **Controlled or uncontrolled**: Works with or without external state + +## Source and Install + +Copy `components/option-list` into your project next to your shadcn/ui components: + + + + + + + + + + + + + + + + + + +### Download + +- [**Source on GitHub**](https://github.com/assistant-ui/tool-ui/tree/main/components/option-list) +- [**Quick download (ZIP)**](https://download-directory.github.io/?url=https://github.com/assistant-ui/tool-ui/tree/main/components/option-list) + +## Usage + +```tsx +// Backend tool +import { tool } from 'ai'; +import { z } from 'zod'; +import { serializableOptionListSchema } from '@tool-ui/option-list'; + +const selectFormat = tool({ + description: 'Let user select export format(s)', + inputSchema: z.object({ formats: z.array(z.string()) }), + outputSchema: serializableOptionListSchema, + async execute({ formats }) { + return { + options: formats.map(f => ({ id: f, label: f.toUpperCase() })), + selectionMode: 'multi', + maxSelections: 2, + }; + }, +}); + +// Frontend with assistant-ui +import { makeAssistantToolUI } from '@assistant-ui/react'; +import { OptionList } from '@tool-ui/option-list'; + +export const SelectFormatUI = makeAssistantToolUI({ + toolName: 'selectFormat', + render: ({ result }) => ( + { + console.log('Selected formats:', selection); + }} + /> + ), +}); +``` + +## Props + + void" }, +onConfirm: { description: "Confirm button handler", type: "(value) => void | Promise" }, +onCancel: { description: "Cancel/clear handler", type: "() => void" }, +}} +/> + +## Option Schema + + diff --git a/app/docs/decision-prompt/page.tsx b/app/docs/option-list/page.tsx similarity index 51% rename from app/docs/decision-prompt/page.tsx rename to app/docs/option-list/page.tsx index 4e63b38..39c8000 100644 --- a/app/docs/decision-prompt/page.tsx +++ b/app/docs/option-list/page.tsx @@ -1,12 +1,12 @@ import Content from "./content.mdx"; import { ComponentDocsExamples } from "../_components/component-docs-examples"; -import { DecisionPromptPreview } from "../[component]/previews/decision-prompt-preview"; +import { OptionListPreview } from "../[component]/previews/option-list-preview"; -export default function DecisionPromptDocsPage() { +export default function OptionListDocsPage() { return ( } - examples={} + examples={} defaultTab="docs" /> ); diff --git a/components/tool-ui/data-table/_cn.ts b/components/tool-ui/data-table/_cn.ts index 88283f0..ed8d149 100644 --- a/components/tool-ui/data-table/_cn.ts +++ b/components/tool-ui/data-table/_cn.ts @@ -1,3 +1,10 @@ +/** + * Utility re-export for copy-standalone portability. + * + * This file centralizes the cn() utility so the component can be easily + * copied to another project by updating this to use the target project's + * utility path (e.g., "@/lib/utils"). + */ import type { ClassValue } from "clsx"; import { clsx } from "clsx"; import { twMerge } from "tailwind-merge"; diff --git a/components/tool-ui/data-table/_ui.tsx b/components/tool-ui/data-table/_ui.tsx index 69e0919..11d93f4 100644 --- a/components/tool-ui/data-table/_ui.tsx +++ b/components/tool-ui/data-table/_ui.tsx @@ -1,3 +1,10 @@ +/** + * UI component re-exports for copy-standalone portability. + * + * This file centralizes UI dependencies so the component can be easily + * copied to another project by updating these imports to match the target + * project's component library paths. + */ "use client"; export { Button } from "../../ui/button"; diff --git a/components/tool-ui/data-table/data-table.tsx b/components/tool-ui/data-table/data-table.tsx index adf10dd..5a1c9fe 100644 --- a/components/tool-ui/data-table/data-table.tsx +++ b/components/tool-ui/data-table/data-table.tsx @@ -11,6 +11,7 @@ import type { DataTableRowData, ColumnKey, } from "./types"; +import { ActionButtons, normalizeActionsConfig } from "../shared"; /** * Default locale for all Intl formatting operations. @@ -60,6 +61,9 @@ export function DataTable({ onSortChange, className, locale, + footerActions, + onFooterAction, + onBeforeFooterAction, }: DataTableProps) { /** * Resolved locale with explicit default. @@ -149,6 +153,11 @@ export function DataTable({ : ""; }, [columns, sortBy, sortDirection]); + const normalizedFooterActions = React.useMemo( + () => normalizeActionsConfig(footerActions), + [footerActions], + ); + return (
({ {sortAnnouncement}
)} + + {normalizedFooterActions ? ( +
+ onFooterAction?.(id)} + onBeforeAction={onBeforeFooterAction} + /> +
+ ) : null}
); diff --git a/components/tool-ui/data-table/types.ts b/components/tool-ui/data-table/types.ts index 99dfabb..43c52fc 100644 --- a/components/tool-ui/data-table/types.ts +++ b/components/tool-ui/data-table/types.ts @@ -1,3 +1,4 @@ +import type { ActionsProp } from "../shared"; import type { FormatConfig } from "./formatters"; /** @@ -246,6 +247,10 @@ export interface DataTableClientProps { by?: ColumnKey; direction?: "asc" | "desc"; }) => void; + /** Optional footer actions rendered below the table */ + footerActions?: ActionsProp; + onFooterAction?: (actionId: string) => void | Promise; + onBeforeFooterAction?: (actionId: string) => boolean | Promise; } /** diff --git a/components/tool-ui/decision-prompt/_cn.ts b/components/tool-ui/decision-prompt/_cn.ts deleted file mode 100644 index c5b5602..0000000 --- a/components/tool-ui/decision-prompt/_cn.ts +++ /dev/null @@ -1 +0,0 @@ -export { cn } from "@/lib/ui/cn"; diff --git a/components/tool-ui/decision-prompt/_ui.tsx b/components/tool-ui/decision-prompt/_ui.tsx deleted file mode 100644 index 9443e23..0000000 --- a/components/tool-ui/decision-prompt/_ui.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export { Button } from "@/components/ui/button"; -export { Badge } from "@/components/ui/badge"; diff --git a/components/tool-ui/decision-prompt/actions.tsx b/components/tool-ui/decision-prompt/actions.tsx deleted file mode 100644 index 8793715..0000000 --- a/components/tool-ui/decision-prompt/actions.tsx +++ /dev/null @@ -1,205 +0,0 @@ -"use client"; - -import { useState, useEffect, useCallback, useRef } from "react"; -import type { DecisionPromptAction } from "./schema"; -import { Button } from "./_ui"; -import { cn } from "./_cn"; - -interface DecisionPromptActionsProps { - actions: DecisionPromptAction[]; - onAction: (actionId: string) => void | Promise; - onBeforeAction?: (actionId: string) => boolean | Promise; - confirmTimeout?: number; - align?: "left" | "center" | "right"; - layout?: "inline" | "stack"; - className?: string; -} - -export function DecisionPromptActions({ - actions, - onAction, - onBeforeAction, - confirmTimeout = 3000, - align = "right", - layout = "inline", - className, -}: DecisionPromptActionsProps) { - const [confirmingActionId, setConfirmingActionId] = useState( - null, - ); - const [executingActionId, setExecutingActionId] = useState( - null, - ); - const timeoutRef = useRef(undefined); - - // Clear timeout on unmount - useEffect(() => { - return () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - }; - }, []); - - // Reset confirm state after timeout - useEffect(() => { - if (confirmingActionId) { - timeoutRef.current = setTimeout(() => { - setConfirmingActionId(null); - }, confirmTimeout); - - return () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - }; - } - }, [confirmingActionId, confirmTimeout]); - - const handleActionClick = useCallback( - async (action: DecisionPromptAction) => { - // If action is disabled or loading, do nothing - if (action.disabled || action.loading || executingActionId) { - return; - } - - // Two-stage pattern: if action has confirmLabel and not yet confirming - if (action.confirmLabel && confirmingActionId !== action.id) { - setConfirmingActionId(action.id); - return; - } - - // Check if action should proceed - if (onBeforeAction) { - const shouldProceed = await onBeforeAction(action.id); - if (!shouldProceed) { - setConfirmingActionId(null); - return; - } - } - - // Execute the action - try { - setExecutingActionId(action.id); - await onAction(action.id); - } finally { - setExecutingActionId(null); - setConfirmingActionId(null); - } - }, - [confirmingActionId, executingActionId, onAction, onBeforeAction], - ); - - // Handle escape key to cancel confirmation - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape" && confirmingActionId) { - setConfirmingActionId(null); - } - }; - - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [confirmingActionId]); - - const crossAlignClass = { - left: "items-start", - center: "items-center", - right: "items-end", - }[align]; - // Button content is always centered, regardless of alignment - - return ( -
- {actions.map((action) => { - const isConfirming = confirmingActionId === action.id; - const isExecuting = executingActionId === action.id; - const isLoading = action.loading || isExecuting; - const isDisabled = - action.disabled || (executingActionId !== null && !isExecuting); - - // Determine label: use confirmLabel if in confirm state - const label = - isConfirming && action.confirmLabel - ? action.confirmLabel - : action.label; - - // Determine variant: make destructive actions more prominent in confirm state - const variant = action.variant || "default"; - - return ( - - ); - })} -
- ); -} diff --git a/components/tool-ui/decision-prompt/decision-prompt.tsx b/components/tool-ui/decision-prompt/decision-prompt.tsx deleted file mode 100644 index c39c089..0000000 --- a/components/tool-ui/decision-prompt/decision-prompt.tsx +++ /dev/null @@ -1,69 +0,0 @@ -"use client"; - -import type { DecisionPromptProps } from "./schema"; -import { DecisionPromptActions } from "./actions"; -import { MultiSelectActions } from "./multi-select-actions"; -import { DecisionPromptReceipt } from "./receipt"; -import { cn } from "./_cn"; - -export function DecisionPrompt({ - prompt: _prompt, - actions, - selectedAction, - selectedActions, - description: _description, - onAction = () => {}, - onMultiAction = () => {}, - onBeforeAction, - confirmTimeout = 3000, - align = "right", - layout = "inline", - multiSelect = false, - confirmLabel = "Confirm", - cancelLabel = "Cancel", - minSelections = 1, - maxSelections, - className, -}: DecisionPromptProps) { - const isCompleted = multiSelect - ? selectedActions && selectedActions.length > 0 - : !!selectedAction; - - return ( -
- {isCompleted ? ( - - ) : multiSelect ? ( - - ) : ( - - )} -
- ); -} diff --git a/components/tool-ui/decision-prompt/index.tsx b/components/tool-ui/decision-prompt/index.tsx deleted file mode 100644 index cd566a5..0000000 --- a/components/tool-ui/decision-prompt/index.tsx +++ /dev/null @@ -1,12 +0,0 @@ -export { DecisionPrompt } from "./decision-prompt"; -export type { - DecisionPromptAction, - DecisionPromptProps, - SerializableDecisionPrompt, -} from "./schema"; -export { - DecisionPromptActionSchema, - DecisionPromptPropsSchema, - SerializableDecisionPromptSchema, -} from "./schema"; -export { parseSerializableDecisionPrompt } from "./schema"; diff --git a/components/tool-ui/decision-prompt/multi-select-actions.tsx b/components/tool-ui/decision-prompt/multi-select-actions.tsx deleted file mode 100644 index 8c803e3..0000000 --- a/components/tool-ui/decision-prompt/multi-select-actions.tsx +++ /dev/null @@ -1,198 +0,0 @@ -"use client"; - -import { useState, useCallback } from "react"; -import type { DecisionPromptAction } from "./schema"; -import { Button } from "./_ui"; -import { cn } from "./_cn"; -import { Check } from "lucide-react"; - -interface MultiSelectActionsProps { - actions: DecisionPromptAction[]; - onConfirm: (actionIds: string[]) => void | Promise; - align?: "left" | "center" | "right"; - layout?: "inline" | "stack"; - confirmLabel?: string; - cancelLabel?: string; - minSelections?: number; - maxSelections?: number; - className?: string; -} - -export function MultiSelectActions({ - actions, - onConfirm, - align = "right", - layout = "inline", - confirmLabel = "Confirm", - cancelLabel = "Cancel", - minSelections = 1, - maxSelections, - className, -}: MultiSelectActionsProps) { - const [selectedIds, setSelectedIds] = useState>(new Set()); - const [isExecuting, setIsExecuting] = useState(false); - - const toggleSelection = useCallback( - (actionId: string) => { - setSelectedIds((prev) => { - const next = new Set(prev); - if (next.has(actionId)) { - next.delete(actionId); - } else { - if (maxSelections && next.size >= maxSelections) { - return prev; - } - next.add(actionId); - } - return next; - }); - }, - [maxSelections], - ); - - const handleConfirm = useCallback(async () => { - if (isExecuting) return; - - const selected = Array.from(selectedIds); - if (selected.length < minSelections) return; - - try { - setIsExecuting(true); - await onConfirm(selected); - } finally { - setIsExecuting(false); - } - }, [selectedIds, minSelections, onConfirm, isExecuting]); - - const handleCancel = useCallback(() => { - setSelectedIds(new Set()); - }, []); - - const alignClassButtons = { - left: "justify-start", - center: "justify-center", - right: "justify-end", - }[align]; - - const isConfirmDisabled = - isExecuting || selectedIds.size < minSelections || selectedIds.size === 0; - const isCancelDisabled = isExecuting || selectedIds.size === 0; - - return ( -
-
- {actions.map((action) => { - const isSelected = selectedIds.has(action.id); - const isDisabled = Boolean( - action.disabled || - isExecuting || - (!isSelected && - maxSelections && - selectedIds.size >= maxSelections), - ); - - return ( - - ); - })} -
- -
- - -
-
- ); -} diff --git a/components/tool-ui/decision-prompt/receipt.tsx b/components/tool-ui/decision-prompt/receipt.tsx deleted file mode 100644 index 8b9f00c..0000000 --- a/components/tool-ui/decision-prompt/receipt.tsx +++ /dev/null @@ -1,83 +0,0 @@ -"use client"; - -import { CheckCircle2 } from "lucide-react"; -import type { DecisionPromptAction } from "./schema"; -import { Badge } from "./_ui"; -import { cn } from "./_cn"; - -interface DecisionPromptReceiptProps { - selectedAction?: string; - selectedActions?: string[]; - actions: DecisionPromptAction[]; - align?: "left" | "center" | "right"; - multiSelect?: boolean; - className?: string; -} - -export function DecisionPromptReceipt({ - selectedAction, - selectedActions, - actions, - align = "right", - multiSelect = false, - className, -}: DecisionPromptReceiptProps) { - const alignClass = { - left: "justify-start", - center: "justify-center", - right: "justify-end", - }[align]; - - // Multi-select mode - if (multiSelect && selectedActions && selectedActions.length > 0) { - const selectedActionObjs = actions.filter((a) => - selectedActions.includes(a.id), - ); - - return ( -
- {selectedActionObjs.map((action) => ( - - - {action.icon && {action.icon}} - {action.label} - - ))} -
- ); - } - - // Single-select mode - const action = actions.find((a) => a.id === selectedAction); - - if (!action) { - return null; - } - - return ( -
- - - {action.icon && {action.icon}} - {action.label} - -
- ); -} diff --git a/components/tool-ui/decision-prompt/schema.ts b/components/tool-ui/decision-prompt/schema.ts deleted file mode 100644 index 41ff7e4..0000000 --- a/components/tool-ui/decision-prompt/schema.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { z } from "zod"; -import type { ReactNode } from "react"; - -export const DecisionPromptActionSchema = z.object({ - id: z.string().min(1), - label: z.string().min(1), - confirmLabel: z.string().optional(), - variant: z - .enum(["default", "destructive", "secondary", "ghost", "outline"]) - .optional(), - icon: z.custom().optional(), - loading: z.boolean().optional(), - disabled: z.boolean().optional(), - shortcut: z.string().optional(), -}); - -export type DecisionPromptAction = z.infer; - -export const DecisionPromptPropsSchema = z.object({ - prompt: z.string().min(1), - actions: z.array(DecisionPromptActionSchema).min(1), - selectedAction: z.string().optional(), - selectedActions: z.array(z.string()).optional(), - description: z.string().optional(), - onAction: z.custom<(actionId: string) => void | Promise>().optional(), - onMultiAction: z - .custom<(actionIds: string[]) => void | Promise>() - .optional(), - onBeforeAction: z - .custom<(actionId: string) => boolean | Promise>() - .optional(), - confirmTimeout: z.number().positive().optional(), - className: z.string().optional(), - align: z.enum(["left", "center", "right"]).optional(), - layout: z.enum(["inline", "stack"]).optional(), - multiSelect: z.boolean().optional(), - confirmLabel: z.string().optional(), - cancelLabel: z.string().optional(), - minSelections: z.number().min(0).optional(), - maxSelections: z.number().min(1).optional(), -}); - -export type DecisionPromptProps = Omit< - z.infer, - "onAction" | "onBeforeAction" | "onMultiAction" -> & { - onAction?: (actionId: string) => void | Promise; - onMultiAction?: (actionIds: string[]) => void | Promise; - onBeforeAction?: (actionId: string) => boolean | Promise; -}; - -export const SerializableDecisionPromptSchema = DecisionPromptPropsSchema.omit({ - onAction: true, - onMultiAction: true, - onBeforeAction: true, -}).extend({ - actions: z.array(DecisionPromptActionSchema.omit({ icon: true })), -}); - -export type SerializableDecisionPrompt = z.infer< - typeof SerializableDecisionPromptSchema ->; - -export function parseSerializableDecisionPrompt( - input: unknown, -): SerializableDecisionPrompt { - const res = SerializableDecisionPromptSchema.safeParse(input); - if (!res.success) { - throw new Error(`Invalid DecisionPrompt payload: ${res.error.message}`); - } - return res.data; -} diff --git a/components/tool-ui/decision-prompt/usage-docs.md b/components/tool-ui/decision-prompt/usage-docs.md deleted file mode 100644 index dad7eac..0000000 --- a/components/tool-ui/decision-prompt/usage-docs.md +++ /dev/null @@ -1,345 +0,0 @@ -# DecisionPrompt Component - -A flexible, inline component for presenting decisions and action choices to users. Designed specifically for tool result actions in conversation UIs, with support for binary decisions, multi-choice selection, destructive confirmations, and async operations. - -## Features - -- **Inline-only design** - No modals or dialogs, keeps users in the conversation flow -- **Two-stage destructive actions** - Automatic "confirm" pattern for dangerous operations -- **Receipt state** - Shows what action was taken for conversation history -- **Async support** - Loading states and disabled states during execution -- **Keyboard navigation** - Escape to cancel confirmations, full keyboard support -- **Flexible styling** - Customizable alignment, variants, icons - -## Basic Usage - -```tsx -import { DecisionPrompt } from "@/components/tool-ui/decision-prompt"; - -function MyComponent() { - const [selectedAction, setSelectedAction] = useState(); - - return ( - { - setSelectedAction(actionId); - // Handle the action - }} - /> - ); -} -``` - -## Props - -### `prompt` (required) - -The question or prompt to display to the user. - -```tsx - -``` - -### `actions` (required) - -Array of actions the user can choose from. Each action has: - -- `id` (required): Unique identifier -- `label` (required): Display text -- `confirmLabel` (optional): Label shown in two-stage confirmation -- `variant` (optional): `"default" | "destructive" | "secondary" | "ghost"` -- `icon` (optional): React element to display before label -- `loading` (optional): Shows loading spinner -- `disabled` (optional): Disables the action -- `shortcut` (optional): Keyboard shortcut hint - -```tsx -actions={[ - { - id: "delete", - label: "Delete", - confirmLabel: "Confirm delete", - variant: "destructive", - icon: - } -]} -``` - -### `selectedAction` (optional) - -The ID of the action that was selected. When set, the component displays the receipt state instead of action buttons. - -```tsx - -``` - -### `description` (optional) - -Additional context or description below the prompt. - -```tsx - -``` - -### `onAction` (optional) - -Callback fired when user selects an action. For two-stage actions, this fires only after confirmation. - -```tsx -onAction={async (actionId) => { - if (actionId === "send") { - await sendEmail(); - } - setSelectedAction(actionId); -}} -``` - -### `onBeforeAction` (optional) - -Callback fired before action executes. Return `false` or `Promise` to prevent the action. - -```tsx -onBeforeAction={async (actionId) => { - if (actionId === "delete") { - const hasPermission = await checkPermission(); - if (!hasPermission) { - alert("You don't have permission"); - return false; - } - } - return true; -}} -``` - -### `confirmTimeout` (optional) - -Timeout in milliseconds for two-stage confirmations to auto-reset. Default: `3000` (3 seconds). - -```tsx - -``` - -### `align` (optional) - -Alignment of action buttons: `"left" | "center" | "right"`. Default: `"right"`. - -```tsx - -``` - -### `className` (optional) - -Additional CSS classes for the container. - -## Use Cases - -### Binary Decision - -Simple yes/no or confirm/cancel choices. - -```tsx - { - if (actionId === "send") { - sendEmail(); - } - }} -/> -``` - -### Multi-Choice Selection - -Choose from 3+ options. - -```tsx - { - exportData(actionId); - }} - align="center" -/> -``` - -### Destructive Action (Two-Stage) - -Automatic confirmation pattern for dangerous operations. - -```tsx - { - if (actionId === "delete") { - deleteFiles(); - } - }} - confirmTimeout={5000} // 5 second timeout before reset -/> -``` - -**Two-stage flow:** - -1. User clicks "Delete" → button changes to "Confirm delete" (red, pulsing) -2. User clicks "Confirm delete" → `onAction` fires -3. If user doesn't click within timeout → resets to "Delete" -4. User presses Escape → resets to "Delete" - -### Async with Loading - -Show loading state during async operations. - -```tsx - { - if (actionId === "install") { - await installPackages(); // Shows loading spinner automatically - } - }} -/> -``` - -### With Validation - -Prevent actions based on runtime conditions. - -```tsx - { - if (actionId === "deploy") { - const canDeploy = await checkDeploymentPermission(); - if (!canDeploy) { - alert("You don't have permission to deploy"); - return false; // Prevents action - } - } - return true; - }} - onAction={(actionId) => { - deployToProduction(); - }} -/> -``` - -## Integration with Conversation System - -The component is designed to work with conversation/message systems that persist state: - -```tsx -// Message data structure -interface Message { - id: string; - type: "decision-prompt"; - data: { - prompt: string; - actions: DecisionPromptAction[]; - selectedAction?: string; // Set when user makes choice - }; -} - -// Render message -function MessageRenderer({ message }: { message: Message }) { - const [messages, setMessages] = useState([]); - - return ( - { - // Update message with selected action - setMessages((prev) => - prev.map((msg) => - msg.id === message.id - ? { ...msg, data: { ...msg.data, selectedAction: actionId } } - : msg, - ), - ); - }} - /> - ); -} -``` - -## Styling - -The component uses Tailwind CSS and follows the existing design system: - -- Uses `border`, `bg-card`, `text-card-foreground` for consistent theming -- Receipt state uses `bg-muted/30` for visual distinction -- Destructive confirmations show `animate-pulse` and `ring-2 ring-destructive` -- Fully responsive with mobile-first design - -## Accessibility - -- Keyboard navigation with Tab/Shift+Tab -- Escape key cancels two-stage confirmations -- ARIA labels include keyboard shortcuts -- Loading states announced to screen readers -- Focus management during state transitions - -## TypeScript - -Full TypeScript support with Zod validation: - -```tsx -import type { - DecisionPromptAction, - DecisionPromptProps, -} from "@/components/tool-ui/decision-prompt"; - -// For message persistence (without functions) -import type { SerializableDecisionPrompt } from "@/components/tool-ui/decision-prompt"; -``` - -## Examples - -See `example.tsx` for comprehensive examples of all use cases. diff --git a/components/tool-ui/media-card/_cn.ts b/components/tool-ui/media-card/_cn.ts index 6e96531..ed8d149 100644 --- a/components/tool-ui/media-card/_cn.ts +++ b/components/tool-ui/media-card/_cn.ts @@ -1 +1,14 @@ -export { cn } from "../data-table/_cn"; +/** + * Utility re-export for copy-standalone portability. + * + * This file centralizes the cn() utility so the component can be easily + * copied to another project by updating this to use the target project's + * utility path (e.g., "@/lib/utils"). + */ +import type { ClassValue } from "clsx"; +import { clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/components/tool-ui/media-card/_ui.tsx b/components/tool-ui/media-card/_ui.tsx index f209c44..b2df713 100644 --- a/components/tool-ui/media-card/_ui.tsx +++ b/components/tool-ui/media-card/_ui.tsx @@ -1,3 +1,10 @@ +/** + * UI component re-exports for copy-standalone portability. + * + * This file centralizes UI dependencies so the component can be easily + * copied to another project by updating these imports to match the target + * project's component library paths. + */ "use client"; export { Card, CardContent, CardFooter } from "../../ui/card"; diff --git a/components/tool-ui/media-card/context.tsx b/components/tool-ui/media-card/context.tsx index d15bd27..0020620 100644 --- a/components/tool-ui/media-card/context.tsx +++ b/components/tool-ui/media-card/context.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import type { SerializableMediaCard } from "./schema"; +import type { ActionsProp } from "../shared"; export type MediaCardUIState = { playing?: boolean; @@ -26,6 +27,15 @@ export interface MediaCardClientProps { type: "play" | "pause" | "mute" | "unmute", payload?: unknown, ) => void; + onMediaAction?: (actionId: string, card: SerializableMediaCard) => void; + onBeforeMediaAction?: (args: { + action: string; + card: SerializableMediaCard; + }) => boolean | Promise; + footerActions?: ActionsProp; + onFooterAction?: (actionId: string) => void | Promise; + onBeforeFooterAction?: (actionId: string) => boolean | Promise; + locale?: string; } export interface MediaCardContextValue { @@ -37,7 +47,7 @@ export interface MediaCardContextValue { setState: (patch: Partial) => void; handlers: Pick< MediaCardClientProps, - "onNavigate" | "onAction" | "onBeforeAction" | "onMediaEvent" + "onNavigate" | "onMediaAction" | "onBeforeMediaAction" | "onMediaEvent" >; mediaElement: HTMLMediaElement | null; setMediaElement: (node: HTMLMediaElement | null) => void; diff --git a/components/tool-ui/media-card/footer.tsx b/components/tool-ui/media-card/footer.tsx index a9bfaa8..bf96014 100644 --- a/components/tool-ui/media-card/footer.tsx +++ b/components/tool-ui/media-card/footer.tsx @@ -1,7 +1,7 @@ "use client"; import * as React from "react"; -import { Actions } from "./actions"; +import { Actions } from "./media-actions"; import { useMediaCard } from "./context"; function formatDuration(durationMs: number) { diff --git a/components/tool-ui/media-card/actions.tsx b/components/tool-ui/media-card/media-actions.tsx similarity index 98% rename from components/tool-ui/media-card/actions.tsx rename to components/tool-ui/media-card/media-actions.tsx index 83f3790..abba809 100644 --- a/components/tool-ui/media-card/actions.tsx +++ b/components/tool-ui/media-card/media-actions.tsx @@ -95,7 +95,7 @@ export function Actions() { async function run(actionId: MediaActionId) { const shouldProceed = - (await handlers.onBeforeAction?.({ + (await handlers.onBeforeMediaAction?.({ action: actionId, card, })) ?? true; @@ -170,7 +170,7 @@ export function Actions() { break; } - handlers.onAction?.(actionId, card); + handlers.onMediaAction?.(actionId, card); } const playing = diff --git a/components/tool-ui/media-card/media-card.tsx b/components/tool-ui/media-card/media-card.tsx index 760e3ea..da3e4b8 100644 --- a/components/tool-ui/media-card/media-card.tsx +++ b/components/tool-ui/media-card/media-card.tsx @@ -16,6 +16,7 @@ import { MediaCardBody } from "./body"; import { MediaCardFooter } from "./footer"; import { LinkOverlay } from "./link-overlay"; import { MediaCardProgress } from "./progress"; +import { ActionButtons, normalizeActionsConfig } from "../shared"; const BASE_CARD_STYLE = "border border-border bg-card text-sm shadow-xs"; const DEFAULT_CONTENT_SPACING = "gap-4 p-5"; @@ -47,9 +48,12 @@ export function MediaCard(props: MediaCardProps) { defaultState, onStateChange, onNavigate, - onAction, - onBeforeAction, onMediaEvent, + footerActions, + onFooterAction, + onBeforeFooterAction, + onMediaAction, + onBeforeMediaAction, locale: providedLocale, ...serializable } = props; @@ -119,8 +123,8 @@ export function MediaCard(props: MediaCardProps) { setState: updateState, handlers: { onNavigate, - onAction, - onBeforeAction, + onMediaAction, + onBeforeMediaAction, onMediaEvent, }, mediaElement, @@ -141,6 +145,11 @@ export function MediaCard(props: MediaCardProps) { : DEFAULT_CONTENT_SPACING; const linkContentPadding = LINK_CONTENT_SPACING; + const normalizedFooterActions = React.useMemo( + () => normalizeActionsConfig(footerActions), + [footerActions], + ); + return (
)} + {normalizedFooterActions ? ( +
+ onFooterAction?.(id)} + onBeforeAction={onBeforeFooterAction} + /> +
+ ) : null}
); diff --git a/components/tool-ui/option-list/_cn.ts b/components/tool-ui/option-list/_cn.ts new file mode 100644 index 0000000..ed8d149 --- /dev/null +++ b/components/tool-ui/option-list/_cn.ts @@ -0,0 +1,14 @@ +/** + * Utility re-export for copy-standalone portability. + * + * This file centralizes the cn() utility so the component can be easily + * copied to another project by updating this to use the target project's + * utility path (e.g., "@/lib/utils"). + */ +import type { ClassValue } from "clsx"; +import { clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/components/tool-ui/option-list/_ui.tsx b/components/tool-ui/option-list/_ui.tsx new file mode 100644 index 0000000..b12c0dc --- /dev/null +++ b/components/tool-ui/option-list/_ui.tsx @@ -0,0 +1,9 @@ +/** + * UI component re-exports for copy-standalone portability. + * + * This file centralizes UI dependencies so the component can be easily + * copied to another project by updating these imports to match the target + * project's component library paths. + */ +export { Button } from "../../ui/button"; +export { Separator } from "../../ui/separator"; diff --git a/components/tool-ui/option-list/index.tsx b/components/tool-ui/option-list/index.tsx new file mode 100644 index 0000000..b24468b --- /dev/null +++ b/components/tool-ui/option-list/index.tsx @@ -0,0 +1,13 @@ +export { OptionList } from "./option-list"; +export type { + OptionListProps, + OptionListOption, + OptionListSelection, + SerializableOptionList, +} from "./schema"; +export { + OptionListOptionSchema, + OptionListPropsSchema, + SerializableOptionListSchema, + parseSerializableOptionList, +} from "./schema"; diff --git a/components/tool-ui/option-list/option-list.tsx b/components/tool-ui/option-list/option-list.tsx new file mode 100644 index 0000000..0c4c694 --- /dev/null +++ b/components/tool-ui/option-list/option-list.tsx @@ -0,0 +1,303 @@ +"use client"; + +import { useMemo, useState, useCallback, useEffect, Fragment } from "react"; +import type { + OptionListProps, + OptionListSelection, + OptionListOption, +} from "./schema"; +import { ActionButtons, normalizeActionsConfig } from "../shared"; +import type { Action } from "../shared"; +import { Button, Separator } from "./_ui"; +import { cn } from "./_cn"; +import { Check } from "lucide-react"; + +function normalizeToSet( + value: OptionListSelection | undefined, + mode: "multi" | "single", + maxSelections?: number, +): Set { + if (mode === "single") { + const single = + typeof value === "string" + ? value + : Array.isArray(value) + ? value[0] + : null; + return single ? new Set([single]) : new Set(); + } + + const arr = + typeof value === "string" ? [value] : Array.isArray(value) ? value : []; + + return new Set(maxSelections ? arr.slice(0, maxSelections) : arr); +} + +function setToSelection( + selected: Set, + mode: "multi" | "single", +): OptionListSelection { + if (mode === "single") { + const [first] = selected; + return first ?? null; + } + return Array.from(selected); +} + +function areSetsEqual(a: Set, b: Set) { + if (a.size !== b.size) return false; + for (const val of a) { + if (!b.has(val)) return false; + } + return true; +} + +export function OptionList({ + options, + selectionMode = "multi", + minSelections = 1, + maxSelections, + value, + defaultValue, + onChange, + onConfirm, + onCancel, + footerActions, + className, +}: OptionListProps) { + const effectiveMaxSelections = selectionMode === "single" ? 1 : maxSelections; + + const [uncontrolledSelected, setUncontrolledSelected] = useState>( + () => normalizeToSet(defaultValue, selectionMode, effectiveMaxSelections), + ); + + useEffect(() => { + setUncontrolledSelected((prev) => + normalizeToSet(Array.from(prev), selectionMode, effectiveMaxSelections), + ); + }, [selectionMode, effectiveMaxSelections]); + + const selectedIds = useMemo( + () => + value !== undefined + ? normalizeToSet(value, selectionMode, effectiveMaxSelections) + : uncontrolledSelected, + [value, uncontrolledSelected, selectionMode, effectiveMaxSelections], + ); + + const selectedCount = selectedIds.size; + + const updateSelection = useCallback( + (next: Set) => { + const normalizedNext = normalizeToSet( + Array.from(next), + selectionMode, + effectiveMaxSelections, + ); + + if (value === undefined) { + if (!areSetsEqual(uncontrolledSelected, normalizedNext)) { + setUncontrolledSelected(normalizedNext); + } + } + + onChange?.(setToSelection(normalizedNext, selectionMode)); + }, + [ + effectiveMaxSelections, + selectionMode, + uncontrolledSelected, + value, + onChange, + ], + ); + + 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 handleConfirm = useCallback(async () => { + if (!onConfirm) return; + if (selectedCount === 0 || selectedCount < minSelections) return; + await onConfirm(setToSelection(selectedIds, selectionMode)); + }, [minSelections, onConfirm, selectedCount, selectedIds, selectionMode]); + + const handleCancel = useCallback(() => { + const empty = new Set(); + updateSelection(empty); + onCancel?.(); + }, [onCancel, updateSelection]); + + const normalizedFooterActions = useMemo(() => { + const normalized = normalizeActionsConfig(footerActions); + if (normalized) return normalized; + return { + items: [ + { id: "cancel", label: "Clear", variant: "ghost" as const }, + { id: "confirm", label: "Confirm", variant: "default" as const }, + ], + align: "right" as const, + } satisfies ReturnType; + }, [footerActions]); + + const isConfirmDisabled = + selectedCount < minSelections || selectedCount === 0; + const isCancelDisabled = selectedCount === 0; + + const actionsWithDisabledState = useMemo((): Action[] => { + return normalizedFooterActions.items.map((action) => { + const gatedDisabled = + (action.id === "confirm" && isConfirmDisabled) || + (action.id === "cancel" && isCancelDisabled); + return { + ...action, + disabled: action.disabled || gatedDisabled, + label: + action.id === "confirm" && + selectionMode === "multi" && + selectedCount > 0 + ? `${action.label} (${selectedCount})` + : action.label, + }; + }); + }, [ + normalizedFooterActions.items, + isConfirmDisabled, + isCancelDisabled, + selectionMode, + selectedCount, + ]); + + const handleFooterAction = useCallback( + async (actionId: string) => { + if (actionId === "confirm") { + await handleConfirm(); + } else if (actionId === "cancel") { + handleCancel(); + } + }, + [handleConfirm, handleCancel], + ); + + const indicatorShape = + selectionMode === "single" ? "rounded-full" : "rounded"; + + const renderIndicator = (option: OptionListOption, isSelected: boolean) => ( +
+ {selectionMode === "multi" && isSelected && } + {selectionMode === "single" && isSelected && ( + + )} +
+ ); + + return ( +
+
+ {options.map((option, index) => { + const isSelected = selectedIds.has(option.id); + const isSelectionLocked = + selectionMode === "multi" && + effectiveMaxSelections !== undefined && + selectedCount >= effectiveMaxSelections && + !isSelected; + const isDisabled = option.disabled || isSelectionLocked; + + return ( + + {index > 0 && ( + + )} + + + ); + })} +
+ +
+ +
+
+ ); +} diff --git a/components/tool-ui/option-list/schema.ts b/components/tool-ui/option-list/schema.ts new file mode 100644 index 0000000..2cfc6dd --- /dev/null +++ b/components/tool-ui/option-list/schema.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; +import type { ReactNode } from "react"; +import type { ActionsProp } from "../shared"; +import { + ActionSchema, + SerializableActionSchema, + SerializableActionsConfigSchema, +} from "../shared"; + +export const OptionListOptionSchema = z.object({ + id: z.string().min(1), + label: z.string().min(1), + description: z.string().optional(), + icon: z.custom().optional(), + disabled: z.boolean().optional(), +}); + +export const OptionListPropsSchema = z.object({ + options: z.array(OptionListOptionSchema).min(1), + selectionMode: z.enum(["multi", "single"]).optional(), + value: z + .union([z.array(z.string()), z.string(), z.null()]) + .optional(), + defaultValue: z + .union([z.array(z.string()), z.string(), z.null()]) + .optional(), + footerActions: z + .union([z.array(ActionSchema), SerializableActionsConfigSchema]) + .optional(), + minSelections: z.number().min(0).optional(), + maxSelections: z.number().min(1).optional(), + className: z.string().optional(), +}); + +export type OptionListSelection = string[] | string | null; + +export type OptionListOption = z.infer; + +export type OptionListProps = Omit< + z.infer, + "value" | "defaultValue" +> & { + value?: OptionListSelection; + defaultValue?: OptionListSelection; + onChange?: (value: OptionListSelection) => void; + onConfirm?: (value: OptionListSelection) => void | Promise; + onCancel?: () => void; + footerActions?: ActionsProp; +}; + +export const SerializableOptionListSchema = OptionListPropsSchema.extend({ + options: z.array(OptionListOptionSchema.omit({ icon: true })), + footerActions: z + .union([z.array(SerializableActionSchema), SerializableActionsConfigSchema]) + .optional(), +}); + +export type SerializableOptionList = z.infer; + +export function parseSerializableOptionList( + input: unknown, +): SerializableOptionList { + const res = SerializableOptionListSchema.safeParse(input); + if (!res.success) { + throw new Error(`Invalid OptionList payload: ${res.error.message}`); + } + return res.data; +} diff --git a/components/tool-ui/shared/_cn.ts b/components/tool-ui/shared/_cn.ts new file mode 100644 index 0000000..ed8d149 --- /dev/null +++ b/components/tool-ui/shared/_cn.ts @@ -0,0 +1,14 @@ +/** + * Utility re-export for copy-standalone portability. + * + * This file centralizes the cn() utility so the component can be easily + * copied to another project by updating this to use the target project's + * utility path (e.g., "@/lib/utils"). + */ +import type { ClassValue } from "clsx"; +import { clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/components/tool-ui/shared/_ui.tsx b/components/tool-ui/shared/_ui.tsx new file mode 100644 index 0000000..d358610 --- /dev/null +++ b/components/tool-ui/shared/_ui.tsx @@ -0,0 +1,10 @@ +/** + * UI component re-exports for copy-standalone portability. + * + * This file centralizes UI dependencies so the component can be easily + * copied to another project by updating these imports to match the target + * project's component library paths. + */ +"use client"; + +export { Button } from "../../ui/button"; diff --git a/components/tool-ui/shared/action-buttons.tsx b/components/tool-ui/shared/action-buttons.tsx new file mode 100644 index 0000000..ff3a8c6 --- /dev/null +++ b/components/tool-ui/shared/action-buttons.tsx @@ -0,0 +1,108 @@ +"use client"; + +import type { Action } from "./schema"; +import { Button } from "./_ui"; +import { cn } from "./_cn"; +import { useActionButtons } from "./use-action-buttons"; + +export interface ActionButtonsProps { + actions: Action[]; + onAction: (actionId: string) => void | Promise; + onBeforeAction?: (actionId: string) => boolean | Promise; + confirmTimeout?: number; + align?: "left" | "center" | "right"; + className?: string; +} + +export function ActionButtons({ + actions, + onAction, + onBeforeAction, + confirmTimeout = 3000, + align = "right", + className, +}: ActionButtonsProps) { + const { actions: resolvedActions, runAction } = useActionButtons({ + actions, + onAction, + onBeforeAction, + confirmTimeout, + }); + + const crossAlignClass = { + left: "items-start", + center: "items-center", + right: "items-end", + }[align]; + + return ( +
+ {resolvedActions.map((action) => { + const label = action.currentLabel; + const variant = action.variant || "default"; + + return ( + + ); + })} +
+ ); +} diff --git a/components/tool-ui/shared/actions-config.ts b/components/tool-ui/shared/actions-config.ts new file mode 100644 index 0000000..e3f1696 --- /dev/null +++ b/components/tool-ui/shared/actions-config.ts @@ -0,0 +1,23 @@ +import type { Action, ActionsConfig } from "./schema"; + +export type ActionsProp = ActionsConfig | Action[]; + +export function normalizeActionsConfig( + actions?: ActionsProp, +): ActionsConfig | null { + if (!actions) return null; + + const resolved = Array.isArray(actions) + ? { items: actions } + : { + items: actions.items ?? [], + align: actions.align, + confirmTimeout: actions.confirmTimeout, + }; + + if (!resolved.items || resolved.items.length === 0) { + return null; + } + + return resolved; +} diff --git a/components/tool-ui/shared/index.ts b/components/tool-ui/shared/index.ts new file mode 100644 index 0000000..7d27135 --- /dev/null +++ b/components/tool-ui/shared/index.ts @@ -0,0 +1,22 @@ +export { ActionButtons } from "./action-buttons"; +export type { ActionButtonsProps } from "./action-buttons"; +export { useActionButtons } from "./use-action-buttons"; +export type { + UseActionButtonsOptions, + UseActionButtonsResult, +} from "./use-action-buttons"; +export type { + Action, + SerializableAction, + ActionsConfig, + SerializableActionsConfig, +} from "./schema"; +export type { ActionsProp } from "./actions-config"; +export { normalizeActionsConfig } from "./actions-config"; +export { + ActionSchema, + SerializableActionSchema, + ActionButtonsPropsSchema, + SerializableActionsSchema, + SerializableActionsConfigSchema, +} from "./schema"; diff --git a/components/tool-ui/shared/schema.ts b/components/tool-ui/shared/schema.ts new file mode 100644 index 0000000..bc6e0c4 --- /dev/null +++ b/components/tool-ui/shared/schema.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; +import type { ReactNode } from "react"; + +export const ActionSchema = z.object({ + id: z.string().min(1), + label: z.string().min(1), + confirmLabel: z.string().optional(), + variant: z + .enum(["default", "destructive", "secondary", "ghost", "outline"]) + .optional(), + icon: z.custom().optional(), + loading: z.boolean().optional(), + disabled: z.boolean().optional(), + shortcut: z.string().optional(), +}); + +export type Action = z.infer; + +export const ActionButtonsPropsSchema = z.object({ + actions: z.array(ActionSchema).min(1), + align: z.enum(["left", "center", "right"]).optional(), + confirmTimeout: z.number().positive().optional(), + className: z.string().optional(), +}); + +export const SerializableActionSchema = ActionSchema.omit({ icon: true }); +export const SerializableActionsSchema = + ActionButtonsPropsSchema.extend({ + actions: z.array(SerializableActionSchema), + }).omit({ className: true }); + +export type ActionsConfig = { + items: Action[]; + align?: "left" | "center" | "right"; + confirmTimeout?: number; +}; + +export const SerializableActionsConfigSchema = z.object({ + items: z.array(SerializableActionSchema).min(1), + align: z.enum(["left", "center", "right"]).optional(), + confirmTimeout: z.number().positive().optional(), +}); + +export type SerializableActionsConfig = z.infer< + typeof SerializableActionsConfigSchema +>; + +export type SerializableAction = z.infer; diff --git a/components/tool-ui/shared/use-action-buttons.tsx b/components/tool-ui/shared/use-action-buttons.tsx new file mode 100644 index 0000000..9a1f484 --- /dev/null +++ b/components/tool-ui/shared/use-action-buttons.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { Action } from "./schema"; + +export type UseActionButtonsOptions = { + actions: Action[]; + onAction: (actionId: string) => void | Promise; + onBeforeAction?: (actionId: string) => boolean | Promise; + confirmTimeout?: number; +}; + +export type UseActionButtonsResult = { + actions: Array< + Action & { + currentLabel: string; + isConfirming: boolean; + isExecuting: boolean; + isDisabled: boolean; + isLoading: boolean; + } + >; + runAction: (actionId: string) => Promise; + confirmingActionId: string | null; + executingActionId: string | null; +}; + +export function useActionButtons( + options: UseActionButtonsOptions, +): UseActionButtonsResult { + const { + actions, + onAction, + onBeforeAction, + confirmTimeout = 3000, + } = options; + + const [confirmingActionId, setConfirmingActionId] = useState( + null, + ); + const [executingActionId, setExecutingActionId] = useState( + null, + ); + + useEffect(() => { + if (!confirmingActionId) return; + const id = setTimeout(() => setConfirmingActionId(null), confirmTimeout); + return () => clearTimeout(id); + }, [confirmingActionId, confirmTimeout]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && confirmingActionId) { + setConfirmingActionId(null); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [confirmingActionId]); + + const runAction = useCallback( + async (actionId: string) => { + const action = actions.find((a) => a.id === actionId); + if (!action) return; + + const isExecuting = executingActionId !== null; + if (action.disabled || action.loading || isExecuting) { + return; + } + + if (action.confirmLabel && confirmingActionId !== action.id) { + setConfirmingActionId(action.id); + return; + } + + if (onBeforeAction) { + const shouldProceed = await onBeforeAction(action.id); + if (!shouldProceed) { + setConfirmingActionId(null); + return; + } + } + + try { + setExecutingActionId(action.id); + await onAction(action.id); + } finally { + setExecutingActionId(null); + setConfirmingActionId(null); + } + }, + [actions, confirmingActionId, executingActionId, onAction, onBeforeAction], + ); + + const resolvedActions = useMemo( + () => + actions.map((action) => { + const isConfirming = confirmingActionId === action.id; + const isExecuting = executingActionId === action.id; + const isLoading = action.loading || isExecuting; + const isDisabled = + action.disabled || (executingActionId !== null && !isExecuting); + const currentLabel = + isConfirming && action.confirmLabel + ? action.confirmLabel + : action.label; + + return { + ...action, + currentLabel, + isConfirming, + isExecuting, + isDisabled, + isLoading, + }; + }), + [actions, confirmingActionId, executingActionId], + ); + + return { + actions: resolvedActions, + runAction, + confirmingActionId, + executingActionId, + }; +} diff --git a/components/tool-ui/social-post/_cn.ts b/components/tool-ui/social-post/_cn.ts index 6e96531..ed8d149 100644 --- a/components/tool-ui/social-post/_cn.ts +++ b/components/tool-ui/social-post/_cn.ts @@ -1 +1,14 @@ -export { cn } from "../data-table/_cn"; +/** + * Utility re-export for copy-standalone portability. + * + * This file centralizes the cn() utility so the component can be easily + * copied to another project by updating this to use the target project's + * utility path (e.g., "@/lib/utils"). + */ +import type { ClassValue } from "clsx"; +import { clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/components/tool-ui/social-post/_ui.tsx b/components/tool-ui/social-post/_ui.tsx index 65442f8..5cf1d72 100644 --- a/components/tool-ui/social-post/_ui.tsx +++ b/components/tool-ui/social-post/_ui.tsx @@ -1,3 +1,10 @@ +/** + * UI component re-exports for copy-standalone portability. + * + * This file centralizes UI dependencies so the component can be easily + * copied to another project by updating these imports to match the target + * project's component library paths. + */ "use client"; export { Button } from "../../ui/button"; @@ -14,5 +21,3 @@ export { TooltipTrigger, } from "../../ui/tooltip"; export { Badge } from "../../ui/badge"; - -// We will use plain /