import React, {
ComponentPropsWithoutRef,
forwardRef,
MouseEvent,
Ref,
useEffect,
useRef,
} from 'react';
import {
ButtonType,
IconSize,
Tooltip,
} from '@pantheon-systems/pds-toolkit-react';
import { Icon, IconProps } from '../Icon/Icon';
import './icon-button.css';
// 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(' ');
export type IconButtonSize = 'xs' | 's' | 'm';
interface IconButtonProps extends ComponentPropsWithoutRef<'button'> {
/**
* Aria-label attribute.
*/
ariaLabel: string;
/**
* Type of button
*/
buttonType?: ButtonType;
/**
* Additional class names.
*/
className?: string;
/**
* Is the button disabled?
*/
disabled?: boolean;
/**
* Show a visible border around the button.
*/
hasBorder?: boolean;
/**
* Show tooltip on hover. If false, the title attribute will be used.
*/
hasTooltip?: boolean;
/**
* Which icon to render second. If this is set, the button will be animated.
*/
icon2Name?: IconProps['iconName'];
/**
* Which icon to render.
*/
iconName: IconProps['iconName'];
/**
* Is the button agentic? Paints the border with the AI gradient
* (`--pds-gradient-ai-active`) instead of the plain border color. Needs
* `hasBorder`; without a border there is nothing to color. Only valid on
* the `standard` variant.
*/
isAgentic?: boolean;
/**
* Whether the button is in a pressed/active toggle state. Maps to aria-pressed.
*/
isPressed?: boolean;
/**
* Click event handler callback.
*/
onClick?: (e: MouseEvent<HTMLElement>) => void;
/**
* Icon to display when isPressed is true. When not set, iconName is used in both states.
*/
pressedIconName?: IconProps['iconName'];
/**
* Which size of button to render.
*/
size?: IconButtonSize;
/**
* Which variant of button to render.
*/
variant?: 'standard' | 'reverse' | 'critical';
}
/**
* IconButton UI component
*/
export const IconButton = forwardRef(
(
{
ariaLabel,
buttonType = 'button',
className,
disabled = false,
hasBorder = false,
hasTooltip = true,
icon2Name,
iconName,
isAgentic = false,
isPressed,
onClick,
pressedIconName,
size = 's',
variant = 'standard',
...props
}: IconButtonProps,
ref: Ref<HTMLButtonElement>,
) => {
// Set up classes.
const baseClass = 'pds-icon-button';
const sizeClass = size !== 's' ? `${baseClass}--${size}` : null;
const variantClass =
variant !== 'standard' ? `${baseClass}--${variant}` : null;
const agenticAllowed = variant === 'standard';
if (isAgentic && !agenticAllowed && process.env.NODE_ENV !== 'production') {
console.warn(
`[IconButton] The \`isAgentic\` prop is not supported on the "${variant}" variant. Use "standard".`,
);
}
const agenticClass =
isAgentic && agenticAllowed && hasBorder ? `${baseClass}--agentic` : null;
const borderClass = hasBorder ? `${baseClass}--has-border` : null;
const pressedClass = isPressed ? `${baseClass}--pressed` : null;
const animationClasses = {
fadeOut: `${baseClass}--fadeOut`,
hide: `${baseClass}--hide`,
scaleIn: `${baseClass}--scaleIn`,
};
// Map button sizes to icon sizes and offset values.
let setOffsetValue: number;
let setIconSize: IconSize;
switch (size) {
case 'xs':
// One step up from the icon size 's' buttons use, so the icon fills
// more of the smaller 1.75rem box rather than looking lost in it.
setIconSize = 'm';
setOffsetValue = 6;
break;
case 's':
setIconSize = 'm';
setOffsetValue = 8;
break;
case 'm':
setIconSize = 'xl';
setOffsetValue = 11;
break;
default:
setIconSize = 'm';
setOffsetValue = 8;
break;
}
// If there is a second icon, the button will be animated.
const isAnimated = Boolean(icon2Name);
// Set up refs for the two icons.
const icon1Ref = useRef<HTMLDivElement>(null);
const icon2Ref = useRef<HTMLDivElement>(null);
const animationTimeoutRef = useRef<
ReturnType<typeof setTimeout> | undefined
>(undefined);
// Clear any pending animation timer on unmount so it can't fire against a
// detached ref (avoids a null classList access during teardown).
useEffect(() => {
return () => {
if (animationTimeoutRef.current) {
clearTimeout(animationTimeoutRef.current);
}
};
}, []);
// Set up the animation transitions.
const handleAnimation = () => {
if (!icon1Ref.current || !icon2Ref.current) {
return;
}
icon1Ref.current.classList.add(animationClasses.fadeOut);
icon1Ref.current.classList.add(animationClasses.hide);
icon2Ref.current.classList.remove(animationClasses.hide);
icon2Ref.current.classList.add(animationClasses.scaleIn);
animationTimeoutRef.current = setTimeout(() => {
if (!icon1Ref.current || !icon2Ref.current) {
return;
}
icon1Ref.current.classList.remove(
animationClasses.fadeOut,
animationClasses.hide,
);
icon1Ref.current.classList.add(animationClasses.scaleIn);
icon2Ref.current.classList.remove(animationClasses.scaleIn);
icon2Ref.current.classList.add(animationClasses.hide);
}, 2000);
};
// Handle click.
const handleClick = (e: MouseEvent<HTMLElement>) => {
if (isAnimated) {
handleAnimation();
}
if (onClick) {
onClick(e);
}
};
const activeIconName =
isPressed && pressedIconName ? pressedIconName : iconName;
// Preprocess button content.
const buttonContent = icon2Name ? (
<div className={`${baseClass}__content`}>
<div ref={icon1Ref} className={`${baseClass}__icon-wrapper`}>
<Icon
className={`${baseClass}__icon`}
iconName={iconName}
size={setIconSize}
/>
</div>
<div
ref={icon2Ref}
className={`${baseClass}__icon-wrapper ${animationClasses.hide}`}
>
<Icon
className={`${baseClass}__icon`}
iconName={icon2Name}
size={setIconSize}
/>
</div>
</div>
) : (
<div className={`${baseClass}__content`}>
<Icon
className={`${baseClass}__icon`}
iconName={activeIconName}
size={setIconSize}
/>
</div>
);
// Render the output
return (
<button
ref={ref}
aria-label={ariaLabel}
aria-pressed={isPressed}
className={mergeClasses([
baseClass,
sizeClass,
variantClass,
borderClass,
agenticClass,
pressedClass,
className,
])}
disabled={disabled}
onClick={handleClick}
title={hasTooltip && !disabled ? undefined : ariaLabel}
type={buttonType}
{...props}
>
{hasTooltip && !disabled ? (
<Tooltip
disableTapTrigger
className={`${baseClass}__tooltip`}
content={ariaLabel}
customTrigger={buttonContent}
offsetValue={setOffsetValue}
/>
) : (
buttonContent
)}
</button>
);
},
);
Icon Button
Starting point for an IconButton proposal.
Extends a PDS componentOpen in Storybook