import { ComponentProps, useState } from 'react';
import { Meta, StoryObj } from '@storybook/react-vite';
import { SlotPlaceholder } from '../_story-helpers/SlotPlaceholder';
import { SAMPLE_IMAGE } from '../_story-helpers/sample-image';
import { ChatAttachment } from '../ChatAttachment/ChatAttachment';
import { Icon } from '../Icon/Icon';
import { IconButton } from '../IconButton/IconButton';
import { MenuButton } from '../_base/MenuButton/MenuButton';
import { Select } from '../Select/Select';
import { ChatInput, ChatInputProps } from './ChatInput';
import './chat-input-story.css';
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 = [
{ label: 'Zappy', value: 'zappy' },
{ label: 'Claude Haiku 5.5', value: 'haiku' },
{ label: 'Claude Sonnet 5.5', value: 'sonnet' },
{ label: 'Claude Opus 5.5', value: 'opus' },
];
interface Attachment {
id: string;
name: string;
previewUrl?: string;
// Why it failed, as an adjective phrase ("too large (max 10 MB)"). Set
// means the attachment is in the error state.
reason?: string;
}
// Sample attachments so the field header is visible before anything is
// dropped. Drop files (images get a thumbnail) to add more.
const SAMPLE_FILES: Attachment[] = [
{ id: 'sample-1', name: 'skyline.webp', previewUrl: SAMPLE_IMAGE },
{ id: 'sample-2', name: 'verylong-filename-123.md' },
{ id: 'sample-3', name: 'notes.md' },
];
const TOO_LARGE = 'too large (max 10 MB)';
const UNSUPPORTED = 'not a supported type';
const withReason = (file: Attachment, reason: string): Attachment => ({
...file,
reason,
});
const REPORT: Attachment = { id: 'sample-report', name: 'report-final.pdf' };
const PHOTO: Attachment = {
id: 'sample-photo',
name: 'photo.png',
previewUrl: SAMPLE_IMAGE,
};
// Two failed for the same reason.
const SAME_ERRORS: Attachment[] = [
SAMPLE_FILES[0],
withReason(REPORT, TOO_LARGE),
withReason(PHOTO, TOO_LARGE),
SAMPLE_FILES[2],
];
// Two failed for different reasons.
const MIXED_ERRORS: Attachment[] = [
SAMPLE_FILES[0],
withReason(REPORT, TOO_LARGE),
withReason(PHOTO, UNSUPPORTED),
SAMPLE_FILES[2],
];
// The screen writes the helper message from the failures. One file: name it.
// Several with the same reason: name the reason. Different reasons: count them
// and point to the chips.
const errorSummary = (failed: Attachment[]) => {
if (failed.length === 1) return `${failed[0].name} is ${failed[0].reason}`;
const reasons = new Set(failed.map((file) => file.reason));
return reasons.size === 1
? `${failed.length} files are ${failed[0].reason}`
: `${failed.length} files could not be attached. Hover a file to see why.`;
};
const capitalize = (text: string) =>
text.charAt(0).toUpperCase() + text.slice(1);
type SendState = 'send' | 'stop' | 'working';
const SEND_LABELS: Record<SendState, 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.
// `showAboveField` and `initialFiles` are story setup, not props: the
// component shows each slot whenever it is given content.
type SampleProps = ChatInputProps & {
initialFiles?: Attachment[];
showAboveField?: boolean;
};
const ChatInputWithToolbar = ({
initialFiles = [],
showAboveField = false,
...args
}: SampleProps) => {
const [value, setValue] = useState('');
const [state, setState] = useState<SendState>('send');
const [model, setModel] = useState(MODELS[0].value);
const [files, setFiles] = useState<Attachment[]>(initialFiles);
const handleSend = () => {
if (state === 'send') {
setState('working');
setTimeout(() => setState('stop'), 400);
} else if (state === 'stop') {
setState('send');
setValue('');
}
};
const failed = files.filter((file) => file.reason);
const chips = files.map((file) => (
<ChatAttachment
key={file.id}
errorMessage={file.reason ? capitalize(file.reason) : undefined}
status={file.reason ? 'error' : 'default'}
fileName={file.name}
fileType={file.previewUrl ? 'image' : 'file'}
onRemove={() => setFiles((list) => list.filter((f) => f.id !== file.id))}
previewUrl={file.previewUrl}
removeLabel={`Remove ${file.name}`}
/>
));
const context = showAboveField ? (
<div className='chat-input-story__context'>
<Icon iconName='codeBranch' size='s' />
<span>Working on the feature branch</span>
<span className='chat-input-story__time'>42m ago</span>
</div>
) : undefined;
return (
<ChatInput
{...args}
aboveField={context}
dropLabel={args.dropLabel}
fieldHeader={files.length ? <>{chips}</> : undefined}
message={
failed.length ? (
<span className='chat-input-story__error' role='alert'>
<Icon iconName='diamondExclamation' size='s' />
{errorSummary(failed)}
</span>
) : (
args.message
)
}
onFilesDrop={(dropped) =>
setFiles((list) => [
...list,
...dropped.map((file) => ({
id: `${file.name}-${file.lastModified}-${list.length}`,
name: file.name,
previewUrl: file.type.startsWith('image/')
? URL.createObjectURL(file)
: undefined,
})),
])
}
actionsEnd={
<>
<Select
id={`${args.id}-model`}
label='Model'
onOptionSelect={(option) => setModel(option.value)}
options={MODELS}
value={model}
variant='bare'
/>
<IconButton ariaLabel='Voice input' iconName='microphoneLines' />
<IconButton
ariaLabel={SEND_LABELS[state]}
disabled={state === 'send' && !value.trim()}
iconName={state === 'stop' ? 'stop' : 'paperPlane'}
isWorking={state === 'working'}
onClick={handleSend}
variant='primary'
/>
</>
}
actionsStart={
<>
<MenuButton
iconName='plus'
iconOnly
id={`${args.id}-add`}
label='Add'
menuItems={addItems}
size='s'
/>
</>
}
onChange={(e) => setValue(e.target.value)}
value={value}
/>
);
};
const meta: Meta<typeof ChatInput> = {
tags: ['new-pattern', 'ready-for-dev'],
title: 'Component Proposals/AI Chat/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 an add menu at the start, and a model selector and a primary `IconButton` (Send) 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 = {};
export const Compact: Story = {
args: { variant: 'compact' },
parameters: {
docs: {
description: {
story:
'A single-line bar with the controls on the same row, for tight spaces. Not resizable.',
},
},
},
};
// The two variants one above the other, for the slot stories.
const BothVariants = ({
idPrefix = 'chat-input',
...props
}: SampleProps & { idPrefix?: string }) => (
<div className='chat-input-story__stack'>
<ChatInputWithToolbar {...props} id={`${idPrefix}-default`} />
<ChatInputWithToolbar
{...props}
id={`${idPrefix}-compact`}
variant='compact'
/>
</div>
);
export const WithAboveField: Story = {
parameters: {
docs: {
description: {
story:
'`aboveField` is a bar attached to the top edge, outside the border, for context such as the branch the prompt applies to. Shown in both variants.',
},
},
},
render: (args) => <BothVariants {...args} showAboveField />,
};
export const WithFieldHeader: Story = {
parameters: {
docs: {
description: {
story:
'`fieldHeader` sits inside the border above the text, for attached files (`ChatAttachment`). In the compact variant it sits above the row. Drag a file onto the field to add one.',
},
},
},
render: (args) => <BothVariants {...args} initialFiles={SAMPLE_FILES} />,
};
export const WithAttachmentErrors: Story = {
parameters: {
docs: {
description: {
story:
'The helper message is written by the screen. Failures with the same reason are grouped ("2 files are too large"). Different reasons are counted, and the message points to the chips. Remove a failed file to see the message change; with one left, it is named. It is one alert, so a screen reader reads it once.',
},
},
},
render: (args) => (
<div className='chat-input-story__stack'>
<ChatInputWithToolbar
{...args}
id='chat-input-same'
initialFiles={SAME_ERRORS}
/>
<ChatInputWithToolbar
{...args}
id='chat-input-mixed'
initialFiles={MIXED_ERRORS}
/>
</div>
),
};
export const WithAllProperties: Story = {
parameters: {
docs: {
description: {
story:
'Every slot filled: `aboveField`, `fieldHeader`, `actionsStart` and `actionsEnd`, with the helper message. Shown in both variants. Attached files and images sit inside the field, in `fieldHeader`.',
},
},
},
render: (args) => (
<div className='chat-input-story__stack'>
<BothVariants {...args} initialFiles={SAMPLE_FILES} showAboveField />
</div>
),
};
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}
aboveField={
<SlotPlaceholder label='aboveField' style={{ minHeight: '2rem' }} />
}
actionsEnd={<SlotPlaceholder label='actionsEnd' style={slotStyle} />}
actionsStart={<SlotPlaceholder label='actionsStart' style={slotStyle} />}
fieldHeader={
<SlotPlaceholder
label='fieldHeader'
style={{ minHeight: '2rem', width: '100%' }}
/>
}
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 slots of the component. `aboveField` (outside the border, on the top edge), `fieldHeader` (inside the border, above the text) and the toolbar slots `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