← All proposals

Chat Input

A labelled textarea with a toolbar inside the same bordered box:

Net-new componentOpen in Storybook

proposals/ChatInput/ChatInput.stories.tsx213 lines · View on GitHub
import { ComponentProps, useState } from 'react';

import { Meta, StoryObj } from '@storybook/react-vite';

import { SlotPlaceholder } from '../_story-helpers/SlotPlaceholder';
import { IconButton } from '../IconButton/IconButton';
import { MenuButton } from '../MenuButton/MenuButton';
import {
	ModelSelector,
	ModelSelectorOption,
} from '../ModelSelector/ModelSelector';
import { SendButton, SendButtonState } from '../SendButton/SendButton';
import { ChatInput, ChatInputProps } from './ChatInput';

type MenuItems = ComponentProps<typeof MenuButton>['menuItems'];

const addItems: MenuItems = [
	{
		description: 'Upload from your computer',
		iconName: 'paperclip',
		label: 'Add photos & files',
	},
	{ label: 'Menu item 2' },
	{ label: 'Menu item 3' },
	{ label: 'Menu item 4' },
];

// Sample models: Zappy first, then other agents.
const MODELS: ModelSelectorOption[] = [
	{ id: 'zappy', label: 'Zappy' },
	{ id: 'haiku', label: 'Claude Haiku 5.5' },
	{ id: 'sonnet', label: 'Claude Sonnet 5.5' },
	{ id: 'opus', label: 'Claude Opus 5.5' },
];

const SEND_LABELS: Record<SendButtonState, string> = {
	send: 'Send message',
	stop: 'Stop generating',
	working: 'Sending message',
};

// Wires the toolbar the way a chat screen would: Send is disabled until there
// is text, then chains send, working, stop.
const ChatInputWithToolbar = (args: ChatInputProps) => {
	const [value, setValue] = useState('');
	const [state, setState] = useState<SendButtonState>('send');
	const [model, setModel] = useState(MODELS[0].id);
	const [fileNames, setFileNames] = useState<string[]>([]);

	const handleSend = () => {
		if (state === 'send') {
			setState('working');
			setTimeout(() => setState('stop'), 400);
		} else if (state === 'stop') {
			setState('send');
			setValue('');
		}
	};

	const isCompact = args.variant === 'compact';

	return (
		<ChatInput
			{...args}
			dropLabel={args.dropLabel}
			message={
				isCompact
					? undefined
					: fileNames.length
						? `Attached: ${fileNames.join(', ')}`
						: args.message
			}
			onFilesDrop={(files) =>
				setFileNames((names) => [...names, ...files.map((f) => f.name)])
			}
			actionsEnd={
				<>
					<ModelSelector
						id='chat-input-model'
						onValueChange={setModel}
						options={MODELS}
						size='xs'
						value={model}
					/>
					<IconButton ariaLabel='Voice input' iconName='microphoneLines' />
					<SendButton
						ariaLabel={SEND_LABELS[state]}
						disabled={state === 'send' && !value.trim()}
						onClick={handleSend}
						state={state}
					/>
				</>
			}
			actionsStart={
				<>
					<MenuButton
						iconName='plus'
						iconOnly
						id='chat-input-add'
						label='Add'
						menuItems={addItems}
						size='s'
					/>
					{!isCompact && (
						<IconButton ariaLabel='Attach file' iconName='paperclip' />
					)}
				</>
			}
			onChange={(e) => setValue(e.target.value)}
			value={value}
		/>
	);
};

const meta: Meta<typeof ChatInput> = {
	tags: ['new-pattern'],
	title: 'Proposals/Chat Input',
	component: ChatInput,
	parameters: {
		docs: {
			description: {
				component:
					'**New pattern:** not in PDS v2 today. A `Textarea` with a toolbar inside the field, for composing a prompt. Put add and attach buttons at the start, and a model selector and `SendButton` at the end. Pass `onFilesDrop` to accept files dragged onto the field. Set `variant` to `compact` for a single-line bar with the controls on the same row.',
			},
		},
	},
	argTypes: {
		className: { table: { category: 'Base attributes' } },
		disabled: { control: 'boolean', table: { category: 'Base attributes' } },
		dropLabel: { control: 'text', table: { category: 'Content' } },
		id: { table: { category: 'Base attributes' } },
		isResizable: {
			control: 'boolean',
			table: { category: 'Component configuration' },
		},
		label: { control: 'text', table: { category: 'Content' } },
		message: { control: 'text', table: { category: 'Content' } },
		placeholder: { control: 'text', table: { category: 'Content' } },
		rows: { control: 'number', table: { category: 'Component configuration' } },
		showLabel: {
			control: 'boolean',
			table: { category: 'Component configuration' },
		},
		size: {
			control: 'select',
			options: ['s', 'm'],
			table: { category: 'Component configuration' },
		},
		variant: {
			control: 'select',
			options: ['default', 'compact'],
			table: { category: 'Component configuration' },
		},
	},
	args: {
		disabled: false,
		dropLabel: 'Drop files to attach',
		id: 'chat-input-example',
		isResizable: true,
		label: 'Label',
		message: 'Enter to send + Shift+Enter for new line',
		placeholder: 'Ask Zappy...',
		rows: 3,
		showLabel: false,
		size: 's',
		variant: 'default',
	},
	render: (args) => <ChatInputWithToolbar {...args} />,
};

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {};

const slotStyle = { minHeight: '2rem', minWidth: '8rem', width: 'auto' };

// Usage-page sample: the slots as placeholders, with drag and drop wired so
// the page can be tried as is. Dropped names replace the helper text.
const SlotsWithDrop = (args: ChatInputProps) => {
	const [fileNames, setFileNames] = useState<string[]>([]);

	return (
		<ChatInput
			{...args}
			actionsEnd={<SlotPlaceholder label='actionsEnd' style={slotStyle} />}
			actionsStart={<SlotPlaceholder label='actionsStart' style={slotStyle} />}
			message={
				fileNames.length ? `Attached: ${fileNames.join(', ')}` : args.message
			}
			onFilesDrop={(files) =>
				setFileNames((names) => [...names, ...files.map((f) => f.name)])
			}
		/>
	);
};

export const Slots: Story = {
	// Used by the Usage page; hidden from the sidebar.
	tags: ['!dev'],
	args: { dropLabel: 'Drop files to attach', message: 'Helper text' },
	parameters: {
		docs: {
			description: {
				story:
					'The toolbar parts of the component. `actionsStart` and `actionsEnd` take any nodes; the screen decides what goes in them. The `Default` story fills them with an add menu, a model selector, a voice button and Send. Drag a file onto the field to try drag and drop.',
			},
		},
	},
	render: (args) => <SlotsWithDrop {...args} />,
};