← All proposals

Icon Button

Starting point for an IconButton proposal.

Extends a PDS componentOpen in Storybook

proposals/IconButton/IconButton.tsx288 lines · View on GitHub
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>
		);
	},
);