← All proposals

Milestone

Net-new, in two parts, in their own family so "timeline" keeps one meaning (the log).

Net-new componentOpen in Storybook

proposals/Milestone/Milestone.tsx159 lines · View on GitHub
import { ComponentPropsWithoutRef, ReactNode, useContext } from 'react';

import { Badge, BadgeProps } from '@pantheon-systems/pds-toolkit-react';

import { Panel } from '../Panel/Panel';

import { MilestoneListContext } from './milestone-list-context';

import './milestone.css';

export type MilestoneHeadingLevel = 2 | 3 | 4 | 5 | 6;

export const milestoneVariants = ['card', 'flat'] as const;

export type MilestoneVariant = (typeof milestoneVariants)[number];

export interface MilestoneProps extends Omit<
	ComponentPropsWithoutRef<'div'>,
	'title'
> {
	/**
	 * Type (color) of the Badge beside the subtitle. Only used when
	 * `badgeLabel` is set.
	 */
	badgeColor?: BadgeProps['color'];
	/**
	 * Content of a PDS Badge beside the subtitle: "You're here", a status, a
	 * stage. The Badge is shown when this is set. It sits in the same line as
	 * the subtitle, so it moves with it.
	 */
	badgeLabel?: BadgeProps['label'];
	/**
	 * Size of the Badge beside the subtitle. Only used when `badgeLabel` is
	 * set.
	 */
	badgeSize?: BadgeProps['size'];
	/**
	 * The whole body under the title: the description, and anything after it
	 * (a table, a summary, a form). Blocks in it sit an xs gap apart.
	 */
	children?: ReactNode;
	/**
	 * Additional class names
	 */
	className?: string;
	/**
	 * Level of the title's heading element. Pick the one that fits the page's
	 * outline.
	 */
	headingLevel?: MilestoneHeadingLevel;
	/**
	 * Is this the milestone in focus (the current step, or the one being
	 * read)? The dot takes the accent color with a ring; the others are neutral.
	 * Sets `aria-current='step'`.
	 */
	isActive?: boolean;
	/**
	 * Quiet line in the secondary color: a date, a range, a stage or a short
	 * description. It goes where the `MilestoneList` `subtitlePosition` says:
	 * above or under the title, or beside the dot. Wrap a date in a `time`
	 * element for a machine-readable value.
	 */
	subtitle?: ReactNode;
	/**
	 * What the milestone is called.
	 */
	title: ReactNode;
	/**
	 * `card` puts the milestone on a card (the default Panel); `flat` leaves the
	 * text alone on the line. Leave it out to follow the `MilestoneList`'s
	 * `variant` (`card` unless set).
	 */
	variant?: MilestoneVariant;
}

// 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(' ');

/**
 * Milestone UI component. One milestone in a `MilestoneList`: a dot on the line, a title, a subtitle and a body, on a
 * card (the default Panel) or flat.
 */
export const Milestone = ({
	badgeColor = 'info-muted',
	badgeLabel,
	badgeSize = 'xs',
	children,
	className,
	headingLevel = 3,
	isActive = false,
	subtitle,
	title,
	variant: variantProp,
	...props
}: MilestoneProps) => {
	const baseClass = 'pds-milestone';
	const { subtitlePosition, variant: listVariant } =
		useContext(MilestoneListContext);
	const variant = variantProp ?? listVariant ?? 'card';
	const Heading = `h${headingLevel}` as const;

	const activeClass = isActive ? `${baseClass}--active` : null;
	const cardClass = variant === 'card' ? `${baseClass}--card` : null;

	// The dot sits on the first line of the header, so it goes with the
	// subtitle when that comes first and with the title otherwise.
	const hasSubtitleRow = Boolean(subtitle || badgeLabel);
	const isSubtitleAbove = hasSubtitleRow && subtitlePosition === 'above';
	const marker = <span aria-hidden='true' className={`${baseClass}__marker`} />;

	const subtitleNode = hasSubtitleRow ? (
		<div className={`pds-copy-xs ${baseClass}__subtitle`}>
			{isSubtitleAbove && marker}
			{subtitle}
			{badgeLabel && (
				<Badge color={badgeColor} label={badgeLabel} size={badgeSize} />
			)}
		</div>
	) : null;

	const titleNode = (
		<Heading className={`pds-heading-m ${baseClass}__title`}>
			{!isSubtitleAbove && marker}
			<span>{title}</span>
		</Heading>
	);

	const content = (
		<div className={`${baseClass}__content`}>
			<div className={`${baseClass}__header`}>
				{isSubtitleAbove ? (
					<>
						{subtitleNode}
						{titleNode}
					</>
				) : (
					<>
						{titleNode}
						{subtitleNode}
					</>
				)}
			</div>
			{children && <div className={`${baseClass}__body`}>{children}</div>}
		</div>
	);

	return (
		<div
			aria-current={isActive ? 'step' : undefined}
			className={mergeClasses([baseClass, activeClass, cardClass, className])}
			{...props}
		>
			{variant === 'card' ? <Panel padding='none'>{content}</Panel> : content}
		</div>
	);
};