Actions

Button

Botón de acción con variantes y tamaños

Preview

size
variant
children
loading

Code

import css from "./Button.module.css";

import { SpinnerIcon } from "@phosphor-icons/react/dist/csr/Spinner";

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

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

interface ButtonProps {
  size: Size | ResponsiveSize;
  variant: "solid" | "soft" | "outline" | "ghost";
  loading?: boolean;
  type?: "button" | "submit" | "reset";
  "aria-expanded"?: boolean;
  "aria-controls"?: string;
  children: React.ReactNode;
  ref?: React.Ref<HTMLButtonElement>;
  onClick?: (ev: React.MouseEvent<HTMLButtonElement>) => void;
}

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 Button({
  size,
  variant,
  loading,
  type,
  "aria-expanded": ariaExpanded,
  "aria-controls": ariaControls,
  children,
  ref,
  onClick,
}: ButtonProps) {
  // Enter and Space still click through pointer-events: none, and a submit would go twice.
  const handleClick = (ev: React.MouseEvent<HTMLButtonElement>) => {
    if (loading) {
      ev.preventDefault();
      return;
    }
    onClick?.(ev);
  };

  // aria-disabled, not disabled: the native one drops the focus mid-load.
  return (
    <button
      ref={ref}
      type={type}
      className={`
        ${css.button}
        ${getSizeClasses(size)}
        ${css[variant]}
        ${loading ? css.loading : ""}
      `}
      aria-busy={loading || undefined}
      aria-disabled={loading || undefined}
      aria-expanded={ariaExpanded}
      aria-controls={ariaControls}
      onClick={handleClick}
    >
      {loading && (
        <SpinnerIcon
          className={css.button_spinner}
          weight="bold"
          aria-hidden="true"
        />
      )}
      <span className={css.button_content}>{children}</span>
    </button>
  );
}

Props

PropTypeRequiredDescription
size"xs" | "sm" | "md" | "lg" | ResponsiveSizerequiredTamaño del botón (altura, padding y tipografía). Fijo o responsive.
variant"solid" | "soft" | "outline" | "ghost"requiredDe más a menos peso. solid va relleno, soft lleva un fondo suave de acento, outline es un contorno que se rellena al pasar el ratón y ghost va sin borde ni fondo y se tiñe con el hover.
loadingboolean—En espera: spinner delante del contenido y clic bloqueado, sin soltar el foco.
type"button" | "submit" | "reset"—Tipo nativo del botón. Sin él decide el navegador, que dentro de un formulario lo trata como submit.
aria-expandedboolean—Si el panel que controla está abierto. Popover lo inyecta cuando Button es su disparador.
aria-controlsstring—Id del panel que el botón abre y cierra. Popover lo inyecta cuando Button es su disparador.
childrenReact.ReactNoderequiredContenido del botón (texto, o icono + texto).
refReact.Ref<HTMLButtonElement>—Ref al <button> nativo; permite a Button actuar como disparador de Popover.
onClick(ev: React.MouseEvent<HTMLButtonElement>) => void—Callback al hacer clic; en espera no se llama.