Display

Table

Data table with sorting, selection and two mobile modes

Preview

Library components
Sizes
AccordionDisplaysm · md3
BadgeDisplayxs · sm · md4
BreadcrumbNavigationsm · md3
BurgerNavigationsm · md · lg5
ButtonActionsxs · sm · md · lg9
ButtonEmailActionsxs · sm · md6
ButtonLinkActionsxs · sm · md · lg5
ButtonScrollActionsmd · lg3

The mobile control decides what the table does when its slot drops below 480 px. With scroll it slides sideways with the first column pinned. With stack every row becomes a card and a dropdown sets the order. Picking one turns Narrow on, since on a wide stage both look the same. On a touch screen hoverable doesn't show, because there is no pointer.

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" | ResponsiveSizerequiredHeight of each row (40/48) and its side padding (12/16). The text stays at 14 and the selection Checkbox takes the same size. Fixed or responsive.
columns{ key: string; label: string; align: "start" | "end"; sortable?: boolean }[]requiredColumns in order. The first one heads each row. align is required, because numbers go to the end and text to the start.
rows{ id: string; [key: string]: string | number }[]requiredRows already filtered, sorted and paged by the caller. Each cell paints row[column.key] as is; id identifies the row in the selection.
aria-labelstringrequiredName of the table and of its scroll region for screen readers.
mobile"scroll" | "stack"requiredWhat it does when narrow. scroll slides sideways with the first column pinned; stack turns each row into a card below a 480 px container, without the header.
framedboolean—Card contour around the table.
hoverableboolean—Tints the row under the pointer. The selected one does not change.
stripedboolean—Tints every other row.
sorting{ key: string; direction: "ascending" | "descending"; icon: "hover" | "always"; announcement: (label, direction) => string; onChange: (key, direction) => void }—Turns sorting on whole or not at all: state, icon, the text that announces each change and the callback. Without it, headers are plain text.
selection{ selected: string[]; allAriaLabel: string; rowAriaLabel: (row) => string; onChange: (selected) => void }—Turns the checkbox column on whole or not at all: the checked ids, the names for screen readers and the callback. "All" means the rows received; ids from other pages are kept. With any checked, the header box clears them.