← All proposals

Agentic Split Button

A separate component, `AgenticSplitButton`, a copy of the PDS v2 Split Button, so the engineer's port is mostly a copy:

Net-new componentOpen in Storybook

proposals/AgenticSplitButton/AgenticSplitButton.tsx166 lines · View on GitHub
import { ComponentPropsWithoutRef, useRef } from 'react';

import type { Placement } from '@floating-ui/react';
import {
	ButtonLink,
	LinkItemType,
	MenuItemType,
	PDSIcon,
} from '@pantheon-systems/pds-toolkit-react';

import { Button } from '../_base/Button/Button';
import { mergeClasses } from '../_base/Button/utils';
import { MenuButton } from '../_base/MenuButton/MenuButton';

import './agentic-split-button.css';

type SplitButtonSize = 's' | 'm';

/**
 * Prop types for AgenticSplitButton
 */
export interface AgenticSplitButtonProps extends ComponentPropsWithoutRef<'span'> {
	/**
	 * Array of menu items
	 */
	actionItems: Array<LinkItemType | MenuItemType>;
	/**
	 * Anchor the actions menu panel to the whole button (primary action +
	 * caret combined) instead of just the caret trigger. Defaults to
	 * `false` — the panel positions relative to the caret alone, matching
	 * current behavior. Set this when `placement` needs to align the panel
	 * with an edge of the whole control (e.g. `bottom-start` under the
	 * primary action's left edge, for a table row action) rather than just
	 * the caret's narrow width.
	 */
	anchorToFullButton?: boolean;
	/**
	 * Additional class names
	 */
	className?: string;
	/**
	 * Is the button disabled?
	 */
	disabled?: boolean;
	/**
	 * Icon shown at the start of the primary action, e.g. `'plus'`. Only
	 * applies to the primary action — there's no equivalent for menu items.
	 */
	iconName?: PDSIcon;
	/**
	 * ID value for this component.
	 */
	id: string;
	/**
	 * Shimmers the gradient border. Off for reduced motion.
	 */
	isAnimated?: boolean;
	/**
	 * Label for the buttons additional actions menu.
	 */
	moreActionsLabel?: string;
	/**
	 * Preferred placement of the actions menu panel. Defaults to
	 * `bottom-start`, matching `MenuButton`'s own default. Aligns relative
	 * to the caret trigger alone unless `anchorToFullButton` is set, in
	 * which case it aligns relative to the whole control instead. Passed
	 * straight through to the underlying `MenuButton`.
	 */
	placement?: Placement;
	/**
	 * Which size of button to render
	 */
	size?: SplitButtonSize;
}

/**
 * A split button for actions the agent does: a primary action with a menu of
 * additional options, with the AI gradient as the border around the whole
 * control. The secondary look (there is no primary version).
 */
export const AgenticSplitButton = ({
	actionItems,
	anchorToFullButton = false,
	className,
	disabled,
	iconName,
	id,
	isAnimated = false,
	moreActionsLabel = 'More actions',
	placement = 'bottom-start',
	size = 's',
	...props
}: AgenticSplitButtonProps) => {
	// Set up classes.
	const baseClass = 'pds-split-button';
	const variantClass = `${baseClass}--secondary`;
	const agenticClass = 'pds-agentic-split-button';
	const animatedClass = isAnimated ? `${agenticClass}--animated` : null;

	const rootRef = useRef<HTMLSpanElement>(null);

	// Separate the primary action from the rest.
	const primaryAction = actionItems[0];
	const moreActions = actionItems.slice(1);

	// Preprocess the first item based on its type.
	let primaryActionElement = null;

	if ('isLink' in primaryAction && primaryAction.isLink === true) {
		primaryActionElement = (
			<ButtonLink
				className={`${baseClass}__primary-action`}
				disabled={disabled}
				displayType={iconName ? 'icon-start' : 'label-only'}
				iconName={iconName}
				linkContent={primaryAction.linkContent}
				size={size}
				variant='secondary'
			/>
		);
	} else if ('label' in primaryAction) {
		primaryActionElement = (
			<Button
				className={`${baseClass}__primary-action`}
				disabled={disabled}
				displayType={iconName ? 'icon-start' : 'label-only'}
				iconName={iconName}
				label={primaryAction.label}
				onClick={() => primaryAction.callback?.()}
				size={size}
				variant='secondary'
			/>
		);
	}

	// Render the output
	return (
		<span
			ref={rootRef}
			className={mergeClasses([
				baseClass,
				variantClass,
				agenticClass,
				animatedClass,
				className,
			])}
			id={id}
			{...props}
		>
			{primaryActionElement}
			<MenuButton
				isSplitButton
				className={`${baseClass}__menu-button`}
				disabled={disabled}
				id={`${id}-menu`}
				label={moreActionsLabel}
				menuItems={moreActions}
				placement={placement}
				positionReferenceRef={anchorToFullButton ? rootRef : undefined}
				size={size}
				variant='secondary'
			/>
		</span>
	);
};