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>
);
};
LabeledDivider
Net-new LabeledDivider: a horizontal rule with a pill-shaped label centered on it.
Net-new componentOpen in Storybook