← All proposals

Send Button

A square, filled icon button (primary Button colors, IconButton shape and sizes) with three states:

Net-new componentOpen in Storybook

proposals/SendButton/SendButton.tsx196 lines · View on GitHub
import { ComponentPropsWithoutRef, forwardRef, MouseEvent, Ref } from 'react';

import {
	ButtonType,
	Icon,
	IconSize,
	Tooltip,
} from '@pantheon-systems/pds-toolkit-react';

import { Spinner, SpinnerProps, SpinnerSize } from '../Spinner/Spinner';

import './send-button.css';

export type SendButtonSize = 'xs' | 's' | 'm';

export type SendButtonState = 'send' | 'stop' | 'working';

// PDS has no filled-square "stop" icon yet, so it is drawn here with the same
// markup and size classes as `Icon`. The path is Font Awesome Free's solid
// `stop`, so the port adds it as an FA registry entry (`faStop`, style
// 'solid') and uses `<Icon iconName='stop' />`.
const StopIcon = ({
	className,
	size,
}: {
	className: string;
	size: IconSize;
}) => (
	<svg
		aria-hidden='true'
		className={`pds-icon pds-icon--${size} pds-icon--stop ${className}`}
		fill='none'
		focusable='false'
		role='img'
		viewBox='0 0 640 640'
		xmlns='http://www.w3.org/2000/svg'
	>
		<path
			d='M160 96L480 96C515.3 96 544 124.7 544 160L544 480C544 515.3 515.3 544 480 544L160 544C124.7 544 96 515.3 96 480L96 160C96 124.7 124.7 96 160 96z'
			fill='currentColor'
		/>
	</svg>
);

interface SendButtonProps extends ComponentPropsWithoutRef<'button'> {
	/**
	 * Aria-label attribute. Describes what the button does in its current
	 * state (for example "Send message" or "Stop generating"), so update it
	 * when `state` changes. Also the tooltip text.
	 */
	ariaLabel: string;
	/**
	 * Type of button
	 */
	buttonType?: ButtonType;
	/**
	 * Additional class names.
	 */
	className?: string;
	/**
	 * Is the button disabled? Use when there is nothing to send yet, such as an
	 * empty prompt. Always disabled while `state` is `working`.
	 */
	disabled?: boolean;
	/**
	 * Show tooltip on hover. If false, the title attribute will be used.
	 */
	hasTooltip?: boolean;
	/**
	 * Click event handler callback.
	 */
	onClick?: (e: MouseEvent<HTMLElement>) => void;
	/**
	 * Which size of button to render.
	 */
	size?: SendButtonSize;
	/**
	 * Color of the working spinner. Any CSS color or `var(--token)`, or
	 * `'agentic'` for the AI gradient. Defaults to the button's foreground
	 * color (`currentColor`).
	 */
	spinnerColor?: SpinnerProps['color'];
	/**
	 * What the button is doing. `send` submits the prompt, `working` shows a
	 * spinner while the request is in flight (disabled), and `stop` lets
	 * the user cancel a running response.
	 */
	state?: SendButtonState;
}

/**
 * SendButton UI component
 */
export const SendButton = forwardRef(
	(
		{
			ariaLabel,
			buttonType = 'button',
			className,
			disabled = false,
			hasTooltip = true,
			onClick,
			size = 's',
			spinnerColor = 'currentColor',
			state = 'send',
			...props
		}: SendButtonProps,
		ref: Ref<HTMLButtonElement>,
	) => {
		const baseClass = 'pds-send-button';
		const classes = [
			baseClass,
			size !== 's' ? `${baseClass}--${size}` : null,
			`${baseClass}--${state}`,
			className,
		]
			.filter(Boolean)
			.join(' ');

		// Same icon sizes as IconButton; the spinner is matched to the icon.
		let setIconSize: IconSize;
		let setSpinnerSize: SpinnerSize;
		let setOffsetValue: number;
		switch (size) {
			case 'xs':
				setIconSize = 'm';
				setSpinnerSize = 's';
				setOffsetValue = 6;
				break;
			case 'm':
				setIconSize = 'xl';
				setSpinnerSize = 'l';
				setOffsetValue = 11;
				break;
			default:
				setIconSize = 'm';
				setSpinnerSize = 'm';
				setOffsetValue = 8;
				break;
		}

		const isWorking = state === 'working';
		const isDisabled = disabled || isWorking;

		const content = (
			<div className={`${baseClass}__content`}>
				{isWorking ? (
					<Spinner
						className={`${baseClass}__spinner`}
						color={spinnerColor}
						size={setSpinnerSize}
					/>
				) : (
					<>
						{state === 'stop' ? (
							<StopIcon className={`${baseClass}__icon`} size={setIconSize} />
						) : (
							<Icon
								className={`${baseClass}__icon`}
								iconName='paperPlane'
								size={setIconSize}
							/>
						)}
					</>
				)}
			</div>
		);

		return (
			<button
				ref={ref}
				aria-busy={isWorking || undefined}
				aria-label={ariaLabel}
				className={classes}
				disabled={isDisabled}
				onClick={onClick}
				title={hasTooltip && !isDisabled ? undefined : ariaLabel}
				type={buttonType}
				{...props}
			>
				{hasTooltip && !isDisabled ? (
					<Tooltip
						disableTapTrigger
						className={`${baseClass}__tooltip`}
						content={ariaLabel}
						customTrigger={content}
						offsetValue={setOffsetValue}
					/>
				) : (
					content
				)}
			</button>
		);
	},
);