Display

Table

Tabla de datos con orden, selección y dos modos de móvil

Preview

Componentes de la librería
Tallas
AccordionDisplaysm · md3
BadgeDisplayxs · sm · md4
BreadcrumbNavigationsm · md3
BurgerNavigationsm · md · lg5
ButtonActionsxs · sm · md · lg9
ButtonEmailActionsxs · sm · md6
ButtonLinkActionsxs · sm · md · lg5
ButtonScrollActionsmd · lg3

El control mobile decide qué hace la tabla cuando su hueco baja de 480 px. Con scroll se desliza de lado con la primera columna fija. Con stack cada fila pasa a ser una tarjeta y el orden se elige en un desplegable. Al elegir uno se enciende Estrechar, porque en un escenario ancho los dos se ven igual. En una pantalla táctil hoverable no se nota, porque no hay puntero.

size
mobile
sorting.icon
framed
hoverable
striped
selection

Code

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

import { CaretDownIcon } from "@phosphor-icons/react/dist/csr/CaretDown";
import { CaretUpIcon } from "@phosphor-icons/react/dist/csr/CaretUp";
import { CaretUpDownIcon } from "@phosphor-icons/react/dist/csr/CaretUpDown";

import Checkbox from "@/components/library/react/Checkbox/Checkbox";

type Size = "sm" | "md";
type Direction = "ascending" | "descending";

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

interface TableColumn {
  key: string;
  label: string;
  // Required: numbers align to the end, text to the start; a column has to say which.
  align: "start" | "end";
  sortable?: boolean;
}

interface TableRow {
  id: string;
  [key: string]: string | number;
}

// Capabilities travel whole: a sorting without its announcement cannot be written.
interface TableSorting {
  key: string;
  direction: Direction;
  icon: "hover" | "always";
  announcement: (label: string, direction: Direction) => string;
  onChange: (key: string, direction: Direction) => void;
}

interface TableSelection {
  selected: string[];
  allAriaLabel: string;
  rowAriaLabel: (row: TableRow) => string;
  onChange: (selected: string[]) => void;
}

interface TableProps {
  size: Size | ResponsiveSize;
  columns: TableColumn[];
  rows: TableRow[];
  // Names the table (visually hidden caption) and its scroll region.
  "aria-label": string;
  mobile: "scroll" | "stack";
  framed?: boolean;
  hoverable?: boolean;
  striped?: boolean;
  sorting?: TableSorting;
  selection?: TableSelection;
}

const DIRECTION_ICONS = { ascending: CaretUpIcon, descending: CaretDownIcon };

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 Table({
  size,
  columns,
  rows,
  "aria-label": ariaLabel,
  mobile,
  framed,
  hoverable,
  striped,
  sorting,
  selection,
}: TableProps) {
  const [announcement, setAnnouncement] = useState("");
  const [scrollable, setScrollable] = useState(true);
  const region = useRef<HTMLDivElement>(null);
  const mounted = useRef(false);

  // "All" needs rows: every() over an empty list is true and would tick the box.
  const picked = rows.filter((row) => selection?.selected.includes(row.id)).length;
  const all = rows.length > 0 && picked === rows.length;
  const some = picked > 0 && !all;

  const sort = (column: TableColumn) => {
    if (!sorting) return;
    const flip = sorting.key === column.key && sorting.direction === "ascending";
    sorting.onChange(column.key, flip ? "descending" : "ascending");
  };

  // Ids from other pages stay put: the table only knows the rows it was given.
  const toggleRow = (id: string) => {
    if (!selection) return;
    const { selected } = selection;

    selection.onChange(
      selected.includes(id) ? selected.filter((current) => current !== id) : [...selected, id],
    );
  };

  const toggleAll = () => {
    if (!selection) return;
    const { selected } = selection;
    const ids = rows.map((row) => row.id);

    selection.onChange(
      picked > 0
        ? selected.filter((id) => !ids.includes(id))
        : [...selected, ...ids],
    );
  };

  // The fields, never the object: the caller rebuilds it on every render.
  // A live region born with text is never read, so the first run stays silent.
  useEffect(() => {
    if (!mounted.current) {
      mounted.current = true;
      return;
    }

    if (!sorting) return;
    const sorted = columns.find((column) => column.key === sorting.key);
    if (sorted) setAnnouncement(sorting.announcement(sorted.label, sorting.direction));
  }, [sorting?.key, sorting?.direction]);

  useEffect(() => {
    const box = region.current;
    if (!box) return;

    const observer = new ResizeObserver(() => {
      setScrollable(box.scrollWidth > box.clientWidth);
    });
    [box, ...box.children].forEach((element) => observer.observe(element));

    return () => observer.disconnect();
  }, []);

  return (
    <div
      className={`
        ${css.table}
        ${getSizeClasses(size)}
        ${css[mobile]}
        ${framed ? css.framed : ""}
        ${hoverable ? css.hoverable : ""}
        ${striped ? css.striped : ""}
        ${selection ? css.selectable : ""}
        ${sorting?.icon === "hover" ? css.icon_hover : ""}
      `}
    >
      <div
        ref={region}
        className={css.table_region}
        role="region"
        aria-label={ariaLabel}
        tabIndex={scrollable ? 0 : undefined}
      >
        <table className={css.region_table} role="table">
          <caption className={css.table_caption}>{ariaLabel}</caption>
          <thead className={css.table_head} role="rowgroup">
            <tr role="row">
              {selection && (
                <th
                  className={`${css.head_cell} ${css.select}`}
                  role="columnheader"
                  scope="col"
                >
                  <Checkbox
                    size={size}
                    aria-label={selection.allAriaLabel}
                    indeterminate={some}
                    checked={all}
                    onChange={toggleAll}
                  />
                </th>
              )}
              {columns.map((column, index) => {
                const direction = sorting?.key === column.key ? sorting.direction : undefined;
                const SortIcon = direction ? DIRECTION_ICONS[direction] : CaretUpDownIcon;

                return (
                  <th
                    key={column.key}
                    className={`
                      ${css.head_cell}
                      ${index === 0 ? css.first : ""}
                      ${column.align === "end" ? css.end : ""}
                    `}
                    role="columnheader"
                    scope="col"
                    aria-sort={direction}
                  >
                    {sorting && column.sortable ? (
                      <button
                        type="button"
                        className={css.cell_sort}
                        onClick={() => sort(column)}
                      >
                        {column.label}
                        <SortIcon
                          className={`${css.sort_icon} ${direction ? "" : css.idle}`}
                          aria-hidden="true"
                        />
                      </button>
                    ) : (
                      column.label
                    )}
                  </th>
                );
              })}
            </tr>
          </thead>
          <tbody className={css.table_body} role="rowgroup">
            {rows.map((row) => {
              const selected = selection?.selected.includes(row.id) ?? false;

              return (
                <tr
                  key={row.id}
                  className={`${css.body_row} ${selected ? css.selected : ""}`}
                  role="row"
                >
                  {selection && (
                    <td className={`${css.row_cell} ${css.select}`} role="cell">
                      <Checkbox
                        size={size}
                        aria-label={selection.rowAriaLabel(row)}
                        checked={selected}
                        onChange={() => toggleRow(row.id)}
                      />
                    </td>
                  )}
                  {columns.map((column, index) =>
                    index === 0 ? (
                      <th
                        key={column.key}
                        className={`
                          ${css.row_cell}
                          ${css.first}
                          ${column.align === "end" ? css.end : ""}
                        `}
                        role="rowheader"
                        scope="row"
                        data-label={column.label}
                      >
                        {row[column.key]}
                      </th>
                    ) : (
                      <td
                        key={column.key}
                        className={`${css.row_cell} ${column.align === "end" ? css.end : ""}`}
                        role="cell"
                        data-label={column.label}
                      >
                        {row[column.key]}
                      </td>
                    ),
                  )}
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>
      <p className={css.table_status} aria-live="polite">
        {announcement}
      </p>
    </div>
  );
}

Props

PropTypeRequiredDescription
size"sm" | "md" | ResponsiveSizerequiredAltura de cada fila (40/48) y su padding lateral (12/16). El texto se queda en 14 y el Checkbox de la selección toma la misma talla. Fijo o responsive.
columns{ key: string; label: string; align: "start" | "end"; sortable?: boolean }[]requiredColumnas en orden. La primera es la cabecera de cada fila. align es obligatorio, porque las cifras van al final y el texto al principio.
rows{ id: string; [key: string]: string | number }[]requiredFilas ya filtradas, ordenadas y paginadas por quien usa la tabla. Cada celda pinta row[column.key] tal cual; id identifica la fila en la selección.
aria-labelstringrequiredNombre de la tabla y de su región de scroll para lectores de pantalla.
mobile"scroll" | "stack"requiredQué hace en poco ancho. scroll desplaza en horizontal con la primera columna fija; stack convierte cada fila en una tarjeta por debajo de 480 px de su contenedor, sin cabecera.
framedboolean—Contorno de tarjeta alrededor de la tabla.
hoverableboolean—Tiñe la fila bajo el puntero. La seleccionada no cambia.
stripedboolean—Tiñe una fila de cada dos.
sorting{ key: string; direction: "ascending" | "descending"; icon: "hover" | "always"; announcement: (label, direction) => string; onChange: (key, direction) => void }—Activa la ordenación entera o nada: estado, icono, el texto que anuncia cada cambio al lector y el callback. Sin él, las cabeceras son texto.
selection{ selected: string[]; allAriaLabel: string; rowAriaLabel: (row) => string; onChange: (selected) => void }—Activa la columna de checkbox entera o nada: los ids marcados, los nombres para el lector y el callback. "Todos" son las filas recibidas; los ids de otras páginas se conservan. Con alguna marcada, el de la cabecera las desmarca.