← All proposals

Suggestion Card

A card for use inside an agent `ChatMessage`:

Net-new componentOpen in Storybook

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