← All proposals

Chat Panel

A layout shell for the content of an AI chat drawer or docked side panel.

Net-new componentOpen in Storybook

proposals/ChatPanel/ChatPanel.stories.tsx179 lines · View on GitHub
import { Meta, StoryObj } from '@storybook/react-vite';

import { SlotPlaceholder } from '../_story-helpers/SlotPlaceholder';
import { ChatMessage } from '../ChatMessage/ChatMessage';
import { ChatMessageList } from '../ChatMessage/ChatMessageList';
import { ChatPanel } from './ChatPanel';

import './chat-panel-story.css';

const meta: Meta<typeof ChatPanel> = {
	tags: ['new-pattern', 'ready-for-dev'],
	title: 'Component Proposals/AI Chat/Chat Panel',
	component: ChatPanel,
	decorators: [
		(Story) => (
			<div className='chat-panel-story__frame'>
				<Story />
			</div>
		),
	],
	parameters: {
		docs: {
			// The frame is viewport height, so each docs story gets its own iframe.
			story: { iframeHeight: 640, inline: false },
			description: {
				component:
					'**New pattern:** not in PDS v2 today. The frame of an AI chat drawer or docked panel: a title row with a close button, a pinned header, a scrolling content area and a pinned footer. Every region is a slot; the panel only owns the layout, the scroll and the inset.',
			},
		},
		layout: 'fullscreen',
	},
	argTypes: {
		children: { control: false, table: { category: 'Slots' } },
		className: { table: { category: 'Base attributes' } },
		closeButtonLabel: { control: 'text', table: { category: 'Content' } },
		contentInset: {
			control: 'inline-radio',
			options: ['default', 'none'],
			table: { category: 'Component configuration' },
		},
		footer: { control: false, table: { category: 'Slots' } },
		footerInset: {
			control: 'inline-radio',
			options: ['default', 'none'],
			table: { category: 'Component configuration' },
		},
		hasCloseButton: { table: { category: 'Component configuration' } },
		hasContentFade: { table: { category: 'Component configuration' } },
		hasFooterDivider: { table: { category: 'Component configuration' } },
		hasHeaderDivider: { table: { category: 'Component configuration' } },
		hasScrollToLatest: { table: { category: 'Component configuration' } },
		header: { control: false, table: { category: 'Slots' } },
		headerInset: {
			control: 'inline-radio',
			options: ['default', 'none'],
			table: { category: 'Component configuration' },
		},
		onClose: { table: { category: 'Events' } },
		scrollToLatestLabel: { control: 'text', table: { category: 'Content' } },
		size: {
			control: 'inline-radio',
			options: ['m', 'l'],
			table: { category: 'Component configuration' },
		},
		title: { control: false, table: { category: 'Slots' } },
	},
	args: {
		closeButtonLabel: 'Close chat',
		contentInset: 'default',
		footerInset: 'default',
		hasCloseButton: true,
		hasFooterDivider: false,
		hasHeaderDivider: false,
		headerInset: 'default',
		size: 'm',
	},
};

export default meta;

type Story = StoryObj<typeof meta>;

const fill = { height: '100%' };

export const Default: Story = {
	args: {
		children: <SlotPlaceholder label='Content area' style={fill} />,
		footer: (
			<SlotPlaceholder label='Footer slot' style={{ minHeight: '4rem' }} />
		),
		header: (
			<SlotPlaceholder label='Header slot' style={{ minHeight: '6rem' }} />
		),
		title: <SlotPlaceholder label='Title slot' />,
	},
};

export const Bleed: Story = {
	args: {
		children: <SlotPlaceholder label='Content area' style={fill} />,
		footer: (
			<SlotPlaceholder label='Footer slot' style={{ minHeight: '4rem' }} />
		),
		header: (
			<SlotPlaceholder label='Header slot' style={{ minHeight: '6rem' }} />
		),
		title: <SlotPlaceholder label='Title slot' />,
		contentInset: 'none',
		footerInset: 'none',
		hasFooterDivider: true,
		hasHeaderDivider: true,
		headerInset: 'none',
	},
	parameters: {
		docs: {
			description: {
				story:
					'`headerInset`, `contentInset` and `footerInset` set to `none` remove the inline padding of that region (block padding stays). The child supplies its own, so a tab bar or a divider can run edge to edge.',
			},
		},
	},
};

export const ContentOnly: Story = {
	args: {
		children: <SlotPlaceholder label='Content area' style={fill} />,
	},
	parameters: {
		docs: {
			description: {
				story:
					'Every region is optional. With only `children` you get the close button and a scrolling pane. Turn `hasCloseButton` off for a bare pane when the screen supplies its own way to close.',
			},
		},
	},
};

const LINES = [
	'Can you check the homepage for accessibility problems?',
	'I found three issues: the hero image has no alt text, the subtitle has low contrast and two footer links are broken.',
	'Fix the hero.',
	'Done. I added alt text and raised the contrast of the subtitle.',
	'What about the footer links?',
	'They point to pages that no longer exist. I can remove them or point them to the new pages.',
];

export const Scrolling: Story = {
	args: {
		children: (
			<ChatMessageList>
				{Array.from({ length: 24 }, (_, index) => (
					<ChatMessage
						key={index}
						label={index % 2 === 0 ? 'You' : 'Zappy'}
						sender={index % 2 === 0 ? 'user' : 'agent'}
					>
						{LINES[index % LINES.length]}
					</ChatMessage>
				))}
			</ChatMessageList>
		),
		footer: (
			<SlotPlaceholder label='Footer slot' style={{ minHeight: '4rem' }} />
		),
		hasContentFade: true,
		hasScrollToLatest: true,
		scrollToLatestLabel: 'Scroll to latest',
		title: <SlotPlaceholder label='Title slot' />,
	},
	parameters: {
		docs: {
			description: {
				story:
					'A long conversation. It opens at the end. Scroll up and a button floats at the bottom center of the content area; choose it to jump back to the latest. The bottom edge fades into the footer (`hasContentFade`). With `hasScrollToLatest`, the view also stays at the end as content grows, but only while the person is at the end: scroll up and it stops following.',
			},
		},
	},
};