← All proposals

Section Message

A copy of the PDS v2 Section Message (pds-section-message, same class names, props and behavior) with an agentic type (AI gradient border on a surface fill, the Agent Avatar as its leading icon (decided 2026-10-21; iconName picks its icon), the standard secondary Button as the CTA, a silver-muted Badge) and an avatarProps option for the avatar (halo, shape, size).

Extends a PDS componentOpen in Storybook

proposals/SectionMessage/SectionMessage.tsx298 lines · View on GitHub
import { ComponentPropsWithoutRef, ReactNode, useEffect, useRef } from 'react';

import { CloseButton } from '@pantheon-systems/pds-toolkit-react';

import { AgentAvatar, AgentAvatarProps } from '../AgentAvatar/AgentAvatar';
import { Badge } from '../Badge/Badge';
import { Button } from '../_base/Button/Button';
import { Icon, IconProps } from '../Icon/Icon';

import './section-message.css';

// Stand-ins for the v2 `mergeClasses` util, `StatusType` and `StatusTypeLabels`,
// which the published package does not export. The port uses the real ones.
const mergeClasses = (classes: Array<string | null | undefined>) =>
	classes.filter(Boolean).join(' ');

export type SectionMessageType =
	'agentic' | 'critical' | 'discovery' | 'info' | 'success' | 'warning';

type TypeLabels = Record<SectionMessageType, string>;

const StatusTypeLabels: TypeLabels = {
	agentic: 'Agentic',
	critical: 'Critical',
	discovery: 'Discovery',
	info: 'Information',
	success: 'Success',
	warning: 'Warning',
};

const iconTypes: Record<SectionMessageType, IconProps['iconName']> = {
	agentic: 'zappy',
	info: 'circleInfo',
	warning: 'triangleExclamation',
	success: 'circleCheck',
	critical: 'diamondExclamation',
	discovery: 'handWave',
};

export interface SectionMessageProps extends ComponentPropsWithoutRef<'div'> {
	/**
	 * Options for the Agent Avatar, which is the leading icon of the `agentic`
	 * type (the `iconName` prop picks its icon, and its screen-reader label is
	 * the type's label). Takes the Agent Avatar props, for example `hasHalo`,
	 * `shape` or `size`; the size defaults to fit the first row. Passing it on
	 * another `type` shows the avatar there too.
	 */
	avatarProps?: Partial<AgentAvatarProps>;
	/**
	 * Optional leading icon for the title badge. Only shown when `badgeLabel`
	 * is also set.
	 */
	badgeIcon?: IconProps['iconName'];
	/**
	 * Optional badge text shown beside the title. Requires `title`. The badge
	 * color matches `type`, except for `agentic`, where it is the neutral
	 * `silver-muted`.
	 */
	badgeLabel?: string;
	/**
	 * Additional class names.
	 */
	className?: string;
	/**
	 * Props for optional call to action button.
	 * Can include any Button props except for size and variant.
	 */
	ctaButton?: ComponentPropsWithoutRef<typeof Button>;
	/**
	 * Optional leading icon for `ctaButton`. Always colored to match the
	 * section message's own foreground color.
	 */
	ctaIcon?: IconProps['iconName'];
	/**
	 * Label for dismiss button. Provide a translation string if needed.
	 */
	dismissLabel?: string;
	/**
	 * Icon of the Agent Avatar of the `agentic` type: `zappy` for Pantheon
	 * agents, `sparklesSolid` for third-party agents. Ignored for the other
	 * types.
	 */
	iconName?: 'sparklesSolid' | 'zappy';
	/**
	 * Unique id of message.
	 */
	id?: string;
	/**
	 * Includes dismiss functionality.
	 */
	isDismissible?: boolean;
	/**
	 * Message text.
	 */
	message: ReactNode;
	/**
	 * Callback function when message is dismissed.
	 */
	onDismiss?: (event: Event, id?: string) => void;
	/**
	 * Props for an optional secondary action, rendered before `ctaButton` so the
	 * more prominent action sits on the outside. Can include any Button props
	 * except for size. Defaults to the `link` variant.
	 */
	secondaryCta?: ComponentPropsWithoutRef<typeof Button>;
	/**
	 * Message title.
	 */
	title?: string;
	/**
	 * Message type.
	 */
	type: SectionMessageType;
	/**
	 * Message type labels. Provide translation strings if needed.
	 */
	typeLabels?: TypeLabels;
}

/**
 * SectionMessage UI component
 */
export const SectionMessage = ({
	avatarProps,
	badgeIcon,
	badgeLabel,
	className,
	ctaButton,
	ctaIcon,
	dismissLabel = 'Dismiss',
	iconName,
	id,
	isDismissible,
	message,
	onDismiss,
	secondaryCta,
	title,
	type = 'info',
	typeLabels = StatusTypeLabels,
	...props
}: SectionMessageProps) => {
	const messageRef = useRef<HTMLDivElement>(null);
	const dismissTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(
		undefined,
	);

	// Set up classes.
	const baseClass = 'pds-section-message';
	const cssClasses = {
		agentic: `${baseClass}--agentic`,
		success: `${baseClass}--success`,
		warning: `${baseClass}--warning`,
		info: `${baseClass}--info`,
		critical: `${baseClass}--critical`,
		discovery: `${baseClass}--discovery`,
		dismissing: `${baseClass}--dismissing`,
		dismissed: `${baseClass}--dismissed`,
	};
	const hasDismissClass = isDismissible ? `${baseClass}--hasDismiss` : null;
	const hasTitleClass = title ? `${baseClass}--hasTitle` : null;

	const isAgentic = type === 'agentic';

	// Configure role based on type.
	const roleType = type === 'critical' ? 'alert' : 'status';

	// Label by type.
	const messageTypeLabel = `${typeLabels[type]}: `;

	// Preprocess message by prop type.
	const processedMessage =
		typeof message === 'string' ? <p>{message}</p> : message;

	// Clear any pending dismiss timer on unmount so it can't fire against a
	// detached ref (avoids a null classList access during teardown).
	useEffect(() => {
		return () => {
			if (dismissTimeoutRef.current) {
				clearTimeout(dismissTimeoutRef.current);
			}
		};
	}, []);

	// Set up dismiss functionality.
	const triggerOnDismiss = (event: Event) => {
		onDismiss?.(event, id);
	};

	const dismissAnimationEndEventHandler = () => {
		messageRef.current?.addEventListener('animationend', triggerOnDismiss, {
			once: true,
		});
	};

	const handleDismiss = () => {
		dismissAnimationEndEventHandler();
		messageRef.current?.classList.toggle(cssClasses.dismissing);

		dismissTimeoutRef.current = setTimeout(() => {
			if (!messageRef.current) {
				return;
			}
			messageRef.current.classList.toggle(cssClasses.dismissing);
			messageRef.current.classList.toggle(cssClasses.dismissed);
		}, 400);
	};

	// Render the output
	return (
		<div
			ref={messageRef}
			className={mergeClasses([
				baseClass,
				'pds-copy-s',
				cssClasses[type],
				hasDismissClass,
				hasTitleClass,
				className ?? '',
			])}
			id={id}
			role={roleType}
			{...props}
		>
			<div className={`${baseClass}__content`}>
				<div className={`${baseClass}__icon ${baseClass}__icon-${type}`}>
					{isAgentic || avatarProps ? (
						<AgentAvatar
							iconName={isAgentic ? iconName : undefined}
							label={typeLabels[type]}
							size={title ? 'm' : 's'}
							{...avatarProps}
						/>
					) : (
						<Icon
							iconName={
								`${type}` in iconTypes ? iconTypes[type] : iconTypes.info
							}
							size='xl'
						/>
					)}
				</div>
				<div className={`${baseClass}__text`}>
					<span className='visually-hidden'>{messageTypeLabel}</span>
					{title && (
						<div className={`${baseClass}__header`}>
							<div className={`${baseClass}__title`}>{title}</div>
							{badgeLabel && (
								<Badge
									className={`${baseClass}__badge`}
									color={isAgentic ? 'silver-muted' : type}
									iconName={badgeIcon}
									label={badgeLabel}
									size='s'
								/>
							)}
						</div>
					)}
					<div className={`${baseClass}__message`}>{processedMessage}</div>
				</div>
			</div>
			{(ctaButton || secondaryCta || isDismissible) && (
				<div className={`${baseClass}__actions`}>
					{secondaryCta && (
						<Button
							variant='link'
							{...secondaryCta}
							className={`${baseClass}__secondary`}
							size='s'
						/>
					)}
					{ctaButton && (
						<Button
							{...ctaButton}
							className={`${baseClass}__cta`}
							displayType={ctaIcon ? 'icon-start' : ctaButton.displayType}
							iconName={
								(ctaIcon ?? ctaButton.iconName) as ComponentPropsWithoutRef<
									typeof Button
								>['iconName']
							}
							size='s'
							variant='secondary'
						/>
					)}
					{isDismissible && (
						<CloseButton
							ariaLabel={dismissLabel}
							className={`${baseClass}__dismiss`}
							onClick={handleDismiss}
							size='m'
						/>
					)}
				</div>
			)}
		</div>
	);
};