From 0d5d920764bd6df4706c6db86227d29773582d56 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 23 Jul 2026 11:12:51 +0530 Subject: [PATCH] feat: add PromptInput AI element Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01C7QST4WHS5DmTqgJkvQoyf --- apps/www/src/components/docs/search.tsx | 2 +- .../src/content/docs/ai-elements/meta.json | 4 + .../docs/ai-elements/prompt-input/demo.ts | 87 +++++++ .../docs/ai-elements/prompt-input/index.mdx | 106 ++++++++ .../docs/ai-elements/prompt-input/props.ts | 95 ++++++++ apps/www/src/content/docs/meta.json | 2 +- .../__tests__/prompt-input.test.tsx | 227 ++++++++++++++++++ .../components/prompt-input/index.tsx | 3 + .../prompt-input/prompt-input-context.tsx | 27 +++ .../prompt-input/prompt-input-parts.tsx | 56 +++++ .../prompt-input/prompt-input-root.tsx | 145 +++++++++++ .../prompt-input/prompt-input-submit.tsx | 54 +++++ .../prompt-input/prompt-input-textarea.tsx | 73 ++++++ .../prompt-input/prompt-input.module.css | 130 ++++++++++ .../components/prompt-input/prompt-input.tsx | 18 ++ packages/raystack/index.tsx | 5 + 16 files changed, 1032 insertions(+), 2 deletions(-) create mode 100644 apps/www/src/content/docs/ai-elements/meta.json create mode 100644 apps/www/src/content/docs/ai-elements/prompt-input/demo.ts create mode 100644 apps/www/src/content/docs/ai-elements/prompt-input/index.mdx create mode 100644 apps/www/src/content/docs/ai-elements/prompt-input/props.ts create mode 100644 packages/raystack/components/prompt-input/__tests__/prompt-input.test.tsx create mode 100644 packages/raystack/components/prompt-input/index.tsx create mode 100644 packages/raystack/components/prompt-input/prompt-input-context.tsx create mode 100644 packages/raystack/components/prompt-input/prompt-input-parts.tsx create mode 100644 packages/raystack/components/prompt-input/prompt-input-root.tsx create mode 100644 packages/raystack/components/prompt-input/prompt-input-submit.tsx create mode 100644 packages/raystack/components/prompt-input/prompt-input-textarea.tsx create mode 100644 packages/raystack/components/prompt-input/prompt-input.module.css create mode 100644 packages/raystack/components/prompt-input/prompt-input.tsx diff --git a/apps/www/src/components/docs/search.tsx b/apps/www/src/components/docs/search.tsx index 3dee20f72..84a33fd87 100644 --- a/apps/www/src/components/docs/search.tsx +++ b/apps/www/src/components/docs/search.tsx @@ -86,7 +86,7 @@ export default function DocsSearch({ pageTree }: { pageTree: Root }) { excluded until the user searches. */ const items = flattened.reduce>((acc, item) => { const folder = getFolderFromUrl(item.url); - if (folder === 'components') return acc; + if (folder === 'components' || folder === 'ai-elements') return acc; if (!acc[folder]) { acc[folder] = []; } diff --git a/apps/www/src/content/docs/ai-elements/meta.json b/apps/www/src/content/docs/ai-elements/meta.json new file mode 100644 index 000000000..88524ea4a --- /dev/null +++ b/apps/www/src/content/docs/ai-elements/meta.json @@ -0,0 +1,4 @@ +{ + "title": "AI Elements", + "pages": ["prompt-input"] +} diff --git a/apps/www/src/content/docs/ai-elements/prompt-input/demo.ts b/apps/www/src/content/docs/ai-elements/prompt-input/demo.ts new file mode 100644 index 000000000..eb9e82cbb --- /dev/null +++ b/apps/www/src/content/docs/ai-elements/prompt-input/demo.ts @@ -0,0 +1,87 @@ +'use client'; + +export const preview = { + type: 'code', + code: `function PromptInputPreview() { + const [status, setStatus] = React.useState('idle'); + const timerRef = React.useRef(null); + + React.useEffect(() => () => clearTimeout(timerRef.current), []); + + return ( +
+ { + event.currentTarget.reset(); + setStatus('streaming'); + timerRef.current = setTimeout(() => setStatus('idle'), 2500); + }} + onStop={() => { + clearTimeout(timerRef.current); + setStatus('idle'); + }} + > + + + Skills + 📎 + + + +
+ ); +}` +}; + +export const attachmentsDemo = { + type: 'code', + code: `
+ console.log(value)}> + + {}} /> + + + + + + + +
` +}; + +export const controlledDemo = { + type: 'code', + code: `function ControlledPromptInput() { + const [value, setValue] = React.useState(''); + + return ( + + setValue('')} + > + + + + + + {value.length} characters + + ); +}` +}; + +export const disabledDemo = { + type: 'code', + code: `
+ + + + Skills + + + +
` +}; diff --git a/apps/www/src/content/docs/ai-elements/prompt-input/index.mdx b/apps/www/src/content/docs/ai-elements/prompt-input/index.mdx new file mode 100644 index 000000000..34dcd6eb3 --- /dev/null +++ b/apps/www/src/content/docs/ai-elements/prompt-input/index.mdx @@ -0,0 +1,106 @@ +--- +title: PromptInput +description: A composer for AI chat and standalone "Ask anything" boxes — auto-growing textarea, toolbar slots, and a status-aware submit button. +source: packages/raystack/components/prompt-input +tag: new +--- + +import { preview, attachmentsDemo, controlledDemo, disabledDemo } from "./demo.ts"; + + + +## Anatomy + +Import and assemble the composer. The root is a form that owns submit +semantics: Enter submits, Shift+Enter inserts a newline, and IME composition +is respected. + +```tsx +import { PromptInput } from '@raystack/apsara' + + + {/* attachment previews */} + + + Skills + + + +``` + +`PromptInput` is purely presentational: it never talks to a model or manages a +request. Pass the lifecycle in through `status` and react to `onSubmit` / +`onStop` — it works with the AI SDK's `useChat`, a custom SSE client, or +anything else. + +## API Reference + +### Root + +The `
` that holds the value and submit semantics. + + + +### Textarea + +The text field, built on `TextArea variant="borderless"`. It grows with its +content up to a max-height cap (override with +`--prompt-input-textarea-max-height`), then scrolls. + + + +### Header + +A slot row above the textarea, usually for `Chat.Attachment` previews. Hidden +while empty. + +### Footer + +The bottom toolbar row. Hidden while empty. + +### Button + +A small neutral-ghost `Button` for the toolbar — attach, skills, model pickers +and similar consumer-owned controls. + + + +### Submit + +The trailing send button. It renders ↑ when idle, disables itself while the +input is empty, shows a spinner while `status="submitted"`, and flips to a +stop square that calls `onStop` while `status="streaming"`. + + + +## Examples + +### Attachments in the header + +Attachment previews are presentational (`Chat.Attachment`); file picking, +drag-drop and uploads belong to your app. + + + +### Controlled value + +Control `value` to sync the draft elsewhere — persistence, slash-command +menus, mention pickers. + + + +### Disabled + + + +## Accessibility + +- The root is a native ``; `PromptInput.Submit` is a real submit button, + so assistive tech announces the submit affordance for free. +- Enter submits and Shift+Enter inserts a newline. During IME composition + Enter confirms the composition instead of submitting. +- While a response is in flight the submit button becomes `type="button"` with + an updated accessible name ("Stop response") so it can never resubmit the + form. +- The composer frame carries the focus treatment (`:focus-within`), while the + borderless textarea suppresses its own duplicate focus ring. diff --git a/apps/www/src/content/docs/ai-elements/prompt-input/props.ts b/apps/www/src/content/docs/ai-elements/prompt-input/props.ts new file mode 100644 index 000000000..e247b5f57 --- /dev/null +++ b/apps/www/src/content/docs/ai-elements/prompt-input/props.ts @@ -0,0 +1,95 @@ +import type React from 'react'; + +export interface PromptInputProps { + /** The composed text (controlled). */ + value?: string; + + /** + * The initial text when uncontrolled. + * @defaultValue "" + */ + defaultValue?: string; + + /** Called when the composed text changes. */ + onValueChange?: (value: string) => void; + + /** + * Called with the trimmed text when the prompt is submitted — Enter in the + * textarea or a click on `PromptInput.Submit`. Call + * `event.currentTarget.reset()` to clear the input after sending. + */ + onSubmit?: (value: string, event: React.FormEvent) => void; + + /** + * Called when `PromptInput.Submit` is pressed while `status` is + * `"submitted"` or `"streaming"`. + */ + onStop?: () => void; + + /** + * The consumer-owned request lifecycle. Drives `PromptInput.Submit`: + * `"idle"`/`"error"` show a send arrow, `"submitted"` a spinner and + * `"streaming"` a stop square (both routed to `onStop`). + * @defaultValue "idle" + */ + status?: 'idle' | 'submitted' | 'streaming' | 'error'; + + /** + * Disables the whole composer. + * @defaultValue false + */ + disabled?: boolean; + + /** Custom CSS class names. */ + className?: string; +} + +export interface PromptInputTextareaProps { + /** Placeholder shown while empty. @defaultValue "Write a message…" */ + placeholder?: string; + + /** Disables just the textarea. Inherits the root `disabled` by default. */ + disabled?: boolean; + + /** Custom CSS class names. */ + className?: string; +} + +export interface PromptInputSubmitProps { + /** + * Replaces the status-derived icon (send arrow, spinner or stop square). + */ + children?: React.ReactNode; + + /** + * Accessible name. Defaults to "Send message", or "Stop response" while a + * response is in flight. + */ + 'aria-label'?: string; + + /** Custom CSS class names. */ + className?: string; +} + +export interface PromptInputButtonProps { + /** + * Visual style, forwarded to `Button`. + * @defaultValue "ghost" + */ + variant?: 'solid' | 'outline' | 'ghost' | 'text'; + + /** + * Color, forwarded to `Button`. + * @defaultValue "neutral" + */ + color?: 'accent' | 'danger' | 'neutral' | 'success'; + + /** + * Size, forwarded to `Button`. + * @defaultValue "small" + */ + size?: 'small' | 'normal'; + + /** Custom CSS class names. */ + className?: string; +} diff --git a/apps/www/src/content/docs/meta.json b/apps/www/src/content/docs/meta.json index 8267e5fcb..ed1d989d7 100644 --- a/apps/www/src/content/docs/meta.json +++ b/apps/www/src/content/docs/meta.json @@ -1,3 +1,3 @@ { - "pages": ["(overview)", "theme", "components"] + "pages": ["(overview)", "theme", "ai-elements", "components"] } diff --git a/packages/raystack/components/prompt-input/__tests__/prompt-input.test.tsx b/packages/raystack/components/prompt-input/__tests__/prompt-input.test.tsx new file mode 100644 index 000000000..a13c6941e --- /dev/null +++ b/packages/raystack/components/prompt-input/__tests__/prompt-input.test.tsx @@ -0,0 +1,227 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { PromptInput } from '../prompt-input'; + +const BasicPromptInput = ( + props: Partial[0]> +) => ( + + + + + Skills + + + +); + +describe('PromptInput', () => { + describe('Basic Rendering', () => { + it('renders the textarea, toolbar button and submit', () => { + render(); + + expect(screen.getByPlaceholderText('Reply…')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Skills' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Send message' }) + ).toBeInTheDocument(); + }); + + it('applies custom className to the root form', () => { + const { container } = render( + + + + ); + expect(container.querySelector('form')).toHaveClass('custom-root'); + }); + + it('renders header and footer slots', () => { + render(); + expect(screen.getByTestId('header')).toBeInTheDocument(); + expect(screen.getByTestId('footer')).toBeInTheDocument(); + }); + + it('throws when parts are used outside the root', () => { + const spy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + expect(() => render()).toThrow( + /must be used within / + ); + spy.mockRestore(); + }); + }); + + describe('Value and submission', () => { + it('updates the value while typing and calls onValueChange', async () => { + const onValueChange = vi.fn(); + const user = userEvent.setup(); + render(); + + const textarea = screen.getByPlaceholderText('Reply…'); + await user.type(textarea, 'hello'); + + expect(textarea).toHaveValue('hello'); + expect(onValueChange).toHaveBeenLastCalledWith('hello'); + }); + + it('submits the trimmed value on Enter', async () => { + const onSubmit = vi.fn(); + const user = userEvent.setup(); + render(); + + const textarea = screen.getByPlaceholderText('Reply…'); + await user.type(textarea, ' hello world '); + await user.keyboard('{Enter}'); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0]).toBe('hello world'); + }); + + it('inserts a newline on Shift+Enter instead of submitting', async () => { + const onSubmit = vi.fn(); + const user = userEvent.setup(); + render(); + + const textarea = screen.getByPlaceholderText('Reply…'); + await user.type(textarea, 'line one'); + await user.keyboard('{Shift>}{Enter}{/Shift}'); + await user.type(textarea, 'line two'); + + expect(onSubmit).not.toHaveBeenCalled(); + expect(textarea).toHaveValue('line one\nline two'); + }); + + it('does not submit while composing with an IME', async () => { + const onSubmit = vi.fn(); + render(); + + const textarea = screen.getByPlaceholderText('Reply…'); + fireEvent.change(textarea, { target: { value: 'かな' } }); + fireEvent.keyDown(textarea, { key: 'Enter', isComposing: true }); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('does not submit when the value is empty or whitespace', async () => { + const onSubmit = vi.fn(); + const user = userEvent.setup(); + render(); + + const textarea = screen.getByPlaceholderText('Reply…'); + await user.type(textarea, ' '); + await user.keyboard('{Enter}'); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('submits via the submit button', async () => { + const onSubmit = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.type(screen.getByPlaceholderText('Reply…'), 'hi'); + await user.click(screen.getByRole('button', { name: 'Send message' })); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0]).toBe('hi'); + }); + + it('supports a controlled value', async () => { + const onValueChange = vi.fn(); + const user = userEvent.setup(); + render( + + ); + + const textarea = screen.getByPlaceholderText('Reply…'); + expect(textarea).toHaveValue('controlled'); + await user.type(textarea, '!'); + // Parent did not update the prop, so the value stays. + expect(textarea).toHaveValue('controlled'); + expect(onValueChange).toHaveBeenCalledWith('controlled!'); + }); + + it('clears the value when the form is reset from onSubmit', async () => { + const onSubmit = vi.fn((_value, event) => event.currentTarget.reset()); + const user = userEvent.setup(); + render(); + + const textarea = screen.getByPlaceholderText('Reply…'); + await user.type(textarea, 'clear me'); + await user.keyboard('{Enter}'); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(textarea).toHaveValue(''); + }); + }); + + describe('Submit button states', () => { + it('is disabled while the value is empty', async () => { + const user = userEvent.setup(); + render(); + + const submit = screen.getByRole('button', { name: 'Send message' }); + expect(submit).toBeDisabled(); + + await user.type(screen.getByPlaceholderText('Reply…'), 'x'); + expect(submit).toBeEnabled(); + }); + + it('becomes a stop control while streaming', async () => { + const onStop = vi.fn(); + const onSubmit = vi.fn(); + const user = userEvent.setup(); + render( + + ); + + const stop = screen.getByRole('button', { name: 'Stop response' }); + expect(stop).toBeEnabled(); + expect(stop).toHaveAttribute('type', 'button'); + + await user.click(stop); + expect(onStop).toHaveBeenCalledTimes(1); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('shows the stop control while submitted', () => { + render(); + expect( + screen.getByRole('button', { name: 'Stop response' }) + ).toBeInTheDocument(); + }); + + it('does not submit on Enter while streaming', async () => { + const onSubmit = vi.fn(); + const user = userEvent.setup(); + render(); + + const textarea = screen.getByPlaceholderText('Reply…'); + await user.type(textarea, 'queued'); + await user.keyboard('{Enter}'); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + }); + + describe('Disabled state', () => { + it('disables the textarea, buttons and submit', () => { + render(); + + expect(screen.getByPlaceholderText('Reply…')).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Skills' })).toBeDisabled(); + expect( + screen.getByRole('button', { name: 'Send message' }) + ).toBeDisabled(); + }); + }); +}); diff --git a/packages/raystack/components/prompt-input/index.tsx b/packages/raystack/components/prompt-input/index.tsx new file mode 100644 index 000000000..aa01b0f25 --- /dev/null +++ b/packages/raystack/components/prompt-input/index.tsx @@ -0,0 +1,3 @@ +export { PromptInput } from './prompt-input'; +export type { PromptInputStatus } from './prompt-input-context'; +export type { PromptInputRootProps as PromptInputProps } from './prompt-input-root'; diff --git a/packages/raystack/components/prompt-input/prompt-input-context.tsx b/packages/raystack/components/prompt-input/prompt-input-context.tsx new file mode 100644 index 000000000..97154ce74 --- /dev/null +++ b/packages/raystack/components/prompt-input/prompt-input-context.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { createContext, RefObject, useContext } from 'react'; + +export type PromptInputStatus = 'idle' | 'submitted' | 'streaming' | 'error'; + +export interface PromptInputContextValue { + value: string; + setValue: (value: string) => void; + status: PromptInputStatus; + disabled: boolean; + onStop?: () => void; + textareaRef: RefObject; + requestSubmit: () => void; +} + +export const PromptInputContext = createContext( + null +); + +export function usePromptInputContext(part: string): PromptInputContextValue { + const context = useContext(PromptInputContext); + if (!context) { + throw new Error(`PromptInput.${part} must be used within `); + } + return context; +} diff --git a/packages/raystack/components/prompt-input/prompt-input-parts.tsx b/packages/raystack/components/prompt-input/prompt-input-parts.tsx new file mode 100644 index 000000000..020084304 --- /dev/null +++ b/packages/raystack/components/prompt-input/prompt-input-parts.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { cx } from 'class-variance-authority'; +import { ComponentProps } from 'react'; +import { Button } from '../button'; +import styles from './prompt-input.module.css'; +import { usePromptInputContext } from './prompt-input-context'; + +export interface PromptInputHeaderProps extends ComponentProps<'div'> {} + +export function PromptInputHeader({ + className, + ...props +}: PromptInputHeaderProps) { + return
; +} + +PromptInputHeader.displayName = 'PromptInput.Header'; + +export interface PromptInputFooterProps extends ComponentProps<'div'> {} + +export function PromptInputFooter({ + className, + ...props +}: PromptInputFooterProps) { + return
; +} + +PromptInputFooter.displayName = 'PromptInput.Footer'; + +export interface PromptInputButtonProps extends ComponentProps {} + +export function PromptInputButton({ + className, + variant = 'ghost', + color = 'neutral', + size = 'small', + type = 'button', + disabled, + ...props +}: PromptInputButtonProps) { + const context = usePromptInputContext('Button'); + return ( + + ); +} + +PromptInputSubmit.displayName = 'PromptInput.Submit'; diff --git a/packages/raystack/components/prompt-input/prompt-input-textarea.tsx b/packages/raystack/components/prompt-input/prompt-input-textarea.tsx new file mode 100644 index 000000000..88f989e51 --- /dev/null +++ b/packages/raystack/components/prompt-input/prompt-input-textarea.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { useMergedRefs } from '@base-ui/utils/useMergedRefs'; +import { cx } from 'class-variance-authority'; +import { ChangeEvent, KeyboardEvent, useLayoutEffect, useRef } from 'react'; +import { TextArea, type TextAreaProps } from '../text-area/text-area'; +import styles from './prompt-input.module.css'; +import { usePromptInputContext } from './prompt-input-context'; + +export interface PromptInputTextareaProps + extends Omit< + TextAreaProps, + 'value' | 'defaultValue' | 'onValueChange' | 'variant' | 'size' | 'rows' + > {} + +export function PromptInputTextarea({ + className, + onChange, + onKeyDown, + disabled, + placeholder = 'Write a message…', + ref, + ...props +}: PromptInputTextareaProps) { + const context = usePromptInputContext('Textarea'); + const localRef = useRef(null); + const mergedRef = useMergedRefs(localRef, context.textareaRef, ref); + + const resolvedDisabled = disabled ?? context.disabled; + + // Auto-grow with content: reset to a single row, then take the scroll + // height. The CSS max-height caps growth and hands off to scrolling. + // biome-ignore lint/correctness/useExhaustiveDependencies: re-measure whenever the value changes. + useLayoutEffect(() => { + const el = localRef.current; + if (!el) return; + el.style.height = 'auto'; + el.style.height = `${el.scrollHeight}px`; + }, [context.value]); + + const handleChange = (event: ChangeEvent) => { + onChange?.(event); + context.setValue(event.target.value); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + onKeyDown?.(event); + if (event.defaultPrevented) return; + if (event.key !== 'Enter' || event.shiftKey) return; + // Let IME composition confirm with Enter instead of sending. + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + event.preventDefault(); + context.requestSubmit(); + }; + + return ( +