← All proposals

Suggestion Card

A card for use inside an agent `ChatMessage`:

Net-new componentOpen in Storybook

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

import {
	FlexContainer,
	PanelRow,
	TextInput,
} from '@pantheon-systems/pds-toolkit-react';
import { Meta, StoryObj } from '@storybook/react-vite';

import { SlotPlaceholder } from '../_story-helpers/SlotPlaceholder';
import { Button } from '../_base/Button/Button';
import { FeedbackBar, FeedbackValue } from '../FeedbackBar/FeedbackBar';
import {
	SuggestionCard,
	SuggestionCardState,
	SuggestionChange,
	SuggestionChoice,
} from './SuggestionCard';

// The text field under an "Other" option. Focusing it selects the option, so
// the screen never has a typed answer with the option unselected.
const OtherInput = ({ id, onFocus }: { id: string; onFocus: () => void }) => (
	<TextInput
		id={id}
		label='Other, please specify'
		onFocus={onFocus}
		placeholder='Input text'
		showLabel={false}
	/>
);

const CONTENT = (
	<SlotPlaceholder label='children' style={{ minHeight: '6rem' }} />
);

// The slots show as placeholders, like the PDS stories; the other stories fill
// them with real buttons and a Feedback Bar.
const slotStyle = { width: 'auto' };

const ACTIONS_START = (
	<SlotPlaceholder label='actionsStart' style={slotStyle} />
);

const ACTIONS_END = <SlotPlaceholder label='actionsEnd' style={slotStyle} />;

const meta: Meta<typeof SuggestionCard> = {
	tags: ['new-pattern', 'ready-for-dev'],
	title: 'Component Proposals/AI Chat/Suggestion Card',
	component: SuggestionCard,
	parameters: {
		docs: {
			description: {
				component:
					'**New pattern:** not in PDS v2 today. An agent’s proposed changes in a conversation, with the buttons to decide. Built on `Panel`, with a title, a description, a content area and two footer slots: `actionsStart` (the decision buttons) and `actionsEnd` (for example feedback). It has a pending state and an accepted state.',
			},
		},
	},
	argTypes: {
		actionsEnd: { table: { category: 'Slots' } },
		actionsStart: { table: { category: 'Slots' } },
		buttonSize: {
			control: 'select',
			options: ['xs', 's'],
			table: { category: 'Component configuration' },
		},
		children: { table: { category: 'Content' } },
		className: { table: { category: 'Base attributes' } },
		description: { control: 'text', table: { category: 'Content' } },
		headingLevel: {
			control: 'select',
			options: ['h2', 'h3', 'h4', 'h5', 'h6'],
			table: { category: 'Component configuration' },
		},
		headingSize: {
			control: 'select',
			options: ['m', 'l'],
			table: { category: 'Component configuration' },
		},
		padding: {
			control: 'select',
			options: ['none', 'condensed', 'standard', 'spacious'],
			table: { category: 'Component configuration' },
		},
		state: {
			control: 'select',
			options: ['pending', 'accepted', 'dismissed'],
			table: { category: 'State' },
		},
		statusLabel: { control: 'text', table: { category: 'Content' } },
		title: { control: 'text', table: { category: 'Content' } },
		type: {
			control: 'select',
			options: ['changes', 'choice'],
			table: { category: 'Component configuration' },
		},
	},
	args: {
		actionsEnd: ACTIONS_END,
		actionsStart: ACTIONS_START,
		buttonSize: 'xs',
		children: CONTENT,
		description: 'Tighten and energize the hero copy for stronger impact.',
		headingLevel: 'h3',
		headingSize: 'm',
		padding: 'condensed',
		state: 'pending',
		statusLabel: 'Changes applied',
		title: 'Heading',
	},
};

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {
	args: { description: 'Optional subtitle' },
	parameters: {
		docs: {
			description: {
				story:
					'The two footer slots are optional: `actionsStart` at the left (the decision buttons) and `actionsEnd` at the right (for example a Feedback Bar). With neither, the footer takes no space.',
			},
		},
	},
};

export const Accepted: Story = {
	args: {
		actionsEnd: (
			<FeedbackBar
				downLabel='Bad suggestion'
				groupLabel='Rate this suggestion'
				upLabel='Good suggestion'
			/>
		),
		state: 'accepted',
	},
	parameters: {
		docs: {
			description: {
				story:
					'Once the user accepts, set `state` to `accepted`. `actionsStart` is replaced by `statusLabel` (announced to screen readers), so the card in the history can’t be accepted twice. The changes stay visible. `actionsEnd` stays at the right (here a `FeedbackBar`); the screen owns it and chooses what to show in each state. The final look of this state is still to be refined.',
			},
		},
	},
};

export const Dismissed: Story = {
	args: {
		actionsEnd: <Button label='Undo' size='xs' variant='subtle' />,
		state: 'dismissed',
		statusLabel: 'Dismissed',
	},
	parameters: {
		docs: {
			description: {
				story:
					'When the user dismisses, set `state` to `dismissed`. The changes are hidden, so the card shrinks to a quiet record, and `actionsStart` is replaced by `statusLabel` (announced to screen readers). Put an Undo button in `actionsEnd` so the card can be brought back; the screen owns that.',
			},
		},
	},
};

export const ChoiceSlots: Story = {
	args: {
		children: [1, 2, 3].map((n) => (
			<PanelRow key={n}>
				<div slot='items-left' style={{ width: '100%' }}>
					<SlotPlaceholder label='content' style={{ minHeight: '2rem' }} />
				</div>
			</PanelRow>
		)),
		description: 'Optional subtitle',
		type: 'choice',
	},
	parameters: {
		docs: {
			description: {
				story:
					'`type="choice"` is the multiple choice card: the header, a list of rows and the footer, divided by lines across the card. The rows are a PDS `PanelList`; the footer slots are the same as the default card. Placeholders stand in for the rows.',
			},
		},
	},
};

export const ChoiceRadio: Story = {
	args: {
		actionsStart: <Button label='Continue' variant='primary' />,
		description: 'Pick the one you want me to use.',
		title: 'Which tone should the hero use?',
		type: 'choice',
	},
	parameters: {
		docs: {
			description: {
				story:
					'One answer: give every `SuggestionChoice` the same `name` and `selectionType="radio"`. The whole row is the label.',
			},
		},
	},
	render: (args) => {
		const [value, setValue] = useState('bold');
		const options = [
			{ description: 'Short and confident.', label: 'Bold', value: 'bold' },
			{
				description: 'Warm and conversational.',
				label: 'Friendly',
				value: 'friendly',
			},
			{
				description: 'Precise and measured.',
				label: 'Technical',
				value: 'technical',
			},
		];

		return (
			<SuggestionCard {...args}>
				{options.map((option) => (
					<SuggestionChoice
						checked={value === option.value}
						description={option.description}
						id={`tone-${option.value}`}
						key={option.value}
						label={option.label}
						name='tone'
						onChange={() => setValue(option.value)}
						selectionType='radio'
						value={option.value}
					/>
				))}
				<SuggestionChoice
					checked={value === 'other'}
					id='tone-other'
					label='Other'
					name='tone'
					onChange={() => setValue('other')}
					selectionType='radio'
					value='other'
				>
					<OtherInput id='tone-other-text' onFocus={() => setValue('other')} />
				</SuggestionChoice>
			</SuggestionCard>
		);
	},
};

export const ChoiceCheckbox: Story = {
	args: {
		actionsStart: <Button label='Apply selected' variant='primary' />,
		description: 'Choose any that apply.',
		title: 'Which pages should I update?',
		type: 'choice',
	},
	parameters: {
		docs: {
			description: {
				story:
					'One or more answers: `selectionType="checkbox"`. The screen owns which are checked and what the footer button does.',
			},
		},
	},
	render: (args) => {
		const [checked, setChecked] = useState<string[]>(['home']);
		const options = [
			{
				description: 'Hero headline and subhead.',
				label: 'Home',
				value: 'home',
			},
			{
				description: 'Plan names and the comparison table.',
				label: 'Pricing',
				value: 'pricing',
			},
			{
				description: 'Team intro and mission statement.',
				label: 'About',
				value: 'about',
			},
		];

		return (
			<SuggestionCard {...args}>
				{options.map((option) => (
					<SuggestionChoice
						checked={checked.includes(option.value)}
						description={option.description}
						id={`page-${option.value}`}
						key={option.value}
						label={option.label}
						onChange={() =>
							setChecked((prev) =>
								prev.includes(option.value)
									? prev.filter((v) => v !== option.value)
									: [...prev, option.value],
							)
						}
						selectionType='checkbox'
						value={option.value}
					/>
				))}
				<SuggestionChoice
					checked={checked.includes('other')}
					id='page-other'
					label='Other'
					onChange={() =>
						setChecked((prev) =>
							prev.includes('other')
								? prev.filter((v) => v !== 'other')
								: [...prev, 'other'],
						)
					}
					selectionType='checkbox'
					value='other'
				>
					<OtherInput
						id='page-other-text'
						onFocus={() =>
							setChecked((prev) =>
								prev.includes('other') ? prev : [...prev, 'other'],
							)
						}
					/>
				</SuggestionChoice>
			</SuggestionCard>
		);
	},
};

// Story-only wiring for the Interactive story: one card with the decision
// buttons, the accepted state with a Feedback Bar and the dismissed state with
// Undo. The consumer owns the state and the focus.
const InteractiveCard = ({
	acceptLabel,
	children,
	hasRefine,
	...props
}: ComponentProps<typeof SuggestionCard> & {
	acceptLabel: string;
	hasRefine?: boolean;
}) => {
	const [state, setState] = useState<SuggestionCardState>('pending');
	const [wasUndone, setWasUndone] = useState(false);
	const [feedback, setFeedback] = useState<FeedbackValue | null>(null);

	// The screen owns the rating. Once there is a choice the thumbs are replaced
	// by a thank-you that stays.
	const handleFeedback = (next: FeedbackValue | null) => setFeedback(next);

	const decide = (next: SuggestionCardState) => {
		setWasUndone(false);
		setState(next);
	};

	return (
		<SuggestionCard
			{...props}
			actionsEnd={
				state === 'accepted' ? (
					<FeedbackBar
						downLabel='Bad suggestion'
						groupLabel='Rate this suggestion'
						onValueChange={handleFeedback}
						statusLabel={feedback ? 'Thanks for the feedback' : undefined}
						upLabel='Good suggestion'
						value={feedback}
					/>
				) : state === 'dismissed' ? (
					<Button
						autoFocus
						label='Undo'
						onClick={() => {
							setWasUndone(true);
							setState('pending');
						}}
						size='xs'
						variant='subtle'
					/>
				) : undefined
			}
			actionsStart={
				<>
					<Button
						displayType='icon-start'
						iconName='check'
						label={acceptLabel}
						onClick={() => decide('accepted')}
						variant='primary'
					/>
					{hasRefine && <Button label='Refine' variant='secondary' />}
					<Button
						autoFocus={wasUndone}
						label='Dismiss'
						onClick={() => decide('dismissed')}
						variant='subtle'
					/>
				</>
			}
			state={state}
			statusLabel={state === 'dismissed' ? 'Dismissed' : 'Done'}
		>
			{children}
		</SuggestionCard>
	);
};

export const Interactive: Story = {
	parameters: {
		docs: {
			description: {
				story:
					'The three kinds of card, each working: proposed changes (`type="changes"`, with `SuggestionChange` diff rows), a single choice (radios) and a multiple choice (checkboxes). The main button switches the card to `accepted` and shows the Feedback Bar at the right. Dismiss switches it to `dismissed`, hides the rows and shows Undo, which brings the card back. Focus moves to Undo, and back to Dismiss after Undo. Story-only wiring: the consumer owns the state, the selection and the focus.',
			},
		},
	},
	render: () => {
		const [headline, setHeadline] = useState('confidence');
		const [pages, setPages] = useState<string[]>(['home']);
		const headlines = [
			{
				description: 'Bold and confident.',
				label: 'Ship faster with confidence',
				value: 'confidence',
			},
			{
				description: 'Reassuring.',
				label: 'Deploy with confidence, every time',
				value: 'reassuring',
			},
			{
				description: 'Short and simple.',
				label: 'Your site, live in seconds',
				value: 'seconds',
			},
		];
		const pageOptions = [
			{
				description: 'Hero headline and subhead.',
				label: 'Home',
				value: 'home',
			},
			{
				description: 'Plan names and the comparison table.',
				label: 'Pricing',
				value: 'pricing',
			},
			{
				description: 'Team intro and mission statement.',
				label: 'About',
				value: 'about',
			},
		];

		return (
			<FlexContainer flexDirection='column' gap='xl'>
				<InteractiveCard
					acceptLabel='Accept'
					description='Tighten and energize the hero copy for stronger impact.'
					hasRefine
					title='2 changes'
				>
					<SuggestionChange
						after='Ship faster with confidence'
						afterLabel='After'
						before='Deploy your site'
						beforeLabel='Before'
						label='Headline'
					/>
					<SuggestionChange
						after='Push to production in one click, with previews on every change.'
						afterLabel='After'
						before='Deploy sites on our platform.'
						beforeLabel='Before'
						label='Description'
					/>
				</InteractiveCard>
				<InteractiveCard
					acceptLabel='Use selected'
					description='The current headline is “Deploy your site”. Pick one to replace it.'
					title='Which headline should I use?'
					type='choice'
				>
					{headlines.map((option) => (
						<SuggestionChoice
							checked={headline === option.value}
							description={option.description}
							id={`interactive-headline-${option.value}`}
							key={option.value}
							label={option.label}
							name='interactive-headline'
							onChange={() => setHeadline(option.value)}
							selectionType='radio'
							value={option.value}
						/>
					))}
					<SuggestionChoice
						checked={headline === 'other'}
						id='interactive-headline-other'
						label='Other'
						name='interactive-headline'
						onChange={() => setHeadline('other')}
						selectionType='radio'
						value='other'
					>
						<OtherInput
							id='interactive-headline-other-text'
							onFocus={() => setHeadline('other')}
						/>
					</SuggestionChoice>
				</InteractiveCard>
				<InteractiveCard
					acceptLabel='Apply selected'
					description='Choose any that apply.'
					title='Which pages should I update?'
					type='choice'
				>
					{pageOptions.map((option) => (
						<SuggestionChoice
							checked={pages.includes(option.value)}
							description={option.description}
							id={`interactive-page-${option.value}`}
							key={option.value}
							label={option.label}
							onChange={() =>
								setPages((prev) =>
									prev.includes(option.value)
										? prev.filter((v) => v !== option.value)
										: [...prev, option.value],
								)
							}
							selectionType='checkbox'
							value={option.value}
						/>
					))}
					<SuggestionChoice
						checked={pages.includes('other')}
						id='interactive-page-other'
						label='Other'
						onChange={() =>
							setPages((prev) =>
								prev.includes('other')
									? prev.filter((v) => v !== 'other')
									: [...prev, 'other'],
							)
						}
						selectionType='checkbox'
						value='other'
					>
						<OtherInput
							id='interactive-page-other-text'
							onFocus={() =>
								setPages((prev) =>
									prev.includes('other') ? prev : [...prev, 'other'],
								)
							}
						/>
					</SuggestionChoice>
				</InteractiveCard>
			</FlexContainer>
		);
	},
};