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,
ButtonVariant,
StatusDot,
StatusDotType,
extractText,
mergeClasses,
} from './utils';
import './button.css';
interface ButtonProps 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;
/**
* Is the button agentic? Adds the AI gradient (`--pds-gradient-ai-active`)
* as the border and uses the gradient working spinner. Only valid on
* `primary` and `secondary` variants.
*/
isAgentic?: 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;
/**
* Which variant of button to render
*/
variant?: ButtonVariant;
}
/**
* A Component used to render a Button
*/
export const Button = forwardRef(
(
{
ariaLabel,
buttonType = 'button',
className = '',
disabled = false,
disableWhenWorking = true,
displayType = 'label-only',
iconName,
isAgentic = false,
isFullWidth = false,
isLoading = false,
isSplitButton = false,
isWorking = false,
label,
onClick,
size = 's',
statusDot,
tally,
tooltipText,
variant = 'primary',
...props
}: ButtonProps,
ref: Ref<HTMLButtonElement>,
) => {
// Set up classes.
const baseClass = 'pds-button';
const sizeClass = `${baseClass}--${size}`;
const variantClass = `${baseClass}--${variant}`;
const agenticAllowedVariants: ButtonVariant[] = ['primary', 'secondary'];
const agenticAllowed = agenticAllowedVariants.includes(variant);
if (isAgentic && !agenticAllowed && process.env.NODE_ENV !== 'production') {
console.warn(
`[Button] The \`isAgentic\` prop is not supported on the "${variant}" variant. Use one of: ${agenticAllowedVariants.join(', ')}.`,
);
}
const agenticClass =
isAgentic && agenticAllowed ? `${baseClass}--agentic` : 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;
}
// Tally is only supported on specific variants.
const tallyAllowedVariants: ButtonVariant[] = [
'primary',
'secondary',
'subtle',
'highlight',
];
const tallyAllowed = tallyAllowedVariants.includes(variant);
if (tally && !tallyAllowed && process.env.NODE_ENV !== 'production') {
console.warn(
`[Button] The \`tally\` prop is not supported on the "${variant}" variant. Use one of: ${tallyAllowedVariants.join(', ')}.`,
);
}
// StatusDot is only supported on specific variants.
const statusDotAllowedVariants: ButtonVariant[] = [
'primary',
'secondary',
'subtle',
];
const statusDotAllowed = statusDotAllowedVariants.includes(variant);
if (
statusDot &&
!statusDotAllowed &&
process.env.NODE_ENV !== 'production'
) {
console.warn(
`[Button] The \`statusDot\` prop is not supported on the "${variant}" variant. Use one of: ${statusDotAllowedVariants.join(', ')}.`,
);
}
// 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 && statusDotAllowed) {
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 && tallyAllowed) {
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,
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={isAgentic && agenticAllowed ? 'agentic' : 'currentColor'}
size={setSpinnerSize}
/>
)}
{leadingContent}
</span>
{buttonContent}
</button>
);
return isInteractionDisabled && tooltipText ? (
<Tooltip
disableTapTrigger
content={tooltipText}
customTrigger={buttonElement}
/>
) : (
<>{buttonElement}</>
);
},
);
Button
Starting point for a Button proposal.
Extends a PDS componentOpen in Storybook