From d1c443ed214928697266880e4119cf5ec8d199ee Mon Sep 17 00:00:00 2001 From: balint Date: Mon, 27 Apr 2026 14:46:51 +0100 Subject: [PATCH 1/3] feat: ai prompt input --- .../src/components/SearchDialog.tsx | 7 + .../src/components/SidebarNav.tsx | 49 ++- .../src/components/demos/PromptInputDemo.tsx | 318 ++++++++++++++++++ .../src/components/kumo/prompt-input/index.ts | 10 + .../kumo/prompt-input/prompt-input.tsx | 278 +++++++++++++++ .../src/pages/ai-blocks/prompt-input.mdx | 114 +++++++ .../scripts/component-registry/discovery.ts | 3 + .../kumo/src/blocks/prompt-input/index.ts | 10 + .../src/blocks/prompt-input/prompt-input.tsx | 298 ++++++++++++++++ 9 files changed, 1086 insertions(+), 1 deletion(-) create mode 100644 packages/kumo-docs-astro/src/components/demos/PromptInputDemo.tsx create mode 100644 packages/kumo-docs-astro/src/components/kumo/prompt-input/index.ts create mode 100644 packages/kumo-docs-astro/src/components/kumo/prompt-input/prompt-input.tsx create mode 100644 packages/kumo-docs-astro/src/pages/ai-blocks/prompt-input.mdx create mode 100644 packages/kumo/src/blocks/prompt-input/index.ts create mode 100644 packages/kumo/src/blocks/prompt-input/prompt-input.tsx diff --git a/packages/kumo-docs-astro/src/components/SearchDialog.tsx b/packages/kumo-docs-astro/src/components/SearchDialog.tsx index e83ed87d08..68e173fcb6 100644 --- a/packages/kumo-docs-astro/src/components/SearchDialog.tsx +++ b/packages/kumo-docs-astro/src/components/SearchDialog.tsx @@ -126,6 +126,13 @@ const STATIC_PAGES: Array<{ category: "Components", type: "component", }, + { + name: "Prompt Input", + description: "A text area optimized for AI prompt entry.", + url: "/ai-blocks/prompt-input", + category: "AI Blocks", + type: "block", + }, ]; /** Better descriptions from the Astro doc pages */ diff --git a/packages/kumo-docs-astro/src/components/SidebarNav.tsx b/packages/kumo-docs-astro/src/components/SidebarNav.tsx index acac10ad9e..f3380a89f9 100644 --- a/packages/kumo-docs-astro/src/components/SidebarNav.tsx +++ b/packages/kumo-docs-astro/src/components/SidebarNav.tsx @@ -97,6 +97,11 @@ const blockItems: NavItem[] = [ { label: "Delete Resource", href: "/blocks/delete-resource" }, ]; +// AI Blocks are specialized blocks for AI-related UI patterns +const aiBlockItems: NavItem[] = [ + { label: "Prompt Input", href: "/ai-blocks/prompt-input" }, +]; + // Build info injected via Vite define in astro.config.mjs declare const __DOCS_VERSION__: string; declare const __BUILD_COMMIT__: string; @@ -116,6 +121,7 @@ export function SidebarNav({ currentPath }: SidebarNavProps) { const [componentsOpen, setComponentsOpen] = useState(true); const [chartsOpen, setChartsOpen] = useState(true); const [blocksOpen, setBlocksOpen] = useState(true); + const [aiBlocksOpen, setAiBlocksOpen] = useState(true); const activePath = normalizePathname(currentPath); @@ -303,7 +309,7 @@ export function SidebarNav({ currentPath }: SidebarNavProps) { -
+
{/* Blocks Section */}
+ +
+ {/* AI Blocks Section */} + + +
); diff --git a/packages/kumo-docs-astro/src/components/demos/PromptInputDemo.tsx b/packages/kumo-docs-astro/src/components/demos/PromptInputDemo.tsx new file mode 100644 index 0000000000..e91243b098 --- /dev/null +++ b/packages/kumo-docs-astro/src/components/demos/PromptInputDemo.tsx @@ -0,0 +1,318 @@ +import { useState } from "react"; +import { Button, DropdownMenu } from "@cloudflare/kumo"; +import { + BrainIcon, + FileIcon, + LightningIcon, + SparkleIcon, + XIcon, +} from "@phosphor-icons/react"; +import { PromptInput } from "../kumo/prompt-input"; + +// --------------------------------------------------------------------------- +// Shared +// --------------------------------------------------------------------------- + +const CONTEXTS = ["dashboard-tile-3", "api-usage-chart"]; + +const MODEL_OPTIONS = [ + { value: "fast", label: "Fast", icon: LightningIcon }, + { value: "balanced", label: "Balanced", icon: SparkleIcon }, + { value: "quality", label: "Quality", icon: BrainIcon }, +] as const; + +type Model = (typeof MODEL_OPTIONS)[number]["value"]; + +// --------------------------------------------------------------------------- +// Prompt Input — full-fledged, base size +// --------------------------------------------------------------------------- + +/** Full-featured prompt input with context badges and a radio dropdown. */ +export function PromptInputDemo() { + const [value, setValue] = useState(""); + const [contexts, setContexts] = useState(CONTEXTS); + const [model, setModel] = useState("balanced"); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setValue(""); + }; + + const removeContext = (id: string) => { + setContexts((prev) => prev.filter((c) => c !== id)); + }; + + const selected = MODEL_OPTIONS.find((o) => o.value === model)!; + const SelectedIcon = selected.icon; + + return ( +
+ + + {contexts.map((id) => ( + + ))} + + setValue(e.target.value)} + autoResize + /> + + + } + className="rounded-full text-kumo-subtle hover:text-kumo-default" + > + {selected.label} + + } + /> + + setModel(v as Model)} + > + {MODEL_OPTIONS.map((opt) => ( + + {opt.label} + + + ))} + + + + + + +
+ ); +} + +// --------------------------------------------------------------------------- +// Prompt Input — full-fledged, large size +// --------------------------------------------------------------------------- + +/** Full-featured large prompt input with context badges and a radio dropdown. */ +export function PromptInputLargeDemo() { + const [value, setValue] = useState(""); + const [contexts, setContexts] = useState(CONTEXTS); + const [model, setModel] = useState("balanced"); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setValue(""); + }; + + const removeContext = (id: string) => { + setContexts((prev) => prev.filter((c) => c !== id)); + }; + + const selected = MODEL_OPTIONS.find((o) => o.value === model)!; + const SelectedIcon = selected.icon; + + return ( +
+ + + {contexts.map((id) => ( + + ))} + + setValue(e.target.value)} + autoResize + /> + + + } + className="rounded-full shadow-none text-kumo-subtle hover:text-kumo-default" + > + {selected.label} + + } + /> + + setModel(v as Model)} + > + {MODEL_OPTIONS.map((opt) => ( + + {opt.label} + + + ))} + + + + + + +
+ ); +} + +// --------------------------------------------------------------------------- +// Compact — base size +// --------------------------------------------------------------------------- + +/** Compact prompt input — base size with model dropdown. */ +export function PromptInputCompactDemo() { + const [value, setValue] = useState(""); + const [model, setModel] = useState("balanced"); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setValue(""); + }; + + const selected = MODEL_OPTIONS.find((o) => o.value === model)!; + const SelectedIcon = selected.icon; + + return ( +
+ + + } + /> + } + /> + + setModel(v as Model)} + > + {MODEL_OPTIONS.map((opt) => ( + + {opt.label} + + + ))} + + + + setValue(e.target.value)} + /> + + + + +
+ ); +} + +// --------------------------------------------------------------------------- +// Compact — large size +// --------------------------------------------------------------------------- + +/** Compact prompt input — large size with model dropdown. */ +export function PromptInputCompactLargeDemo() { + const [value, setValue] = useState(""); + const [model, setModel] = useState("balanced"); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setValue(""); + }; + + const selected = MODEL_OPTIONS.find((o) => o.value === model)!; + const SelectedIcon = selected.icon; + + return ( +
+ + + } + /> + } + /> + + setModel(v as Model)} + > + {MODEL_OPTIONS.map((opt) => ( + + {opt.label} + + + ))} + + + + setValue(e.target.value)} + /> + + + + +
+ ); +} diff --git a/packages/kumo-docs-astro/src/components/kumo/prompt-input/index.ts b/packages/kumo-docs-astro/src/components/kumo/prompt-input/index.ts new file mode 100644 index 0000000000..c2439a5f40 --- /dev/null +++ b/packages/kumo-docs-astro/src/components/kumo/prompt-input/index.ts @@ -0,0 +1,10 @@ +export { + PromptInput, + KUMO_PROMPT_INPUT_VARIANTS, + KUMO_PROMPT_INPUT_DEFAULT_VARIANTS, + type PromptInputProps, + type PromptInputTextareaProps, + type KumoPromptInputSize, + type KumoPromptInputVariant, + type KumoPromptInputVariantsProps, +} from "./prompt-input"; diff --git a/packages/kumo-docs-astro/src/components/kumo/prompt-input/prompt-input.tsx b/packages/kumo-docs-astro/src/components/kumo/prompt-input/prompt-input.tsx new file mode 100644 index 0000000000..fa4518b88a --- /dev/null +++ b/packages/kumo-docs-astro/src/components/kumo/prompt-input/prompt-input.tsx @@ -0,0 +1,278 @@ +import { Button, cn, Textarea } from "@cloudflare/kumo"; +import { ArrowUpIcon, StopIcon } from "@phosphor-icons/react"; +import React, { + createContext, + useContext, + forwardRef, + useRef, + useEffect, + useImperativeHandle, +} from "react"; + +export const KUMO_PROMPT_INPUT_VARIANTS = { + size: { + base: { + classes: "rounded-2xl gap-1.5 p-3 [&>textarea]:p-1", + description: "Default prompt input size", + }, + lg: { + classes: "[&>textarea]:text-lg gap-2 p-4 [&>textarea]:p-2 rounded-3xl", + description: "Large prompt input with bigger text", + }, + }, + variant: { + default: { + classes: "", + description: "Standard stacked layout with textarea above footer", + }, + compact: { + classes: "py-2 pl-2 pr-2 [&>textarea]:p-0! gap-2", + description: "Inline single-row layout for space-constrained contexts", + }, + }, +} as const; + +export const KUMO_PROMPT_INPUT_DEFAULT_VARIANTS = { + size: "base", + variant: "default", +} as const; + +export type KumoPromptInputSize = keyof typeof KUMO_PROMPT_INPUT_VARIANTS.size; +export type KumoPromptInputVariant = + keyof typeof KUMO_PROMPT_INPUT_VARIANTS.variant; + +export interface KumoPromptInputVariantsProps { + size?: KumoPromptInputSize; + variant?: KumoPromptInputVariant; +} + +export type PromptInputProps = { + onSubmit?: React.FormEventHandler; + className?: string; + children?: React.ReactNode; +} & KumoPromptInputVariantsProps & + React.HTMLAttributes; + +export type PromptInputTextareaProps = { + className?: string; + autoResize?: boolean; + submitOnEnter?: boolean; +} & React.ComponentProps; + +type PromptInputStyleContextValue = { + size: KumoPromptInputSize; + variant: KumoPromptInputVariant; + textareaRef?: React.MutableRefObject; +}; + +const PromptInputStyleContext = + createContext(null); + +const usePromptInputStyle = () => { + const context = useContext(PromptInputStyleContext); + return context ?? { size: "base" as const, variant: "default" as const }; +}; + +const SIZE_MAP = { + base: { button: "sm" as const }, + lg: { button: "base" as const }, +} as const; + +const PromptInputForm = forwardRef( + function PromptInputForm( + { + onSubmit, + size = KUMO_PROMPT_INPUT_DEFAULT_VARIANTS.size, + variant = KUMO_PROMPT_INPUT_DEFAULT_VARIANTS.variant, + className, + children, + ...rest + }, + ref, + ) { + const textareaRef = useRef(null); + + useImperativeHandle(ref, () => textareaRef.current as HTMLTextAreaElement); + + const BASE_STYLES = cn( + "relative ring-1 bg-kumo-control ring-kumo-line has-[textarea:focus]:ring-kumo-brand/50 has-[textarea:focus]:ring-[1.5px] transition-all", + variant === "compact" ? "flex flex-row items-center" : "flex flex-col", + ); + + const handleContainerClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget && textareaRef.current) { + textareaRef.current.focus(); + } + }; + + return ( + +
+
+ {children} +
+
+
+ ); + }, +); + +const PromptInputHeader: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const context = useContext(PromptInputStyleContext); + if (context?.variant === "compact") return null; + if (React.Children.count(children) === 0) return null; + + return ( +
+ {children} +
+ ); +}; + +const PromptInputTextarea: React.FC = ({ + className, + autoResize = false, + submitOnEnter = true, + ...props +}) => { + const context = useContext(PromptInputStyleContext); + const fallbackRef = useRef(null); + const textareaRef = context?.textareaRef ?? fallbackRef; + const isCompact = context?.variant === "compact"; + const shouldAutoResize = autoResize && !isCompact; + + useEffect(() => { + if (!shouldAutoResize || !textareaRef.current) return; + + const textarea = textareaRef.current; + const MAX_HEIGHT = 256; + + const adjustHeight = () => { + textarea.style.height = "auto"; + const next = textarea.scrollHeight; + if (next >= MAX_HEIGHT) { + textarea.style.height = `${MAX_HEIGHT}px`; + textarea.style.overflowY = "auto"; + } else { + textarea.style.height = `${next}px`; + textarea.style.overflowY = "hidden"; + } + }; + + adjustHeight(); + textarea.addEventListener("input", adjustHeight); + + const observer = new ResizeObserver(adjustHeight); + observer.observe(textarea); + + return () => { + textarea.removeEventListener("input", adjustHeight); + observer.disconnect(); + }; + }, [shouldAutoResize, props.value, textareaRef]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (submitOnEnter && e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + const form = e.currentTarget.form; + if (form) { + form.requestSubmit(); + } + } + props.onKeyDown?.(e); + }; + + const BASE_STYLES = cn( + "resize-none w-full border-none ring-0 focus:ring-0 focus:ring-transparent bg-transparent outline-none focus:outline-none p-0 !pb-0 m-0 rounded-none", + isCompact ? "flex-1" : "max-h-64 overflow-y-auto", + shouldAutoResize && "overflow-hidden", + ); + + return ( +