← 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/MilestoneList.tsx102 lines · View on GitHub
import {
	Children,
	ComponentPropsWithoutRef,
	isValidElement,
	ReactNode,
} from 'react';

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

import './milestone-list.css';

export const milestoneListPaddings = ['none', 'standard'] as const;

export type MilestoneListPadding = (typeof milestoneListPaddings)[number];

export const milestoneListSubtitlePositions = [
	'above',
	'below',
	'start',
] as const;

export type MilestoneListSubtitlePosition =
	(typeof milestoneListSubtitlePositions)[number];

export interface MilestoneListProps extends ComponentPropsWithoutRef<'ol'> {
	/**
	 * The milestones. Each child becomes a list item.
	 */
	children?: ReactNode;
	/**
	 * Additional class names
	 */
	className?: string;
	/**
	 * Space above the first milestone and below the last. `standard` keeps the
	 * list's own breathing room; `none` removes it, for a list that sits in a
	 * container (a Panel) that already has padding.
	 */
	padding?: MilestoneListPadding;
	/**
	 * Where each milestone's subtitle goes: `below` the title (default),
	 * `above` it, or `start`, at the left of the line, beside the dot. At
	 * narrow widths `start` falls back to below the title.
	 */
	subtitlePosition?: MilestoneListSubtitlePosition;
	/**
	 * Put each milestone on a card (`card`, default) or leave the text on the
	 * line (`flat`). A milestone can override it.
	 */
	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(' ');

/**
 * MilestoneList UI component. A list of `Milestone`s hung off one line that
 * runs the full height of the list. It owns the list semantics, the spacing,
 * the line and the card style that milestones follow.
 */
export const MilestoneList = ({
	children,
	className,
	padding = 'standard',
	subtitlePosition = 'below',
	variant,
	...props
}: MilestoneListProps) => {
	const baseClass = 'pds-milestone-list';
	const nodes = Children.toArray(children);

	const paddingClass = padding === 'none' ? `${baseClass}--padding-none` : null;
	const subtitleClass =
		subtitlePosition === 'start' ? `${baseClass}--subtitle-start` : null;

	return (
		<MilestoneListContext.Provider value={{ subtitlePosition, variant }}>
			<ol
				className={mergeClasses([
					baseClass,
					paddingClass,
					subtitleClass,
					className,
				])}
				{...props}
			>
				{nodes.map((child, index) => (
					<li
						className={`${baseClass}__entry`}
						key={isValidElement(child) ? (child.key ?? index) : index}
					>
						{child}
					</li>
				))}
			</ol>
		</MilestoneListContext.Provider>
	);
};