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} />,
};
Chat Input
A labelled textarea with a toolbar inside the same bordered box:
Net-new componentOpen in Storybook