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>
);
};
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