← All proposals

LabeledDivider

Net-new LabeledDivider: a horizontal rule with a pill-shaped label centered on it.

Net-new componentOpen in Storybook

proposals/LabeledDivider/LabeledDivider.tsx224 lines · View on GitHub
import { ComponentPropsWithoutRef, ReactNode, useState } from 'react';

import { Datepicker, Modal } from '@pantheon-systems/pds-toolkit-react';

import { AgenticMenuButton } from '../AgenticMenuButton/AgenticMenuButton';
import { Icon, IconProps } from '../Icon/Icon';
import { MenuButton, MenuButtonProps } from '../_base/MenuButton/MenuButton';

import './labeled-divider.css';

export type LabeledDividerSize = 'xs' | 's';

export type LabeledDividerTone = 'neutral' | 'agentic';

export interface LabeledDividerDatePicker {
	/**
	 * Latest date that can be picked.
	 */
	maxDate?: Date;
	/**
	 * Label of the last menu item, which opens the date picker (for example
	 * "Jump to a specific date").
	 */
	menuLabel: string;
	/**
	 * Title of the modal that holds the date picker.
	 */
	modalTitle: string;
	/**
	 * Called with the picked date. The modal closes after a date is picked.
	 */
	onChange: (date: Date) => void;
	/**
	 * Label of the date field in the modal.
	 */
	pickerLabel: string;
	/**
	 * Currently selected date, shown in the picker.
	 */
	value?: Date;
}

export interface LabeledDividerProps extends ComponentPropsWithoutRef<'div'> {
	/**
	 * Swaps the label for any control, centered on the line (for example a
	 * "Show more" Button that unfolds more content under the divider). The
	 * screen owns the control and what it does, including `aria-expanded` and
	 * the content it reveals. When set, `label`, `menuItems`, `prefixIcon` and
	 * `suffixIcon` are ignored, and the divider is a plain group, not a
	 * separator. The `tone` still colors the lines either side.
	 */
	action?: ReactNode;
	/**
	 * Additional class names
	 */
	className?: string;
	/**
	 * ID value for the trigger button. Required when `menuItems` is set.
	 */
	id?: string;
	/**
	 * Text on the divider (for example "Yesterday" or "Zappy engaged").
	 * Announced to screen readers.
	 */
	label: string;
	/**
	 * Adds a last menu item that opens the PDS Datepicker in a Modal, to jump to
	 * a specific date. Only used when `menuItems` is set.
	 */
	datePicker?: LabeledDividerDatePicker;
	/**
	 * Turns the label into a selector: a button with a chevron that opens this
	 * menu, for jumping to another point in time. Without it the label is
	 * static text. Needs `id`.
	 */
	menuItems?: MenuButtonProps['menuItems'];
	/**
	 * Optional icon shown before the label.
	 */
	prefixIcon?: IconProps['iconName'];
	/**
	 * Size. Sets the text size, the icon size and the padding of the label; the
	 * selector's button takes the same size.
	 */
	size?: LabeledDividerSize;
	/**
	 * Optional icon shown after the label. On the selector it sits before the
	 * chevron.
	 */
	suffixIcon?: IconProps['iconName'];
	/**
	 * Visual tone. `agentic` gives the label the AI gradient border, the lines
	 * the gradient and the shimmer. The selector uses the gradient border for
	 * any tone other than `neutral`.
	 */
	tone?: LabeledDividerTone;
}

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

/**
 * LabeledDivider UI component. A horizontal rule with a pill label centered on
 * it, to mark a point in a stream: a date, a jump in time or a state change.
 */
export const LabeledDivider = ({
	action,
	className,
	datePicker,
	id,
	label,
	menuItems,
	prefixIcon,
	size = 's',
	suffixIcon,
	tone = 'neutral',
	...props
}: LabeledDividerProps) => {
	const baseClass = 'pds-labeled-divider';
	const renderIcon = (iconName: IconProps['iconName']) => (
		<Icon className={`${baseClass}__icon`} iconName={iconName} size={size} />
	);
	const content = (
		<>
			{prefixIcon && renderIcon(prefixIcon)}
			<span>{label}</span>
			{suffixIcon && renderIcon(suffixIcon)}
		</>
	);
	const [isDateModalOpen, setIsDateModalOpen] = useState(false);
	const isSelector = Boolean(menuItems && id);

	const classes = mergeClasses([
		baseClass,
		`pds-copy-${size}`,
		size !== 's' ? `${baseClass}--${size}` : null,
		tone !== 'neutral' ? `${baseClass}--${tone}` : null,
		className,
	]);

	if (action) {
		return (
			<div className={classes} role='group' {...props}>
				<span aria-hidden='true' className={`${baseClass}__line`} />
				<div className={`${baseClass}__action`}>{action}</div>
				<span aria-hidden='true' className={`${baseClass}__line`} />
			</div>
		);
	}

	if (isSelector && menuItems && id) {
		const isAgenticTone = tone !== 'neutral';
		const selectorItems = datePicker
			? [
					...menuItems,
					{ isSeparator: true as const },
					{
						callback: () => setIsDateModalOpen(true),
						label: datePicker.menuLabel,
					},
				]
			: menuItems;

		return (
			<div className={classes} {...props}>
				<span aria-hidden='true' className={`${baseClass}__line`} />
				{isAgenticTone ? (
					<AgenticMenuButton
						className={`${baseClass}__selector`}
						id={id}
						label={content}
						menuItems={selectorItems}
						size={size}
					/>
				) : (
					<MenuButton
						className={`${baseClass}__selector`}
						id={id}
						label={content}
						menuItems={selectorItems}
						size={size}
						variant='secondary'
					/>
				)}
				<span aria-hidden='true' className={`${baseClass}__line`} />
				{datePicker && (
					<Modal
						modalIsOpen={isDateModalOpen}
						setModalIsOpen={setIsDateModalOpen}
						size='s'
						title={datePicker.modalTitle}
					>
						<Datepicker
							id={`${id}-datepicker`}
							label={datePicker.pickerLabel}
							maxDate={datePicker.maxDate}
							mode='single'
							onChange={(date) => {
								if (date) {
									datePicker.onChange(date);
									setIsDateModalOpen(false);
								}
							}}
							value={datePicker.value}
						/>
					</Modal>
				)}
			</div>
		);
	}

	return (
		<div aria-label={label} className={classes} role='separator' {...props}>
			<span aria-hidden='true' className={`${baseClass}__line`} />
			<span aria-hidden='true' className={`${baseClass}__label`}>
				{content}
			</span>
			<span aria-hidden='true' className={`${baseClass}__line`} />
		</div>
	);
};