Navigation

Pagination

Paginador de siete huecos fijos que se compacta cuando no cabe

Preview

Cuando su contenedor se estrecha, la fila pasa a una etiqueta como «Página 6 de 12», cuyo texto escribe la prop formatLabel. El umbral lo pone la talla (392 px en md, 320 en sm) y el cambio de forma es solo CSS. Sin la fila, variant y shape no se notan, porque solo afectan a sus números.

size
variant
shape
align
total

Code

import css from "./Pagination.module.css";
import { useEffect, useRef } from "react";

import { CaretLeftIcon } from "@phosphor-icons/react/dist/csr/CaretLeft";
import { CaretRightIcon } from "@phosphor-icons/react/dist/csr/CaretRight";

type Size = "sm" | "md";
type Align = "start" | "center" | "end";
type Item = number | "start-ellipsis" | "end-ellipsis";

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

interface ResponsiveAlign {
  base: Align;
  mobile?: Align;
  tablet?: Align;
  desktop?: Align;
  wide?: Align;
}

interface PaginationProps {
  size: Size | ResponsiveSize;
  variant: "outline" | "solid" | "soft";
  shape: "rounded" | "circular";
  align: Align | ResponsiveAlign;
  page: number;
  total: number;
  "aria-label": string;
  previousAriaLabel: string;
  nextAriaLabel: string;
  pageAriaLabel: (page: number, current: boolean) => string;
  formatLabel: (page: number, total: number) => string;
  onChange: (page: number) => 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(" ");
}

function getAlignClasses(align: Align | ResponsiveAlign): string {
  if (typeof align === "string") {
    return css[align];
  }

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

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

  return classes.join(" ");
}

// Seven slots whatever the page, so the arrows never move under the pointer.
// An ellipsis always hides two pages or more: hiding one would save no room.
function getItems(page: number, total: number): Item[] {
  if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
  if (page <= 4) return [1, 2, 3, 4, 5, "end-ellipsis", total];
  if (page >= total - 3) {
    return [1, "start-ellipsis", total - 4, total - 3, total - 2, total - 1, total];
  }
  return [1, "start-ellipsis", page - 1, page, page + 1, "end-ellipsis", total];
}

export default function Pagination({
  size,
  variant,
  shape,
  align,
  page,
  total,
  "aria-label": ariaLabel,
  previousAriaLabel,
  nextAriaLabel,
  pageAriaLabel,
  formatLabel,
  onChange,
}: PaginationProps) {
  const previous = useRef<HTMLButtonElement>(null);
  const next = useRef<HTMLButtonElement>(null);
  const pendingFocus = useRef<HTMLButtonElement | null>(null);

  const select = (target: number) => {
    if (target === page) return;
    onChange(target);
  };

  // A disabled button drops focus. The opposite arrow is the one control both
  // layouts share: the compact one hides the page buttons.
  const step = (delta: number) => {
    const target = page + delta;
    if (target === 1) pendingFocus.current = next.current;
    if (target === total) pendingFocus.current = previous.current;
    select(target);
  };

  useEffect(() => {
    pendingFocus.current?.focus();
    pendingFocus.current = null;
  }, [page]);

  return (
    <nav
      className={`${css.pagination} ${getSizeClasses(size)} ${css[variant]} ${css[shape]} ${getAlignClasses(align)}`}
      aria-label={ariaLabel}
    >
      <div className={css.pagination_row}>
        <button
          ref={previous}
          type="button"
          className={css.row_arrow}
          aria-label={previousAriaLabel}
          disabled={page <= 1}
          onClick={() => step(-1)}
        >
          <CaretLeftIcon weight="bold" />
        </button>

        <ul className={css.row_list}>
          {getItems(page, total).map((item) =>
            typeof item === "number" ? (
              <li key={item}>
                <button
                  type="button"
                  className={css.list_page}
                  aria-label={pageAriaLabel(item, item === page)}
                  aria-current={item === page ? "page" : undefined}
                  onClick={() => select(item)}
                >
                  {item}
                </button>
              </li>
            ) : (
              <li key={item} className={css.list_ellipsis} aria-hidden="true">
                …
              </li>
            ),
          )}
        </ul>

        <p className={css.row_label} aria-hidden="true">
          {formatLabel(page, total)}
        </p>

        {/* Focus stays on the arrow after a step, so nothing else would say where it landed. */}
        <p className={css.row_status} aria-live="polite">
          {formatLabel(page, total)}
        </p>

        <button
          ref={next}
          type="button"
          className={css.row_arrow}
          aria-label={nextAriaLabel}
          disabled={page >= total}
          onClick={() => step(1)}
        >
          <CaretRightIcon weight="bold" />
        </button>
      </div>
    </nav>
  );
}

Props

PropTypeRequiredDescription
size"sm" | "md" | ResponsiveSizerequiredLado de cada caja (32/40). Decide también el ancho a partir del cual cabe la fila completa (320/392px de contenedor, con páginas de hasta dos cifras); por debajo pasa a la etiqueta. Fijo o responsive.
variant"outline" | "solid" | "soft"requiredCómo se marca la página actual: contorno, relleno sólido o relleno tenue.
shape"rounded" | "circular"requiredForma de las cajas: esquinas redondeadas o círculo, que se alarga a píldora cuando el número no cabe (tres cifras en sm, cuatro en md).
align"start" | "center" | "end" | ResponsiveAlignrequiredAlinea la fila dentro de su propio hueco, que ocupa entero porque se compacta según ese ancho; no coloca el componente. La etiqueta compacta la sigue. Bajo una tabla de datos va al final, con el rango al otro lado. Fijo o responsive.
totalnumberrequiredNúmero de páginas. Hasta 7 se ven todas; a partir de ahí, siete huecos fijos con puntos suspensivos.
aria-labelstringrequiredNombre del <nav> para lectores de pantalla; distingue esta navegación de las demás de la página.
previousAriaLabelstringrequiredNombre de la flecha anterior para lectores de pantalla; solo dibuja un icono.
nextAriaLabelstringrequiredNombre de la flecha siguiente para lectores de pantalla; solo dibuja un icono.
pageAriaLabel(page: number, current: boolean) => stringrequiredNombre de cada página para lectores de pantalla. Es una función porque el texto lleva el número dentro y la librería no tiene idioma.
formatLabel(page: number, total: number) => stringrequiredTexto de la etiqueta compacta ("Página 3 de 12"). También lo anuncia el lector en cada cambio.
pagenumberrequiredPágina actual (controlada), empezando en 1.
onChange(page: number) => voidrequiredCallback con la página elegida. No salta al pulsar la actual.