← 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.tsx109 lines · View on GitHub
import { ComponentPropsWithoutRef, ReactNode, useContext } from 'react';

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

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

import './milestone.css';

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

export type MilestoneVariant = 'card' | 'flat';

export interface MilestoneProps extends Omit<
	ComponentPropsWithoutRef<'div'>,
	'title'
> {
	/**
	 * The body: text, links, anything.
	 */
	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, under the title or, when the
	 * `MilestoneList` has `subtitlePosition='start'`, beside the dot: a date, a range, a
	 * stage or a short description. 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 = ({
	children,
	className,
	headingLevel = 3,
	isActive = false,
	subtitle,
	title,
	variant: variantProp,
	...props
}: MilestoneProps) => {
	const baseClass = 'pds-milestone';
	const context = useContext(MilestoneListContext);
	const variant = variantProp ?? context.variant ?? 'card';
	const Heading = `h${headingLevel}` as const;

	const content = (
		<div className={`${baseClass}__content`}>
			<div className={`${baseClass}__header`}>
				<Heading className={`pds-heading-m ${baseClass}__title`}>
					<span aria-hidden='true' className={`${baseClass}__marker`} />
					<span>{title}</span>
				</Heading>
				{subtitle && (
					<div className={`pds-copy-xs ${baseClass}__subtitle`}>{subtitle}</div>
				)}
			</div>
			{children && <div className={`${baseClass}__body`}>{children}</div>}
		</div>
	);

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