Actions

ButtonEmail

Copies the email to the clipboard with feedback

Preview

size
email
copiedText
children

Code

import css from "./ButtonEmail.module.css";
import { useRef, useState } from "react";

import type { Icon } from "@phosphor-icons/react";

type Size = "xs" | "sm" | "md";

interface ResponsiveSize {
  base: Size;
  mobile?: Size;
  tablet?: Size;
  desktop?: Size;
  wide?: Size;
}

interface ButtonEmailProps {
  size: Size | ResponsiveSize;
  icons: { copy: Icon; loading: Icon };
  email: string;
  copiedText: string;
  "aria-label"?: string;
  children: React.ReactNode;
}

// MUST match the CSS spin (1s): the icon has to finish its turn.
const SPIN_MS = 1000;
const COPIED_MS = 2000;

const sleep = (ms: number) =>
  new Promise((resolve) => {
    setTimeout(resolve, ms);
  });

function getSizeClasses(size: Size | ResponsiveSize): string {
  if (typeof size === "string") {
    return css[size];
  }

  const classes = [css[`${size.base}_base`]];

  if (size.mobile) classes.push(css[`${size.mobile}_mobile`]);
  if (size.tablet) classes.push(css[`${size.tablet}_tablet`]);
  if (size.desktop) classes.push(css[`${size.desktop}_desktop`]);
  if (size.wide) classes.push(css[`${size.wide}_wide`]);

  return classes.join(" ");
}

export default function ButtonEmail({
  size,
  icons,
  email,
  copiedText,
  "aria-label": ariaLabel,
  children,
}: ButtonEmailProps) {
  const CopyIcon = icons.copy;
  const LoadingIcon = icons.loading;
  const accessibleName = ariaLabel ? `${ariaLabel}: ${email}` : undefined;

  const button = useRef<HTMLButtonElement>(null);
  const [width, setWidth] = useState<number>();
  const [loading, setLoading] = useState(false);
  const [active, setActive] = useState(false);
  const [disabled, setDisabled] = useState(false);

  const onClick = async () => {
    if (disabled) return;

    setDisabled(true);
    setWidth(button.current?.clientWidth);

    // finally: a rejected clipboard write must not leave the button disabled
    try {
      await navigator.clipboard.writeText(email);
      setLoading(true);

      await sleep(SPIN_MS);
      setLoading(false);
      setActive(true);

      await sleep(COPIED_MS);
      setActive(false);
    } finally {
      setDisabled(false);
      setWidth(undefined);
    }
  };

  return (
    <button
      ref={button}
      type="button"
      className={`${css.button} ${getSizeClasses(size)}`}
      style={{ width }}
      aria-disabled={disabled || undefined}
      aria-label={accessibleName}
      onClick={onClick}
    >
      {active ? <span aria-hidden="true">{copiedText}</span> : children}
      <span className={css.button_status} role="status">
        {active ? copiedText : ""}
      </span>
      {loading && (
        <div
          className={`
            ${css.button_icon}
            ${css.loading}
          `}
        >
          <LoadingIcon aria-hidden="true" />
        </div>
      )}
      {!loading && !active && (
        <div className={css.button_icon}>
          <CopyIcon aria-hidden="true" />
        </div>
      )}
    </button>
  );
}

Props

PropTypeRequiredDescription
size"xs" | "sm" | "md" | ResponsiveSizerequiredButton size (font-size; gap and padding scale in em). Fixed or responsive.
icons{ copy: Icon; loading: Icon }requiredIdle and loading icons. The component imports neither.
emailstringrequiredAddress copied to the clipboard.
copiedTextstringrequiredConfirmation text that replaces the content after copying.
aria-labelstring—Action placed before the email in the accessible name (e.g. "Copy the email to the clipboard: hello@…"). Only when the visible text is the email.
childrenReact.ReactNoderequiredContent shown when idle. If it is not the email itself, leave out aria-label.