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>
);
},
};
Suggestion Card
A card for use inside an agent `ChatMessage`:
Net-new componentOpen in Storybook