import {
Children,
cloneElement,
ComponentProps,
ComponentPropsWithoutRef,
Fragment,
isValidElement,
ReactElement,
ReactNode,
useId,
} from 'react';
import {
Checkbox,
Panel,
PanelList,
PanelRow,
} from '@pantheon-systems/pds-toolkit-react';
import { Button } from '../_base/Button/Button';
import { ContentDiff } from '../ContentDiff/ContentDiff';
import { Icon } from '../Icon/Icon';
import './suggestion-card.css';
export type SuggestionCardType = 'changes' | 'choice';
export type SuggestionCardState = 'accepted' | 'dismissed' | 'pending';
export type SuggestionCardButtonSize = 'xs' | 's';
export type SuggestionCardPadding = NonNullable<
ComponentProps<typeof Panel>['padding']
>;
export type SuggestionCardHeadingSize = 'l' | 'm';
export type SuggestionCardHeadingLevel = 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
// Stand-in for the v2 `mergeClasses` util, which the published package does
// not export. The port uses the real one.
const mergeClasses = (classes: Array<string | null | undefined>) =>
classes.filter(Boolean).join(' ');
// Sets `size` on every `Button` in `actionsStart`, looking inside fragments, so
// the set is always one size.
const sizeButtons = (
nodes: ReactNode,
size: SuggestionCardButtonSize,
): ReactNode =>
Children.map(nodes, (child) => {
if (!isValidElement(child)) return child;
if (child.type === Fragment) {
return sizeButtons(
(child.props as { children?: ReactNode }).children,
size,
);
}
if (child.type === Button) {
return cloneElement(child as ReactElement<{ size?: string }>, { size });
}
return child;
});
export interface SuggestionCardProps extends Omit<
ComponentPropsWithoutRef<'div'>,
'title'
> {
/**
* Shown at the right end of the footer, in every state (for example a
* `FeedbackBar`). The screen owns whatever is in it, and decides what to
* show in each state.
*/
actionsEnd?: ReactNode;
/**
* The decision buttons at the left of the footer (for example Accept, Refine
* and Dismiss). Shown while `state` is `pending`; once `accepted` they are
* replaced by `statusLabel`.
*/
actionsStart?: ReactNode;
/**
* Size of the buttons in `actionsStart`, as one set: `xs` (default) or `s`.
* Applied to every `Button` in `actionsStart`, overriding any size set on
* them.
*/
buttonSize?: SuggestionCardButtonSize;
/**
* The changes, as `SuggestionChange` components.
*/
children?: ReactNode;
/**
* Additional class names.
*/
className?: string;
/**
* Short summary of what is being suggested.
*/
description?: ReactNode;
/**
* Heading level of the title, so it fits the page outline.
*/
headingLevel?: SuggestionCardHeadingLevel;
/**
* Size of the title text, from the PDS heading sizes: `m` (16px) or `l`
* (18px). Separate from `headingLevel`, which only sets the tag.
*/
headingSize?: SuggestionCardHeadingSize;
/**
* Padding of the card, the same options as `Panel`: `none`, `condensed`,
* `standard` or `spacious`. Defaults to `condensed` (Panel's default is
* `standard`) to suit a card in a chat.
*/
padding?: SuggestionCardPadding;
/**
* Where the suggestion is. `pending` shows `actionsStart`. `accepted` and
* `dismissed` replace it with `statusLabel`, so the card in the history can't
* be decided twice. `dismissed` also hides the changes, so the card shrinks to
* a record; put an Undo button in `actionsEnd` to let it be brought back.
*/
state?: SuggestionCardState;
/**
* Result text shown when `state` is `accepted` or `dismissed` (for example
* "Changes applied" or "Dismissed"). Announced to screen readers when it
* appears.
*/
statusLabel?: string;
/**
* What is being suggested (for example "2 changes").
*/
title: string;
/**
* What the card asks. `changes` (default) shows proposed changes to accept
* or dismiss. `choice` is multiple choice: the header, a list of rows (the
* `children`, usually `SuggestionChoice` rows with a radio or a checkbox)
* and the footer, divided by lines across the card. `padding` does not apply
* to `choice`; each section is padded and the rows sit full width.
*/
type?: SuggestionCardType;
}
/**
* SuggestionCard UI component. An agent's proposed changes in a conversation,
* with the buttons to decide.
*/
export const SuggestionCard = ({
actionsEnd,
actionsStart,
buttonSize = 'xs',
children,
className,
description,
headingLevel: Heading = 'h3',
headingSize = 'm',
padding = 'condensed',
state = 'pending',
statusLabel,
title,
type = 'changes',
...props
}: SuggestionCardProps) => {
const baseClass = 'pds-suggestion-card';
const titleId = useId();
const isChoice = type === 'choice';
const isAccepted = state === 'accepted';
const isDismissed = state === 'dismissed';
const isResolved = isAccepted || isDismissed;
const hasStart = isResolved || Boolean(actionsStart);
const hasFooterContent = hasStart || Boolean(actionsEnd);
const headingBlock = (
<div className={`${baseClass}__heading`}>
<Heading
className={`pds-heading-${headingSize} ${baseClass}__title`}
id={titleId}
>
{title}
</Heading>
{description && (
<p className={`pds-copy-s ${baseClass}__description`}>{description}</p>
)}
</div>
);
const footerBlock = (
<div
className={mergeClasses([
`${baseClass}__footer`,
hasFooterContent ? null : `${baseClass}__footer--empty`,
])}
>
{!isResolved && actionsStart && (
<div className={`${baseClass}__actions`}>
{sizeButtons(actionsStart, buttonSize)}
</div>
)}
<div role='status'>
{isResolved && (
<div className={`${baseClass}__result`}>
<Icon
className={`${baseClass}__result-icon`}
iconName={isDismissed ? 'circleMinus' : 'check'}
size='s'
/>
<span>{statusLabel}</span>
</div>
)}
</div>
{actionsEnd && (
<div className={`${baseClass}__actions-end`}>{actionsEnd}</div>
)}
</div>
);
const stateClasses = [
isAccepted ? `${baseClass}--accepted` : null,
isDismissed ? `${baseClass}--dismissed` : null,
];
// Multiple choice: the sections run edge to edge, divided by lines, and the
// rows are a PDS panel list.
if (isChoice) {
return (
<Panel
className={mergeClasses([
baseClass,
`${baseClass}--choice`,
'pds-copy-s',
...stateClasses,
className,
])}
padding='none'
{...props}
>
<div className={`${baseClass}__section ${baseClass}__section--header`}>
{headingBlock}
</div>
{children && !isDismissed && (
<PanelList
aria-labelledby={titleId}
className={`${baseClass}__choices`}
hasBorder={false}
paddingSize='s'
role='group'
>
{children}
</PanelList>
)}
<div className={`${baseClass}__section ${baseClass}__section--footer`}>
{footerBlock}
</div>
</Panel>
);
}
return (
<Panel
className={mergeClasses([
baseClass,
'pds-copy-s',
...stateClasses,
className,
])}
padding={padding}
{...props}
>
<div className={`${baseClass}__body`}>
{headingBlock}
{children && !isDismissed && (
<div className={`${baseClass}__changes`}>{children}</div>
)}
{footerBlock}
</div>
</Panel>
);
};
export interface SuggestionChangeProps extends ComponentPropsWithoutRef<'div'> {
/**
* The new value.
*/
after: ReactNode;
/**
* Name of the new value for screen readers (for example "After"); not shown.
*/
afterLabel: string;
/**
* The current value.
*/
before: ReactNode;
/**
* Name of the current value for screen readers (for example "Before"); not
* shown.
*/
beforeLabel: string;
/**
* Additional class names.
*/
className?: string;
/**
* What is changing (for example "Eyebrow" or "Description").
*/
label: string;
}
/**
* SuggestionChange UI component. One change in a `SuggestionCard`: a field
* name with its before and after values.
*/
export const SuggestionChange = ({
after,
afterLabel,
before,
beforeLabel,
className,
label,
...props
}: SuggestionChangeProps) => {
const baseClass = 'pds-suggestion-change';
return (
<div className={mergeClasses([baseClass, className])} {...props}>
<p className={`${baseClass}__field`}>{label}</p>
<ContentDiff
after={after}
afterLabel={afterLabel}
before={before}
beforeLabel={beforeLabel}
/>
</div>
);
};
export interface SuggestionChoiceProps extends Omit<
ComponentPropsWithoutRef<'input'>,
'id' | 'type'
> {
/**
* Content under the label, lined up with it (for example a text field for an
* "Other" option).
*/
children?: ReactNode;
/**
* Additional class names.
*/
className?: string;
/**
* Optional supporting text under the label.
*/
description?: ReactNode;
/**
* Unique id of the input.
*/
id: string;
/**
* The option's text.
*/
label: ReactNode;
/**
* `radio` for one answer (give every row the same `name`) or `checkbox` for
* one or more.
*/
selectionType: 'checkbox' | 'radio';
}
/**
* SuggestionChoice UI component. One row of a `choice` SuggestionCard: a radio
* or a checkbox with a label and optional description. The whole row is the
* label, so clicking anywhere in it selects it.
*/
export const SuggestionChoice = ({
checked,
children,
className,
defaultChecked,
description,
disabled,
id,
label,
name,
onChange,
selectionType,
value,
...props
}: SuggestionChoiceProps) => {
const baseClass = 'pds-suggestion-choice';
const descriptionId = `${id}-description`;
const text = (
<span className={`${baseClass}__text`}>
<span className={`${baseClass}__name`}>{label}</span>
{description && (
<span
className={`pds-copy-xs ${baseClass}__description`}
id={descriptionId}
>
{description}
</span>
)}
</span>
);
return (
<PanelRow className={mergeClasses([baseClass, className])}>
<div slot='items-left' className={`${baseClass}__control`}>
{selectionType === 'checkbox' ? (
// The PDS Checkbox, so the box and check match the rest of PDS.
<Checkbox
checked={checked}
defaultChecked={defaultChecked}
disabled={disabled}
id={id}
inputProps={{
'aria-describedby': description ? descriptionId : undefined,
...props,
}}
label={text}
name={name}
onChange={onChange}
value={value as string | undefined}
/>
) : (
// A PDS radio: the package styles the input inside
// `.pds-radio-group__option`. The whole label is the target.
<label
className={`pds-radio-group__option ${baseClass}__label`}
htmlFor={id}
>
<input
aria-describedby={description ? descriptionId : undefined}
checked={checked}
defaultChecked={defaultChecked}
disabled={disabled}
id={id}
name={name}
onChange={onChange}
type='radio'
value={value}
{...props}
/>
{text}
</label>
)}
{children && <div className={`${baseClass}__extra`}>{children}</div>}
</div>
</PanelRow>
);
};
Suggestion Card
A card for use inside an agent `ChatMessage`:
Net-new componentOpen in Storybook