import React, {
ComponentPropsWithoutRef,
forwardRef,
MouseEvent,
ReactNode,
Ref,
} from 'react';
import {
Icon,
IconSize,
PDSIcon,
Tally,
TallyProps,
Tooltip,
} from '@pantheon-systems/pds-toolkit-react';
import { Spinner, SpinnerSize } from '../Spinner/Spinner';
import {
ButtonDisplayType,
ButtonSize,
ButtonType,
StatusDot,
StatusDotType,
extractText,
mergeClasses,
} from '../_base/Button/utils';
import '../_base/Button/button.css';
import './agentic-button.css';
export interface AgenticButtonProps extends ComponentPropsWithoutRef<'button'> {
/**
* Aria-label attribute.
*/
ariaLabel?: string;
/**
* Type of button
*/
buttonType?: ButtonType;
/**
* Additional class names
*/
className?: string;
/**
* Is the button disabled?
*/
disabled?: boolean;
/**
* Whether `isWorking` disables the button. Defaults to `true`, matching the
* one-off "action in flight" use case (e.g. a submit button mid-request).
* Set to `false` for an ongoing, interactive working state — such as a
* toggle that starts/stops a live process and must stay clickable while
* it runs.
*/
disableWhenWorking?: boolean;
/**
* How the button is displayed.
*/
displayType?: ButtonDisplayType;
/**
* Which icon to render
*/
iconName?: PDSIcon;
/**
* Shimmers the gradient border. Off for reduced motion.
*/
isAnimated?: boolean;
/**
* is the button full width?
*/
isFullWidth?: boolean;
/**
* Is the button in the process of an initial load?
*/
isLoading?: boolean;
/**
* Will this be used for a split button?
*/
isSplitButton?: boolean;
/**
* Is the button in a working state? Renders a leading spinner (colored and
* sized to match the button) while keeping the label and icon visible.
* Disables the button unless `disableWhenWorking` is set to `false`.
*/
isWorking?: boolean;
/**
* The text of the button
*/
label: ReactNode;
/**
* Click event handler callback
*/
onClick?: (e: MouseEvent<HTMLElement>) => void;
/**
* Which size of button to render
* @default 's'
*/
size?: ButtonSize;
/**
* A status dot displayed in the left icon slot. Mutually exclusive with `iconName` when `displayType` is `icon-start`.
* Only valid on `primary`, `secondary`, and `subtle` variants.
*/
statusDot?: StatusDotType;
/**
* A tally badge displayed in the right icon slot. Mutually exclusive with `iconName` when `displayType` is `icon-end`.
* Only valid on `primary`, `secondary`, `subtle`, and `highlight` variants.
*/
tally?: {
label: TallyProps['label'];
type: TallyProps['type'];
};
/**
* Text for an optional tooltip to be displayed when the button is `disabled`, `isLoading`, or `isWorking`.
*/
tooltipText?: string;
}
/**
* AgenticButton UI component. A button for an action the agent does: the
* secondary Button look with the AI gradient as its border and a gradient
* working spinner. It takes every Button prop except `variant`.
*/
export const AgenticButton = forwardRef(
(
{
ariaLabel,
buttonType = 'button',
className = '',
disabled = false,
disableWhenWorking = true,
displayType = 'label-only',
iconName,
isAnimated = false,
isFullWidth = false,
isLoading = false,
isSplitButton = false,
isWorking = false,
label,
onClick,
size = 's',
statusDot,
tally,
tooltipText,
...props
}: AgenticButtonProps,
ref: Ref<HTMLButtonElement>,
) => {
// Set up classes. The base and secondary classes give the Button look;
// `pds-agentic-button` adds the gradient border.
const baseClass = 'pds-button';
const agenticClass = 'pds-agentic-button';
const sizeClass = `${baseClass}--${size}`;
const variantClass = `${baseClass}--secondary`;
const animatedClass = isAnimated ? `${agenticClass}--animated` : null;
const fullWidthClass = isFullWidth ? `${baseClass}--full-width` : null;
const loadingClass = isLoading ? `${baseClass}--loading` : null;
const pulseClass = isLoading ? 'pds-pulse' : null;
const isInteractionDisabled =
disabled || isLoading || (isWorking && disableWhenWorking);
// Map button sizes to icon and spinner sizes.
let setIconSize: IconSize;
let setSpinnerSize: SpinnerSize;
let setTallySize: TallyProps['size'];
switch (size) {
case 'xs':
setIconSize = 'xs';
setSpinnerSize = 's';
setTallySize = 'xs';
break;
case 's':
setIconSize = 'm';
setSpinnerSize = 'm';
setTallySize = 'xs';
break;
case 'm':
setIconSize = 'm';
setSpinnerSize = 'm';
setTallySize = 's';
break;
case 'l':
setIconSize = 'xl';
setSpinnerSize = 'l';
setTallySize = 'm';
break;
}
// Render the chosen icon
// (the published package types `iconName` as required, so guard here)
const renderedIcon = iconName ? (
<Icon
key='icon'
className={`${baseClass}__icon`}
iconName={iconName}
size={setIconSize}
/>
) : null;
// Set content of the button based on displayType, tally, and statusDot.
// Leading content (status dot / icon-start) renders inside the leading
// slot together with the working spinner; see the slot below.
let leadingContent: ReactNode = null;
let buttonContent: ReactNode[] = [];
// Left slot: statusDot takes priority over icon-start (only on allowed variants)
if (statusDot) {
leadingContent = (
<StatusDot className={`${baseClass}__icon`} status={statusDot} />
);
} else if (displayType === 'icon-start' && iconName) {
leadingContent = renderedIcon;
}
buttonContent.push(<React.Fragment key='label'>{label}</React.Fragment>);
// Right slot: tally takes priority over icon-end (only on allowed variants)
if (tally) {
buttonContent.push(
<Tally
key='tally'
className={`${baseClass}__tally`}
label={tally.label}
size={setTallySize}
type={tally.type}
/>,
);
} else if (displayType === 'icon-end' && iconName) {
buttonContent.push(renderedIcon);
}
// Use label as aria-label when used as a split button.
if (isSplitButton) {
leadingContent = null;
buttonContent = [renderedIcon];
ariaLabel = extractText(label);
}
// Preprocess the button element.
const buttonElement = (
<button
ref={ref}
aria-label={ariaLabel}
className={mergeClasses([
baseClass,
sizeClass,
variantClass,
agenticClass,
animatedClass,
fullWidthClass,
loadingClass,
pulseClass,
className,
])}
disabled={isInteractionDisabled}
onClick={onClick}
type={buttonType}
{...props}
>
{/* Anything that toggles in front of the label lives in this
`display: contents` slot, so React only ever inserts inside it
and never in front of the label's text node. Browser page
translation replaces text nodes, which makes React throw on
insertBefore. The label itself stays a bare text node. */}
<span className={`${baseClass}__leading`}>
{isWorking && !isLoading && (
<Spinner
className={`${baseClass}__working-indicator`}
color='agentic'
size={setSpinnerSize}
/>
)}
{leadingContent}
</span>
{buttonContent}
</button>
);
return isInteractionDisabled && tooltipText ? (
<Tooltip
disableTapTrigger
content={tooltipText}
customTrigger={buttonElement}
/>
) : (
<>{buttonElement}</>
);
},
);
Agentic Button
A separate component, `AgenticButton`, for an action the agent does:
Net-new componentOpen in Storybook