import {
Children,
cloneElement,
ComponentProps,
ComponentPropsWithoutRef,
Fragment,
isValidElement,
ReactElement,
ReactNode,
} from 'react';
import { Panel } from '@pantheon-systems/pds-toolkit-react';
import { Button } from '../Button/Button';
import { Icon } from '../Icon/Icon';
import './suggestion-card.css';
export type SuggestionCardState = 'accepted' | '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 the actions, 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'
> {
/**
* The decision buttons (for example Accept, Refine and Dismiss). Shown
* while `state` is `pending`.
*/
actions?: ReactNode;
/**
* Size of the buttons in `actions`, as one set: `xs` (default) or `s`. Applied to
* every `Button` in `actions`, 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 `actions`. `accepted` replaces
* them with `statusLabel`, so the card in the history can't be accepted
* again.
*/
state?: SuggestionCardState;
/**
* Result text shown when `state` is `accepted` (for example "Changes
* applied"). Announced to screen readers when it appears.
*/
statusLabel?: string;
/**
* What is being suggested (for example "2 changes").
*/
title: string;
}
/**
* SuggestionCard UI component. An agent's proposed changes in a conversation,
* with the buttons to decide.
*/
export const SuggestionCard = ({
actions,
buttonSize = 'xs',
children,
className,
description,
headingLevel: Heading = 'h3',
headingSize = 'm',
padding = 'condensed',
state = 'pending',
statusLabel,
title,
...props
}: SuggestionCardProps) => {
const baseClass = 'pds-suggestion-card';
const isAccepted = state === 'accepted';
return (
<Panel
className={mergeClasses([
baseClass,
isAccepted ? `${baseClass}--accepted` : null,
className,
])}
padding={padding}
{...props}
>
<div className={`${baseClass}__body`}>
<Heading className={`pds-heading-${headingSize} ${baseClass}__title`}>
{title}
</Heading>
{description && (
<p className={`pds-copy-s ${baseClass}__description`}>
{description}
</p>
)}
{children && <div className={`${baseClass}__changes`}>{children}</div>}
<div className={`${baseClass}__footer`}>
{!isAccepted && actions && (
<div className={`${baseClass}__actions`}>
{sizeButtons(actions, buttonSize)}
</div>
)}
<div role='status'>
{isAccepted && (
<div className={`${baseClass}__result`}>
<Icon
className={`${baseClass}__result-icon`}
iconName='check'
size='s'
/>
<span>{statusLabel}</span>
</div>
)}
</div>
</div>
</div>
</Panel>
);
};
export interface SuggestionChangeProps extends ComponentPropsWithoutRef<'div'> {
/**
* The new value.
*/
after: ReactNode;
/**
* Heading for the new value (for example "After").
*/
afterLabel: string;
/**
* The current value. Pass a dash when there is none.
*/
before: ReactNode;
/**
* Heading for the current value (for example "Before").
*/
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>
<dl className={`${baseClass}__values`}>
<div className={`${baseClass}__value ${baseClass}__value--before`}>
<dt className={`${baseClass}__value-label`}>{beforeLabel}</dt>
<dd className={`${baseClass}__value-text`}>{before}</dd>
</div>
<div className={`${baseClass}__value ${baseClass}__value--after`}>
<dt className={`${baseClass}__value-label`}>{afterLabel}</dt>
<dd className={`${baseClass}__value-text`}>{after}</dd>
</div>
</dl>
</div>
);
};
Suggestion Card
A card for use inside an agent `ChatMessage`:
Net-new componentOpen in Storybook