Data tables

Show many records in rows and columns that people can sort, select and act on.

BetaSince 1.0.0
On this page

Import

import {
  Avatar,
  Badge,
  Button,
  Checkbox,
  Column,
  ConfirmDialog,
  IconButton,
  Link,
  Menu,
  Pagination,
  RelativeTime,
  Row,
  Select,
  Table,
  Text,
  ToastProvider,
  Toolbar,
  Tooltip,
  useToast,
} from '@envsemble/envui';

Usage

Name the Table.Root with aria-label.

  • A sortable Table.HeaderCell takes sortDirection, ascending, descending or none, and onSortChange, which asks for the other order: the application sorts the rows, goes back to the first page and says the order in a Text with role="status".
  • Numeric columns take numeric on the header and the cells.
  • The name is a Link, and recent dates a RelativeTime.
  • Pagination takes page, pageSize, total, onPageChange and an aria-label, and pending while a page loads.
SortAndPaginate.tsx
import { useState } from 'react';
import { Avatar, Link, Pagination, RelativeTime, Column, Row, Table, Text } from '@envsemble/envui';

type SortColumn = 'name' | 'updated' | 'tasks';
type Direction = 'ascending' | 'descending';

const pageSize = 4;

const columnNames: Record<SortColumn, string> = {
  name: 'name',
  updated: 'updated',
  tasks: 'open tasks',
};

export default function SortAndPaginate() {
  const [sort, setSort] = useState<{ column: SortColumn; direction: Direction }>({
    column: 'updated',
    direction: 'descending',
  });
  const [page, setPage] = useState(1);

  // The application sorts the rows, here in memory, and shows one page of them.
  const sorted = [...projects].sort((first, second) => {
    const order =
      sort.column === 'name'
        ? first.name.localeCompare(second.name)
        : sort.column === 'updated'
          ? first.updated.localeCompare(second.updated)
          : first.tasks - second.tasks;
    return sort.direction === 'ascending' ? order : -order;
  });
  const rows = sorted.slice((page - 1) * pageSize, page * pageSize);

  const direction = (column: SortColumn): Table.SortDirection =>
    sort.column === column ? sort.direction : 'none';
  // A new order starts again from the first page.
  const sortBy = (column: SortColumn) => (next: Direction) => {
    setSort({ column, direction: next });
    setPage(1);
  };

  return (
    // As wide as the room it has, with the names whole on one line.
    <Column gap={3} style={{ inlineSize: '100%' }}>
      <Table.Root aria-label="All projects">
        <Table.Head>
          <Table.Row>
            <Table.HeaderCell sortDirection={direction('name')} onSortChange={sortBy('name')}>
              Name
            </Table.HeaderCell>
            <Table.HeaderCell>Owner</Table.HeaderCell>
            <Table.HeaderCell
              numeric
              sortDirection={direction('tasks')}
              onSortChange={sortBy('tasks')}
            >
              Open tasks
            </Table.HeaderCell>
            <Table.HeaderCell sortDirection={direction('updated')} onSortChange={sortBy('updated')}>
              Updated
            </Table.HeaderCell>
          </Table.Row>
        </Table.Head>
        <Table.Body>
          {rows.map((project) => (
            <Table.Row key={project.id} interactive>
              {/* The row's one link opens the project. */}
              <Table.Cell nowrap>
                <Link href={`/projects/${project.id}`}>{project.name}</Link>
              </Table.Cell>
              <Table.Cell nowrap>
                <Row align="center" gap={2}>
                  <Avatar name={project.owner} size="sm" fallback="initials" />
                  {project.owner}
                </Row>
              </Table.Cell>
              {/* Numbers that compare down a column align to the end, in tabular figures. */}
              <Table.Cell numeric>{project.tasks}</Table.Cell>
              <Table.Cell nowrap>
                <RelativeTime value={project.updated} />
              </Table.Cell>
            </Table.Row>
          ))}
        </Table.Body>
      </Table.Root>
      <Row wrap align="center" justify="between" gap={3}>
        {/* The new order is announced politely; the header's arrow alone isn't read out. */}
        <Text role="status" variant="body-sm" emphasis="muted">
          Sorted by {columnNames[sort.column]}, {sort.direction}
        </Text>
        <Pagination
          aria-label="Project pages"
          page={page}
          pageSize={pageSize}
          total={projects.length}
          onPageChange={setPage}
        />
      </Row>
    </Column>
  );
}

/** A time some hours before now, as an ISO date, so the sample reads as recent. */
const ago = (hours: number) => new Date(Date.now() - hours * 3_600_000).toISOString();

const projects = [
  {
    id: 'website-refresh',
    name: 'Website refresh',
    owner: 'Priya Shah',
    tasks: 12,
    updated: ago(2),
  },
  {
    id: 'quarterly-report',
    name: 'Quarterly report',
    owner: 'Sam Lee',
    tasks: 5,
    updated: ago(26),
  },
  {
    id: 'onboarding-guide',
    name: 'Onboarding guide',
    owner: 'Jordan Kim',
    tasks: 8,
    updated: ago(50),
  },
  {
    id: 'mobile-release',
    name: 'Mobile release',
    owner: 'Chen Wei',
    tasks: 21,
    updated: ago(144),
  },
  {
    id: 'data-migration',
    name: 'Data migration',
    owner: 'Maria Garcia',
    tasks: 3,
    updated: ago(240),
  },
  {
    id: 'annual-review',
    name: 'Annual review',
    owner: 'Alex Morgan',
    tasks: 0,
    updated: ago(408),
  },
];

Selection and bulk actions

Each selectable row has a Checkbox labelled with the row's name and selected on its Table.Row; the header's Checkbox is checked="mixed" while some are selected. A visually hidden role="status" says the count.

  • The bulk bar is a Toolbar named for the selection, with the count in its Toolbar.Button labels, "Archive 2 projects".
  • Archive acts at once with Undo in a toast from useToast, and Delete opens a ConfirmDialog.
  • As the bar goes with the selection, focus moves to the header's Checkbox.
<Table.Row selected={selected.includes(project.id)}>
  <Table.Cell>
    <Checkbox
      aria-label={`Select ${project.name}`}
      checked={selected.includes(project.id)}
      onCheckedChange={(checked) => toggle(project.id, checked)}
    />
  </Table.Cell>
  <Table.Cell>
    <Link href={`/projects/${project.id}`}>{project.name}</Link>
  </Table.Cell>
</Table.Row>

Row actions

The row's other actions are a Menu whose trigger is an IconButton with aria-label "More actions for" the row's name. Delete opens a ConfirmDialog whose finalFocus is the menu button that opened it, or, once its row is gone, the next row's.

<Menu.Root
  aria-label={`Actions for ${project.name}`}
  trigger={<IconButton icon={<IconMoreVert />} aria-label={`More actions for ${project.name}`} />}
>
  <Menu.Item onClick={() => setConfirming(true)}>Delete project…</Menu.Item>
</Menu.Root>

Accessibility

You provide

  • A name for every table, the sorting and paging, and sortDirection on the sorted header.
  • A checkbox labelled with the row's name in every selectable row.
  • Polite statuses for the sort order and the selection count, and focus after an action removes its control.

envUI provides

  • A native table in a focusable region named by its aria-label or caption, scrolled with the arrow keys.
  • aria-sort on sortable headers, aria-selected on selected rows and aria-checked="mixed" on the header's checkbox.
  • Pagination in a navigation landmark, whose range is announced.

Changelog

1.0.0

The page is on the figure template; sorting, selecting, loading and stacking rows are on Table.

1.0.0-prerelease.6

Stacked rows keep their sorting in a Select above them.