← All proposals

Suggestion Card

A card for use inside an agent `ChatMessage`:

Net-new componentOpen in Storybook

proposals/SuggestionCard/SuggestionCard.tsx237 lines · View on GitHub
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>
	);
};