import { ComponentPropsWithoutRef, ReactNode } from 'react';
import { Icon, IconProps } from '../Icon/Icon';
import { Spinner } from '../Spinner/Spinner';
import './timeline-item.css';
export type TimelineItemSize = 's' | 'xs';
export type TimelineItemStatus = 'active' | 'done' | 'error' | 'pending';
export interface TimelineItemProps extends ComponentPropsWithoutRef<'div'> {
/**
* Content shown under the step, always visible and lined up with the label.
* Pass anything: a log block, file chips, a table. For content that starts
* collapsed, use `detail`.
*/
children?: ReactNode;
/**
* Additional class names
*/
className?: string;
/**
* Optional expandable content, such as a tool call's input and output. A
* step with `detail` becomes a toggle.
*/
detail?: ReactNode;
/**
* Is this item the agent's? Switches the status icon from the neutral
* secondary color to the agent color (errors stay critical).
*/
isAgentic?: boolean;
/**
* Quiet text for secondary rows such as a search query or "+7 more".
*/
isQuiet?: boolean;
/**
* Replaces the status icon, for example a wrench for a tool call. While the
* step is `active` the spinner still shows, and the status color and
* screen reader text still follow `status`.
*/
iconName?: IconProps['iconName'];
/**
* What happened or is happening.
*/
label: ReactNode;
/**
* Object with translatable strings, read by screen readers with the status.
*/
labelStrings?: Record<TimelineItemStatus, string>;
/**
* Quiet supporting text after the label. Wrap file names, commands and
* other code in `<code>`.
*/
secondaryText?: ReactNode;
/**
* Text size of the item, as a `pds-copy-*` class on it. Leave it out to
* inherit, for example from the `size` of a `Timeline`.
*/
size?: TimelineItemSize;
/**
* Where the step is at. Without it (and without `iconName`) the step has no
* icon.
*/
status?: TimelineItemStatus;
/**
* When it happened, formatted by the consumer (for example "01:08 AM" or
* "now"). Shown at the end of the row. Pass `timeDateTime` for a
* machine-readable value.
*/
time?: string;
/**
* Machine-readable value for the `time` element (for example an ISO
* date-time).
*/
timeDateTime?: string;
}
// 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(' ');
const defaultLabelStrings: Record<TimelineItemStatus, string> = {
active: 'In progress',
done: 'Done',
error: 'Failed',
pending: 'Pending',
};
const defaultIcons: Record<TimelineItemStatus, IconProps['iconName']> = {
active: 'circle',
done: 'check',
error: 'diamondExclamation',
pending: 'circle',
};
/**
* TimelineItem UI component. One event or piece of work: a status icon, a
* label, quiet secondary text, a time and optional expandable detail. Use it
* alone inline or inside `Timeline` or `ChainOfThought`.
*/
export const TimelineItem = ({
children,
className,
detail,
iconName,
isAgentic = false,
isQuiet = false,
label,
labelStrings = defaultLabelStrings,
secondaryText,
size,
status,
time,
timeDateTime,
...props
}: TimelineItemProps) => {
const baseClass = 'pds-timeline-item';
const hasIcon = status !== undefined || iconName !== undefined;
const statusIcon = () => {
if (status === 'active') {
return (
<Spinner
className={`${baseClass}__status`}
color='var(--timeline-item-status-color)'
label={labelStrings.active}
size='s'
/>
);
}
return (
<>
<Icon
className={`${baseClass}__status`}
iconName={iconName ?? defaultIcons[status ?? 'pending']}
size='xs'
/>
{status && (
<span className='visually-hidden'>{labelStrings[status]}</span>
)}
</>
);
};
const row = (
<>
{hasIcon && statusIcon()}
<span className={`${baseClass}__label`}>{label}</span>
{secondaryText && (
<span className={`pds-copy-xs ${baseClass}__secondary-text`}>
{secondaryText}
</span>
)}
{time && (
<time
className={`pds-copy-xs ${baseClass}__time`}
dateTime={timeDateTime}
>
{time}
</time>
)}
</>
);
return (
<div
className={mergeClasses([
baseClass,
size ? `pds-copy-${size}` : null,
status ? `${baseClass}--${status}` : null,
isAgentic ? `${baseClass}--agentic` : null,
isQuiet ? `${baseClass}--quiet` : null,
time ? `${baseClass}--has-time` : null,
className,
])}
{...props}
>
{detail ? (
<details>
<summary className={`${baseClass}__row`}>
{row}
<Icon
className={`${baseClass}__toggle`}
iconName='angleDown'
size='xs'
/>
</summary>
<div className={`${baseClass}__detail`}>{detail}</div>
</details>
) : (
<div className={`${baseClass}__row`}>{row}</div>
)}
{children && (
<div
className={mergeClasses([
`${baseClass}__content`,
hasIcon ? `${baseClass}__content--indented` : null,
])}
>
{children}
</div>
)}
</div>
);
};
Timeline
Net-new, in two parts.
Net-new componentOpen in Storybook