← All proposals

Timeline

Net-new, in two parts.

Net-new componentOpen in Storybook

proposals/Timeline/TimelineItem.tsx207 lines · View on GitHub
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>
	);
};