Monet
@etched/monet 0.0.1

Table

Ported

A data table. Inside it, use the native parts: caption, thead, tbody, tfoot, tr, th and td. Monet styles them. Columns have fixed widths: set them on the header cells or in a colgroup. A cell with only a Checkbox (with an aria-label) is a check cell. A checked Checkbox anywhere in a row marks the row as selected. Mark the row whose details are open with aria-current="true". Put data-align="end" on the heading and the cells of a column of numbers. A caption names the table; it shows under the rows, in the subtle ink. A row can open child rows: TableChildRows and TableExpandButton.

Kumo source
NameStage
Ana RuizInterviewing
José NúñezScheduled
Lee ParkHired
import { Pill, type PillTone } from "@etched/monet/pill";
import { Table } from "@etched/monet/table";

interface Candidate {
  id: string;
  name: string;
  role: string;
  stage: [string, PillTone];
  owner: string;
  calls: number;
  updated: string;
}

const candidates: Candidate[] = [
  { id: "ana", name: "Ana Ruiz", role: "ASIC design engineer", stage: ["Interviewing", "cyan"], owner: "Sam Lee", calls: 4, updated: "Oct 14" },
  { id: "jose", name: "José Núñez", role: "Firmware engineer", stage: ["Scheduled", "purple"], owner: "Kai Ito", calls: 12, updated: "Oct 13" },
  { id: "lee", name: "Lee Park", role: "Thermal engineer", stage: ["Hired", "green"], owner: "Sam Lee", calls: 11, updated: "Oct 9" },
  { id: "mira", name: "Mira Osei", role: "Physical design lead for the next tape-out, a title long enough to wrap", stage: ["We Passed", "red"], owner: "Ana Ruiz", calls: 1, updated: "Sep 30" },
];

export function Preview() {
  return (
    <div className="card">
      <Table aria-label="Candidates">
        <thead>
          <tr>
            <th>Name</th>
            <th>Stage</th>
          </tr>
        </thead>
        <tbody>
          {candidates.slice(0, 3).map((person) => (
            <tr key={person.id}>
              <th scope="row">{person.name}</th>
              <td>
                <Pill tone={person.stage[1]}>{person.stage[0]}</Pill>
              </td>
            </tr>
          ))}
        </tbody>
      </Table>
    </div>
  );
}

Installation

import { Table, TableChildRows, TableExpandButton, TableResizeHandle, TableRow, TableSortHead } from "@etched/monet/table";

Examples

Check cells, row headings, sorting and pagination

The heading box selects every row, and a checked row shows as selected. Calls, a number, aligns to the end (data-align="end" on its heading and cells); its sort arrow goes before the text.

RoleStageOwnerUpdated
Ana RuizASIC design engineerInterviewingSam Lee4Oct 14
José NúñezFirmware engineerScheduledKai Ito12Oct 13
Lee ParkThermal engineerHiredSam Lee11Oct 9
Mira OseiPhysical design lead for the next tape-out, a title long enough to wrapWe PassedAna Ruiz1Sep 30
1–4 of 37
import { useState } from "react";
import { Checkbox } from "@etched/monet/checkbox";
import { Pagination } from "@etched/monet/pagination";
import { Pill, type PillTone } from "@etched/monet/pill";
import { Table, TableSortHead, type TableSort } from "@etched/monet/table";

interface Candidate {
  id: string;
  name: string;
  role: string;
  stage: [string, PillTone];
  owner: string;
  calls: number;
  updated: string;
}

const candidates: Candidate[] = [
  { id: "ana", name: "Ana Ruiz", role: "ASIC design engineer", stage: ["Interviewing", "cyan"], owner: "Sam Lee", calls: 4, updated: "Oct 14" },
  { id: "jose", name: "José Núñez", role: "Firmware engineer", stage: ["Scheduled", "purple"], owner: "Kai Ito", calls: 12, updated: "Oct 13" },
  { id: "lee", name: "Lee Park", role: "Thermal engineer", stage: ["Hired", "green"], owner: "Sam Lee", calls: 11, updated: "Oct 9" },
  { id: "mira", name: "Mira Osei", role: "Physical design lead for the next tape-out, a title long enough to wrap", stage: ["We Passed", "red"], owner: "Ana Ruiz", calls: 1, updated: "Sep 30" },
];

interface Sort {
  column: "name" | "calls";
  order: TableSort;
}

const sorted = (rows: Candidate[], { column, order }: Sort) =>
  order === "none"
    ? rows
    : [...rows].sort((a, b) => (order === "ascending" ? 1 : -1) * (column === "name" ? a.name.localeCompare(b.name) : a.calls - b.calls));

/** The column headings. Name and Calls sort; `alwaysSorted` keeps one of them sorted at all times. Calls, a number, aligns to the end. */
function Head({ sort, setSort, alwaysSorted }: { sort: Sort; setSort: (sort: Sort) => void; alwaysSorted?: boolean }) {
  const sortable = (column: Sort["column"], label: string, inlineSize: string) => (
    <TableSortHead
      style={{ inlineSize }}
      data-align={column === "calls" ? "end" : undefined}
      sort={sort.column === column ? sort.order : "none"}
      alwaysSorted={alwaysSorted}
      onSortChange={(order) => setSort({ column, order })}
    >
      {label}
    </TableSortHead>
  );

  return (
    <>
      {sortable("name", "Name", "18%")}
      <th>Role</th>
      <th style={{ inlineSize: "12%" }}>Stage</th>
      <th style={{ inlineSize: "11%" }}>Owner</th>
      {sortable("calls", "Calls", "7%")}
      <th style={{ inlineSize: "8%" }}>Updated</th>
    </>
  );
}

function Cells({ person }: { person: Candidate }) {
  return (
    <>
      <th scope="row">{person.name}</th>
      <td>{person.role}</td>
      <td>
        <Pill tone={person.stage[1]}>{person.stage[0]}</Pill>
      </td>
      <td>{person.owner}</td>
      <td data-align="end">{person.calls}</td>
      <td>{person.updated}</td>
    </>
  );
}

export function CheckCellsRowHeadingsSortingAndPagination() {
  const [picked, setPicked] = useState(() => new Set(["jose"]));
  const [sort, setSort] = useState<Sort>({ column: "name", order: "none" });
  const [page, setPage] = useState(1);

  const pick = (id: string, on: boolean) =>
    setPicked((current) => {
      const next = new Set(current);

      if (on) next.add(id);
      else next.delete(id);

      return next;
    });

  return (
    <>
      <div className="card">
        <Table aria-label="Candidates">
          <thead>
            <tr>
              <th>
                <Checkbox
                  aria-label="Select all candidates"
                  checked={picked.size === candidates.length}
                  indeterminate={picked.size > 0 && picked.size < candidates.length}
                  onCheckedChange={(on) => setPicked(new Set(on ? candidates.map((c) => c.id) : []))}
                />
              </th>
              <Head sort={sort} setSort={setSort} />
            </tr>
          </thead>
          <tbody>
            {sorted(candidates, sort).map((person) => (
              <tr key={person.id}>
                <td>
                  <Checkbox aria-label={`Select ${person.name}`} checked={picked.has(person.id)} onCheckedChange={(on) => pick(person.id, on)} />
                </td>
                <Cells person={person} />
              </tr>
            ))}
          </tbody>
        </Table>
      </div>
      <Pagination aria-label="Candidate pages" page={page} onPageChange={setPage} perPage={4} totalCount={37} />
    </>
  );
}

Dense, always sorted, with the open row marked

Like Orbit's leaderboard: alwaysSorted keeps one column sorted, and aria-current marks the open row.

RoleStageOwnerUpdated
José NúñezFirmware engineerScheduledKai Ito12Oct 13
Lee ParkThermal engineerHiredSam Lee11Oct 9
Ana RuizASIC design engineerInterviewingSam Lee4Oct 14
Mira OseiPhysical design lead for the next tape-out, a title long enough to wrapWe PassedAna Ruiz1Sep 30
import { useState } from "react";
import { Pill, type PillTone } from "@etched/monet/pill";
import { Table, TableSortHead, type TableSort } from "@etched/monet/table";

interface Candidate {
  id: string;
  name: string;
  role: string;
  stage: [string, PillTone];
  owner: string;
  calls: number;
  updated: string;
}

const candidates: Candidate[] = [
  { id: "ana", name: "Ana Ruiz", role: "ASIC design engineer", stage: ["Interviewing", "cyan"], owner: "Sam Lee", calls: 4, updated: "Oct 14" },
  { id: "jose", name: "José Núñez", role: "Firmware engineer", stage: ["Scheduled", "purple"], owner: "Kai Ito", calls: 12, updated: "Oct 13" },
  { id: "lee", name: "Lee Park", role: "Thermal engineer", stage: ["Hired", "green"], owner: "Sam Lee", calls: 11, updated: "Oct 9" },
  { id: "mira", name: "Mira Osei", role: "Physical design lead for the next tape-out, a title long enough to wrap", stage: ["We Passed", "red"], owner: "Ana Ruiz", calls: 1, updated: "Sep 30" },
];

interface Sort {
  column: "name" | "calls";
  order: TableSort;
}

const sorted = (rows: Candidate[], { column, order }: Sort) =>
  order === "none"
    ? rows
    : [...rows].sort((a, b) => (order === "ascending" ? 1 : -1) * (column === "name" ? a.name.localeCompare(b.name) : a.calls - b.calls));

/** The column headings. Name and Calls sort; `alwaysSorted` keeps one of them sorted at all times. Calls, a number, aligns to the end. */
function Head({ sort, setSort, alwaysSorted }: { sort: Sort; setSort: (sort: Sort) => void; alwaysSorted?: boolean }) {
  const sortable = (column: Sort["column"], label: string, inlineSize: string) => (
    <TableSortHead
      style={{ inlineSize }}
      data-align={column === "calls" ? "end" : undefined}
      sort={sort.column === column ? sort.order : "none"}
      alwaysSorted={alwaysSorted}
      onSortChange={(order) => setSort({ column, order })}
    >
      {label}
    </TableSortHead>
  );

  return (
    <>
      {sortable("name", "Name", "18%")}
      <th>Role</th>
      <th style={{ inlineSize: "12%" }}>Stage</th>
      <th style={{ inlineSize: "11%" }}>Owner</th>
      {sortable("calls", "Calls", "7%")}
      <th style={{ inlineSize: "8%" }}>Updated</th>
    </>
  );
}

function Cells({ person }: { person: Candidate }) {
  return (
    <>
      <th scope="row">{person.name}</th>
      <td>{person.role}</td>
      <td>
        <Pill tone={person.stage[1]}>{person.stage[0]}</Pill>
      </td>
      <td>{person.owner}</td>
      <td data-align="end">{person.calls}</td>
      <td>{person.updated}</td>
    </>
  );
}

export function DenseAlwaysSortedWithTheOpenRowMarked() {
  const [sort, setSort] = useState<Sort>({ column: "calls", order: "descending" });

  return (
    <div className="card">
      <Table aria-label="Recent calls" density="dense">
        <thead>
          <tr>
            <Head sort={sort} setSort={setSort} alwaysSorted />
          </tr>
        </thead>
        <tbody>
          {sorted(candidates, sort).map((person) => (
            <tr key={person.id} aria-current={person.id === "lee" || undefined}>
              <Cells person={person} />
            </tr>
          ))}
        </tbody>
      </Table>
    </div>
  );
}

Compact headings

compactHeader, as in Orbit's trace table: 12 px headings on the raised fill. Point at a heading, or tab to it, to see an unsorted column's arrow and an unused filter; the sort and a filter that is on stay in view.

Role
Stage
OwnerUpdated
Ana RuizASIC design engineerInterviewingSam Lee4Oct 14
José NúñezFirmware engineerScheduledKai Ito12Oct 13
Lee ParkThermal engineerHiredSam Lee11Oct 9
Mira OseiPhysical design lead for the next tape-out, a title long enough to wrapWe PassedAna Ruiz1Sep 30
import { useState } from "react";
import { Combobox, type ComboboxItemData } from "@etched/monet/combobox";
import { Pill, type PillTone } from "@etched/monet/pill";
import { Table, TableSortHead, type TableSort } from "@etched/monet/table";

interface Candidate {
  id: string;
  name: string;
  role: string;
  stage: [string, PillTone];
  owner: string;
  calls: number;
  updated: string;
}

const candidates: Candidate[] = [
  { id: "ana", name: "Ana Ruiz", role: "ASIC design engineer", stage: ["Interviewing", "cyan"], owner: "Sam Lee", calls: 4, updated: "Oct 14" },
  { id: "jose", name: "José Núñez", role: "Firmware engineer", stage: ["Scheduled", "purple"], owner: "Kai Ito", calls: 12, updated: "Oct 13" },
  { id: "lee", name: "Lee Park", role: "Thermal engineer", stage: ["Hired", "green"], owner: "Sam Lee", calls: 11, updated: "Oct 9" },
  { id: "mira", name: "Mira Osei", role: "Physical design lead for the next tape-out, a title long enough to wrap", stage: ["We Passed", "red"], owner: "Ana Ruiz", calls: 1, updated: "Sep 30" },
];

interface Sort {
  column: "name" | "calls";
  order: TableSort;
}

const sorted = (rows: Candidate[], { column, order }: Sort) =>
  order === "none"
    ? rows
    : [...rows].sort((a, b) => (order === "ascending" ? 1 : -1) * (column === "name" ? a.name.localeCompare(b.name) : a.calls - b.calls));

function Cells({ person }: { person: Candidate }) {
  return (
    <>
      <th scope="row">{person.name}</th>
      <td>{person.role}</td>
      <td>
        <Pill tone={person.stage[1]}>{person.stage[0]}</Pill>
      </td>
      <td>{person.owner}</td>
      <td data-align="end">{person.calls}</td>
      <td>{person.updated}</td>
    </>
  );
}

const stages: ComboboxItemData[] = [...new Set(candidates.map((person) => person.stage[0]))].map((stage) => ({ value: stage, label: stage }));

export function CompactHeadings() {
  const [sort, setSort] = useState<Sort>({ column: "name", order: "ascending" });
  const [shown, setShown] = useState<string[]>([]);
  const order = (column: Sort["column"]) => (sort.column === column ? sort.order : "none");

  return (
    <div className="card">
      <Table aria-label="Candidates by stage" density="dense" compactHeader>
        <thead>
          <tr>
            <TableSortHead style={{ inlineSize: "18%" }} sort={order("name")} onSortChange={(next) => setSort({ column: "name", order: next })}>
              Name
            </TableSortHead>
            <th>Role</th>
            <th style={{ inlineSize: "14%" }}>
              <div style={{ display: "flex", alignItems: "center", gap: "var(--et-space-2)" }}>
                Stage
                <Combobox multiple appearance="filter" aria-label="Filter by stage" items={stages} value={shown} onValueChange={setShown} />
              </div>
            </th>
            <th style={{ inlineSize: "11%" }}>Owner</th>
            <TableSortHead style={{ inlineSize: "9%" }} data-align="end" sort={order("calls")} onSortChange={(next) => setSort({ column: "calls", order: next })}>
              Calls
            </TableSortHead>
            <th style={{ inlineSize: "8%" }}>Updated</th>
          </tr>
        </thead>
        <tbody>
          {sorted(candidates, sort)
            .filter((person) => !shown.length || shown.includes(person.stage[0]))
            .map((person) => (
              <tr key={person.id}>
                <Cells person={person} />
              </tr>
            ))}
        </tbody>
      </Table>
    </div>
  );
}

Group rows

A heading that spans the row starts each group, as in Orbit's role table.

NameRoleOwner
Silicon
Ana RuizASIC design engineerSam Lee
José NúñezFirmware engineerKai Ito
Systems
Lee ParkThermal engineerSam Lee
Mira OseiPhysical design lead for the next tape-out, a title long enough to wrapAna Ruiz
import { type PillTone } from "@etched/monet/pill";
import { Table } from "@etched/monet/table";

interface Candidate {
  id: string;
  name: string;
  role: string;
  stage: [string, PillTone];
  owner: string;
  calls: number;
  updated: string;
}

const candidates: Candidate[] = [
  { id: "ana", name: "Ana Ruiz", role: "ASIC design engineer", stage: ["Interviewing", "cyan"], owner: "Sam Lee", calls: 4, updated: "Oct 14" },
  { id: "jose", name: "José Núñez", role: "Firmware engineer", stage: ["Scheduled", "purple"], owner: "Kai Ito", calls: 12, updated: "Oct 13" },
  { id: "lee", name: "Lee Park", role: "Thermal engineer", stage: ["Hired", "green"], owner: "Sam Lee", calls: 11, updated: "Oct 9" },
  { id: "mira", name: "Mira Osei", role: "Physical design lead for the next tape-out, a title long enough to wrap", stage: ["We Passed", "red"], owner: "Ana Ruiz", calls: 1, updated: "Sep 30" },
];

const teams: [string, Candidate[]][] = [
  ["Silicon", candidates.slice(0, 2)],
  ["Systems", candidates.slice(2)],
];

export function GroupRows() {
  return (
    <div className="card">
      <Table aria-label="Candidates by team">
        <thead>
          <tr>
            <th style={{ inlineSize: "30%" }}>Name</th>
            <th>Role</th>
            <th style={{ inlineSize: "20%" }}>Owner</th>
          </tr>
        </thead>
        {teams.map(([team, people]) => (
          <tbody key={team}>
            <tr>
              <th scope="rowgroup" colSpan={3}>
                {team}
              </th>
            </tr>
            {people.map((person) => (
              <tr key={person.id}>
                <th scope="row">{person.name}</th>
                <td>{person.role}</td>
                <td>{person.owner}</td>
              </tr>
            ))}
          </tbody>
        ))}
      </Table>
    </div>
  );
}

Child rows

TableChildRows puts a parent row before the child rows it opens: here a candidate and their applications. A TableExpandButton goes first in the parent's first cell, named for what it opens. Every row's first cell leaves room for the button, so the names line up, and child rows are one step in.

CandidateRoleStageRecruiterUpdated
Ana Ruiz2 applicationsOct 14
Application 1ASIC design engineerInterviewingSam LeeOct 14
Application 2Verification engineerWe PassedKai ItoSep 2
José NúñezFirmware engineerScheduledKai ItoOct 13
Lee Park3 applicationsOct 9
import { Pill, type PillTone } from "@etched/monet/pill";
import { Text } from "@etched/monet/text";
import { Table, TableChildRows, TableExpandButton } from "@etched/monet/table";

interface Application {
  id: string;
  role: string;
  stage: [string, PillTone];
  recruiter: string;
  updated: string;
}

// Orbit's referrals: a candidate with more than one application opens a row for each.
const referred: { id: string; name: string; applications: [Application, ...Application[]] }[] = [
  {
    id: "ana",
    name: "Ana Ruiz",
    applications: [
      { id: "ana-asic", role: "ASIC design engineer", stage: ["Interviewing", "cyan"], recruiter: "Sam Lee", updated: "Oct 14" },
      { id: "ana-dv", role: "Verification engineer", stage: ["We Passed", "red"], recruiter: "Kai Ito", updated: "Sep 2" },
    ],
  },
  { id: "jose", name: "José Núñez", applications: [{ id: "jose-fw", role: "Firmware engineer", stage: ["Scheduled", "purple"], recruiter: "Kai Ito", updated: "Oct 13" }] },
  {
    id: "lee",
    name: "Lee Park",
    applications: [
      { id: "lee-thermal", role: "Thermal engineer", stage: ["Hired", "green"], recruiter: "Sam Lee", updated: "Oct 9" },
      { id: "lee-pkg", role: "Packaging engineer", stage: ["Revisit", "olive"], recruiter: "Ana Ruiz", updated: "Aug 21" },
      { id: "lee-power", role: "Power engineer", stage: ["No Response", "amber"], recruiter: "Kai Ito", updated: "Jul 30" },
    ],
  },
];

function ApplicationCells({ app }: { app: Application }) {
  return (
    <>
      <td>{app.role}</td>
      <td>
        <Pill tone={app.stage[1]}>{app.stage[0]}</Pill>
      </td>
      <td>{app.recruiter}</td>
      <td>{app.updated}</td>
    </>
  );
}

export function ChildRows() {
  return (
    <div className="card">
      <Table aria-label="Applications by candidate">
        <thead>
          <tr>
            <th style={{ inlineSize: "28%" }}>Candidate</th>
            <th>Role</th>
            <th style={{ inlineSize: "18%" }}>Stage</th>
            <th style={{ inlineSize: "14%" }}>Recruiter</th>
            <th style={{ inlineSize: "10%" }}>Updated</th>
          </tr>
        </thead>
        {referred.map(({ id, name, applications }) =>
          applications.length === 1 ? (
            <tbody key={id}>
              <tr>
                <th scope="row">{name}</th>
                <ApplicationCells app={applications[0]} />
              </tr>
            </tbody>
          ) : (
            <TableChildRows
              key={id}
              defaultOpen={id === "ana"}
              parent={
                <tr>
                  <th scope="row">
                    <TableExpandButton aria-label={`Applications of ${name}`} />
                    {name}
                  </th>
                  <td>
                    <Text as="span" variant="secondary">
                      {applications.length} applications
                    </Text>
                  </td>
                  <td />
                  <td />
                  <td>{applications[0].updated}</td>
                </tr>
              }
            >
              {applications.map((app, i) => (
                <tr key={app.id}>
                  <th scope="row">Application {i + 1}</th>
                  <ApplicationCells app={app} />
                </tr>
              ))}
            </TableChildRows>
          ),
        )}
      </Table>
    </div>
  );
}

Child rows that select and open

In a cell with its own layout, the expand button is the first item. A candidate's box selects all their applications, and shows mixed when some are selected. Rows open with a click, or Enter or Space; a click on the expand button or a box does not open a row. The first column, button included, stays in place while the table scrolls sideways.

Candidate
RoleStageRecruiterUpdated
Ana Ruiz
2 applicationsOct 14
Application 1
ASIC design engineerInterviewingSam LeeOct 14
Application 2
Verification engineerWe PassedKai ItoSep 2
José Núñez
Firmware engineerScheduledKai ItoOct 13
Lee Park
3 applicationsOct 9

Nothing open.

import { useState, type ReactNode } from "react";
import { Checkbox } from "@etched/monet/checkbox";
import { Pill, type PillTone } from "@etched/monet/pill";
import { Text } from "@etched/monet/text";
import { Table, TableChildRows, TableExpandButton, TableRow } from "@etched/monet/table";

interface Candidate {
  id: string;
  name: string;
  role: string;
  stage: [string, PillTone];
  owner: string;
  calls: number;
  updated: string;
}

interface Application {
  id: string;
  role: string;
  stage: [string, PillTone];
  recruiter: string;
  updated: string;
}

// Orbit's referrals: a candidate with more than one application opens a row for each.
const referred: { id: string; name: string; applications: [Application, ...Application[]] }[] = [
  {
    id: "ana",
    name: "Ana Ruiz",
    applications: [
      { id: "ana-asic", role: "ASIC design engineer", stage: ["Interviewing", "cyan"], recruiter: "Sam Lee", updated: "Oct 14" },
      { id: "ana-dv", role: "Verification engineer", stage: ["We Passed", "red"], recruiter: "Kai Ito", updated: "Sep 2" },
    ],
  },
  { id: "jose", name: "José Núñez", applications: [{ id: "jose-fw", role: "Firmware engineer", stage: ["Scheduled", "purple"], recruiter: "Kai Ito", updated: "Oct 13" }] },
  {
    id: "lee",
    name: "Lee Park",
    applications: [
      { id: "lee-thermal", role: "Thermal engineer", stage: ["Hired", "green"], recruiter: "Sam Lee", updated: "Oct 9" },
      { id: "lee-pkg", role: "Packaging engineer", stage: ["Revisit", "olive"], recruiter: "Ana Ruiz", updated: "Aug 21" },
      { id: "lee-power", role: "Power engineer", stage: ["No Response", "amber"], recruiter: "Kai Ito", updated: "Jul 30" },
    ],
  },
];

function ApplicationCells({ app }: { app: Application }) {
  return (
    <>
      <td>{app.role}</td>
      <td>
        <Pill tone={app.stage[1]}>{app.stage[0]}</Pill>
      </td>
      <td>{app.recruiter}</td>
      <td>{app.updated}</td>
    </>
  );
}

/** A first cell's parts in a row with an 8 px gap: the expand button, a box and the name. */
function FirstCell({ children }: { children: ReactNode }) {
  return <div style={{ display: "flex", alignItems: "center", gap: "var(--et-space-2)" }}>{children}</div>;
}

export function ChildRowsThatSelectAndOpen() {
  const [picked, setPicked] = useState(() => new Set(["lee-thermal"]));
  const [open, setOpen] = useState<string | null>(null);
  const all = referred.flatMap(({ applications }) => applications.map((app) => app.id));

  const pick = (ids: string[], on: boolean) =>
    setPicked((current) => {
      const next = new Set(current);

      for (const id of ids) {
        if (on) next.add(id);
        else next.delete(id);
      }

      return next;
    });

  // Checked when every one of the applications is selected, mixed when some are.
  const box = (label: string, ids: string[]) => (
    <Checkbox
      aria-label={label}
      checked={ids.every((id) => picked.has(id))}
      indeterminate={ids.some((id) => picked.has(id)) && !ids.every((id) => picked.has(id))}
      onCheckedChange={(on) => pick(ids, on)}
    />
  );

  const row = (label: string) => ({ onActivate: () => setOpen(label), "aria-current": open === label ? ("true" as const) : undefined });

  return (
    <>
      <div className="card">
        <Table aria-label="Referred candidates" truncate stickyFirstColumn style={{ minInlineSize: "72rem" }}>
          <thead>
            <tr>
              <th style={{ inlineSize: "16rem" }}>
                <FirstCell>
                  {box("Select all applications", all)}
                  Candidate
                </FirstCell>
              </th>
              <th>Role</th>
              <th style={{ inlineSize: "10rem" }}>Stage</th>
              <th style={{ inlineSize: "9rem" }}>Recruiter</th>
              <th style={{ inlineSize: "7rem" }}>Updated</th>
            </tr>
          </thead>
          {referred.map(({ id, name, applications }) =>
            applications.length === 1 ? (
              <tbody key={id}>
                <TableRow {...row(name)}>
                  <th scope="row">
                    <FirstCell>
                      {box(`Select ${name}`, [applications[0].id])}
                      <Text as="span" truncate>
                        {name}
                      </Text>
                    </FirstCell>
                  </th>
                  <ApplicationCells app={applications[0]} />
                </TableRow>
              </tbody>
            ) : (
              <TableChildRows
                key={id}
                defaultOpen={id === "ana"}
                parent={
                  <TableRow {...row(name)}>
                    <th scope="row">
                      <FirstCell>
                        <TableExpandButton aria-label={`Applications of ${name}`} />
                        {box(`Select ${name}`, applications.map((app) => app.id))}
                        <Text as="span" truncate>
                          {name}
                        </Text>
                      </FirstCell>
                    </th>
                    <td>
                      <Text as="span" variant="secondary">
                        {applications.length} applications
                      </Text>
                    </td>
                    <td />
                    <td />
                    <td>{applications[0].updated}</td>
                  </TableRow>
                }
              >
                {applications.map((app, i) => (
                  <TableRow key={app.id} {...row(`${name}, application ${i + 1}`)}>
                    <th scope="row">
                      <FirstCell>
                        {box(`Select ${name}, application ${i + 1}`, [app.id])}
                        <Text as="span" truncate>
                          Application {i + 1}
                        </Text>
                      </FirstCell>
                    </th>
                    <ApplicationCells app={app} />
                  </TableRow>
                ))}
              </TableChildRows>
            ),
          )}
        </Table>
      </div>
      <p>{open ? `Showing ${open}.` : "Nothing open."}</p>
    </>
  );
}

Pipeline rows

One line per row (truncate) and a sticky first column while the table scrolls sideways. The select-all box sits outside the sort button (start); a checked box anywhere in a row selects it. Ghost triggers edit values in place. Drag a heading's edge, or focus it and use the arrow keys, to resize the column; Home or a double-click resets it.

RoleConvictionStageNotes
Ana Ruiz
InterviewingLed the clock tree rework on the last tape-out; wants to stay close to silicon bring-up.
José Núñez
ScheduledStrong on bootloaders. Asked about the on-call rotation.
Lee Park
HiredBuilt the liquid cooling test bench at their last job.
Mira Osei
We PassedReferred by Sam. Prefers to start after the next quarter.
import { useState } from "react";
import { Avatar } from "@etched/monet/avatar";
import { Checkbox } from "@etched/monet/checkbox";
import { Combobox, type ComboboxItemData } from "@etched/monet/combobox";
import { Pill, type PillTone } from "@etched/monet/pill";
import { Select, type SelectItem } from "@etched/monet/select";
import { Text } from "@etched/monet/text";
import { Table, TableResizeHandle, TableSortHead, type TableSort } from "@etched/monet/table";

interface Candidate {
  id: string;
  name: string;
  role: string;
  stage: [string, PillTone];
  owner: string;
  calls: number;
  updated: string;
}

const candidates: Candidate[] = [
  { id: "ana", name: "Ana Ruiz", role: "ASIC design engineer", stage: ["Interviewing", "cyan"], owner: "Sam Lee", calls: 4, updated: "Oct 14" },
  { id: "jose", name: "José Núñez", role: "Firmware engineer", stage: ["Scheduled", "purple"], owner: "Kai Ito", calls: 12, updated: "Oct 13" },
  { id: "lee", name: "Lee Park", role: "Thermal engineer", stage: ["Hired", "green"], owner: "Sam Lee", calls: 11, updated: "Oct 9" },
  { id: "mira", name: "Mira Osei", role: "Physical design lead for the next tape-out, a title long enough to wrap", stage: ["We Passed", "red"], owner: "Ana Ruiz", calls: 1, updated: "Sep 30" },
];

const roles: ComboboxItemData[] = [
  { value: "asic", label: "ASIC design engineer" },
  { value: "firmware", label: "Firmware engineer" },
  { value: "thermal", label: "Thermal engineer" },
  { value: "physical", label: "Physical design lead" },
];

const convictions: SelectItem<string>[] = [
  { value: "4", label: "Strong yes", tone: "green" },
  { value: "3", label: "Yes", tone: "olive" },
  { value: "2", label: "No", tone: "orange" },
  { value: "1", label: "Strong no", tone: "red" },
];

const notes = new Map([
  ["ana", "Led the clock tree rework on the last tape-out; wants to stay close to silicon bring-up."],
  ["jose", "Strong on bootloaders. Asked about the on-call rotation."],
  ["lee", "Built the liquid cooling test bench at their last job."],
  ["mira", "Referred by Sam. Prefers to start after the next quarter."],
]);

const widths = { candidate: 256, role: 224, conviction: 160, stage: 144 };

export function PipelineRows() {
  const [width, setWidth] = useState(widths);
  const [picked, setPicked] = useState(() => new Set(["lee"]));
  const [order, setOrder] = useState<TableSort>("ascending");
  const [role, setRole] = useState<Record<string, string | null>>({ ana: "asic", jose: "firmware", lee: "thermal", mira: null });
  const [conviction, setConviction] = useState<Record<string, string | null>>({ ana: "4", jose: "3", lee: "4", mira: "2" });

  const pick = (id: string, on: boolean) =>
    setPicked((current) => {
      const next = new Set(current);

      if (on) next.add(id);
      else next.delete(id);

      return next;
    });

  const rows = [...candidates].sort((a, b) => (order === "descending" ? -1 : 1) * a.name.localeCompare(b.name));

  const resize = (column: keyof typeof widths, label: string) => (
    <TableResizeHandle
      aria-label={`${label} column width`}
      width={width[column]}
      defaultWidth={widths[column]}
      onWidthChange={(next) => setWidth({ ...width, [column]: next })}
    />
  );

  return (
    <div className="card">
      <Table aria-label="Pipeline" truncate stickyFirstColumn style={{ minInlineSize: "72rem" }}>
        <thead>
          <tr>
            <TableSortHead
              style={{ inlineSize: width.candidate }}
              sort={order}
              alwaysSorted
              onSortChange={setOrder}
              end={resize("candidate", "Candidate")}
              start={
                <Checkbox
                  aria-label="Select all candidates"
                  checked={picked.size === candidates.length}
                  indeterminate={picked.size > 0 && picked.size < candidates.length}
                  onCheckedChange={(on) => setPicked(new Set(on ? candidates.map((c) => c.id) : []))}
                />
              }
            >
              Candidate
            </TableSortHead>
            <th style={{ inlineSize: width.role }}>
              Role
              {resize("role", "Role")}
            </th>
            <th style={{ inlineSize: width.conviction }}>
              Conviction
              {resize("conviction", "Conviction")}
            </th>
            <th style={{ inlineSize: width.stage }}>
              Stage
              {resize("stage", "Stage")}
            </th>
            <th>Notes</th>
          </tr>
        </thead>
        <tbody>
          {rows.map((person) => (
            <tr key={person.id}>
              <th scope="row">
                <div style={{ display: "flex", alignItems: "center", gap: "var(--et-space-2)" }}>
                  <Checkbox aria-label={`Select ${person.name}`} checked={picked.has(person.id)} onCheckedChange={(on) => pick(person.id, on)} />
                  <Avatar name={person.name} size="sm" alt="" />
                  <Text as="span" truncate>
                    {person.name}
                  </Text>
                </div>
              </th>
              <td>
                <Combobox
                  aria-label={`Role of ${person.name}`}
                  appearance="ghost"
                  size="sm"
                  items={roles}
                  value={role[person.id] ?? null}
                  onValueChange={(value) => setRole({ ...role, [person.id]: value })}
                  placeholder="Add a role"
                />
              </td>
              <td>
                <Select
                  aria-label={`Conviction about ${person.name}`}
                  appearance="ghost"
                  size="sm"
                  items={convictions}
                  value={conviction[person.id] ?? null}
                  onValueChange={(value) => setConviction({ ...conviction, [person.id]: value })}
                  placeholder="Rate"
                />
              </td>
              <td>
                <Pill tone={person.stage[1]}>{person.stage[0]}</Pill>
              </td>
              <td>{notes.get(person.id)}</td>
            </tr>
          ))}
        </tbody>
      </Table>
    </div>
  );
}

Rows that open, and sorts in the URL

With onActivate, a click on a row, or Enter or Space on it, opens it. The name link keeps its own click. The sort heading is a link to the next sort.

import { useState } from "react";
import { type PillTone } from "@etched/monet/pill";
import { Link } from "@etched/monet/link";
import { Table, TableRow, TableSortHead } from "@etched/monet/table";

interface Candidate {
  id: string;
  name: string;
  role: string;
  stage: [string, PillTone];
  owner: string;
  calls: number;
  updated: string;
}

const candidates: Candidate[] = [
  { id: "ana", name: "Ana Ruiz", role: "ASIC design engineer", stage: ["Interviewing", "cyan"], owner: "Sam Lee", calls: 4, updated: "Oct 14" },
  { id: "jose", name: "José Núñez", role: "Firmware engineer", stage: ["Scheduled", "purple"], owner: "Kai Ito", calls: 12, updated: "Oct 13" },
  { id: "lee", name: "Lee Park", role: "Thermal engineer", stage: ["Hired", "green"], owner: "Sam Lee", calls: 11, updated: "Oct 9" },
  { id: "mira", name: "Mira Osei", role: "Physical design lead for the next tape-out, a title long enough to wrap", stage: ["We Passed", "red"], owner: "Ana Ruiz", calls: 1, updated: "Sep 30" },
];

export function RowsThatOpenAndSortsInTheUrl() {
  const [open, setOpen] = useState<string | null>(null);

  return (
    <>
      <div className="card">
        <Table aria-label="Leaderboard" density="dense">
          <thead>
            <tr>
              <th>Name</th>
              <TableSortHead sort="descending" render={<a href="#table-sort=calls-ascending" />} style={{ inlineSize: "8rem" }} data-align="end">
                Calls
              </TableSortHead>
            </tr>
          </thead>
          <tbody>
            {[...candidates]
              .sort((a, b) => b.calls - a.calls)
              .map((person) => (
                <TableRow key={person.id} onActivate={() => setOpen(person.name)} aria-current={open === person.name ? "true" : undefined}>
                  <th scope="row">
                    <Link variant="plain" href={`#profile-${person.id}`}>
                      {person.name}
                    </Link>
                  </th>
                  <td data-align="end">{person.calls}</td>
                </TableRow>
              ))}
          </tbody>
        </Table>
      </div>
      <p>{open ? `Opened ${open}.` : "No row open."}</p>
    </>
  );
}

Row actions

A menu at the end of each row. stickyLastColumn keeps it in view while the table scrolls sideways. Its heading shows no text, so it has an aria-label.

NameRoleStageOwner
Ana RuizASIC design engineerInterviewingSam Lee
José NúñezFirmware engineerScheduledKai Ito
Lee ParkThermal engineerHiredSam Lee
Mira OseiPhysical design lead for the next tape-out, a title long enough to wrapWe PassedAna Ruiz

Last action: none.

import { CopyIcon, DotsThreeIcon, PencilSimpleIcon, TrashIcon } from "@phosphor-icons/react";
import { useState } from "react";
import { Button } from "@etched/monet/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@etched/monet/dropdown";
import { Pill, type PillTone } from "@etched/monet/pill";
import { Table } from "@etched/monet/table";

interface Candidate {
  id: string;
  name: string;
  role: string;
  stage: [string, PillTone];
  owner: string;
  calls: number;
  updated: string;
}

const candidates: Candidate[] = [
  { id: "ana", name: "Ana Ruiz", role: "ASIC design engineer", stage: ["Interviewing", "cyan"], owner: "Sam Lee", calls: 4, updated: "Oct 14" },
  { id: "jose", name: "José Núñez", role: "Firmware engineer", stage: ["Scheduled", "purple"], owner: "Kai Ito", calls: 12, updated: "Oct 13" },
  { id: "lee", name: "Lee Park", role: "Thermal engineer", stage: ["Hired", "green"], owner: "Sam Lee", calls: 11, updated: "Oct 9" },
  { id: "mira", name: "Mira Osei", role: "Physical design lead for the next tape-out, a title long enough to wrap", stage: ["We Passed", "red"], owner: "Ana Ruiz", calls: 1, updated: "Sep 30" },
];

export function RowActions() {
  const [last, setLast] = useState("none");

  return (
    <>
      <div className="card">
        <Table aria-label="Candidates with actions" stickyLastColumn style={{ minInlineSize: "72rem" }}>
          <thead>
            <tr>
              <th style={{ inlineSize: "14rem" }}>Name</th>
              <th>Role</th>
              <th style={{ inlineSize: "10rem" }}>Stage</th>
              <th style={{ inlineSize: "10rem" }}>Owner</th>
              <th style={{ inlineSize: "3.75rem" }} aria-label="Actions" />
            </tr>
          </thead>
          <tbody>
            {candidates.map((person) => (
              <tr key={person.id}>
                <th scope="row">{person.name}</th>
                <td>{person.role}</td>
                <td>
                  <Pill tone={person.stage[1]}>{person.stage[0]}</Pill>
                </td>
                <td>{person.owner}</td>
                <td data-align="end">
                  <DropdownMenu>
                    <DropdownMenuTrigger
                      render={<Button variant="ghost" size="sm" shape="square" aria-label={`Actions for ${person.name}`} icon={<DotsThreeIcon />} />}
                    />
                    <DropdownMenuContent align="end">
                      <DropdownMenuItem icon={<PencilSimpleIcon />} onClick={() => setLast(`Edit ${person.name}`)}>
                        Edit
                      </DropdownMenuItem>
                      <DropdownMenuItem icon={<CopyIcon />} onClick={() => setLast(`Copy link to ${person.name}`)}>
                        Copy link
                      </DropdownMenuItem>
                      <DropdownMenuSeparator />
                      <DropdownMenuItem icon={<TrashIcon />} variant="destructive" onClick={() => setLast(`Remove ${person.name}`)}>
                        Remove
                      </DropdownMenuItem>
                    </DropdownMenuContent>
                  </DropdownMenu>
                </td>
              </tr>
            ))}
          </tbody>
        </Table>
      </div>
      <p>Last action: {last}.</p>
    </>
  );
}

Caption and stripes

A caption names the table and shows under the rows. striped puts every second row on the raised fill; Orbit has none, so it is off by default.

Calls in the last 30 days, by candidate.
NameOwnerCalls
Ana RuizSam Lee4
José NúñezKai Ito12
Lee ParkSam Lee11
Mira OseiAna Ruiz1
Noor HaddadKai Ito7
Ivan PetrovSam Lee3
import { type PillTone } from "@etched/monet/pill";
import { Table } from "@etched/monet/table";

interface Candidate {
  id: string;
  name: string;
  role: string;
  stage: [string, PillTone];
  owner: string;
  calls: number;
  updated: string;
}

const candidates: Candidate[] = [
  { id: "ana", name: "Ana Ruiz", role: "ASIC design engineer", stage: ["Interviewing", "cyan"], owner: "Sam Lee", calls: 4, updated: "Oct 14" },
  { id: "jose", name: "José Núñez", role: "Firmware engineer", stage: ["Scheduled", "purple"], owner: "Kai Ito", calls: 12, updated: "Oct 13" },
  { id: "lee", name: "Lee Park", role: "Thermal engineer", stage: ["Hired", "green"], owner: "Sam Lee", calls: 11, updated: "Oct 9" },
  { id: "mira", name: "Mira Osei", role: "Physical design lead for the next tape-out, a title long enough to wrap", stage: ["We Passed", "red"], owner: "Ana Ruiz", calls: 1, updated: "Sep 30" },
];

const crowd: Candidate[] = [
  ...candidates,
  { id: "noor", name: "Noor Haddad", role: "Verification engineer", stage: ["Interviewing", "cyan"], owner: "Kai Ito", calls: 7, updated: "Oct 12" },
  { id: "ivan", name: "Ivan Petrov", role: "DFT engineer", stage: ["Scheduled", "purple"], owner: "Sam Lee", calls: 3, updated: "Oct 11" },
  { id: "chen", name: "Chen Wei", role: "Packaging engineer", stage: ["Offer", "olive"], owner: "Ana Ruiz", calls: 9, updated: "Oct 10" },
  { id: "sara", name: "Sara Lind", role: "Power engineer", stage: ["Interviewing", "cyan"], owner: "Kai Ito", calls: 5, updated: "Oct 8" },
  { id: "omar", name: "Omar Farouk", role: "Compiler engineer", stage: ["Hired", "green"], owner: "Sam Lee", calls: 14, updated: "Oct 6" },
  { id: "eva", name: "Eva Novak", role: "Systems engineer", stage: ["We Passed", "red"], owner: "Ana Ruiz", calls: 2, updated: "Oct 3" },
  { id: "raj", name: "Raj Mehta", role: "Board designer", stage: ["Scheduled", "purple"], owner: "Kai Ito", calls: 6, updated: "Sep 29" },
  { id: "lena", name: "Lena Vogel", role: "Test engineer", stage: ["Interviewing", "cyan"], owner: "Sam Lee", calls: 8, updated: "Sep 27" },
];

export function CaptionAndStripes() {
  return (
    <div className="card">
      <Table density="dense" striped>
        <caption>Calls in the last 30 days, by candidate.</caption>
        <thead>
          <tr>
            <th>Name</th>
            <th style={{ inlineSize: "12rem" }}>Owner</th>
            <th style={{ inlineSize: "8rem" }} data-align="end">
              Calls
            </th>
          </tr>
        </thead>
        <tbody>
          {crowd.slice(0, 6).map((person) => (
            <tr key={person.id}>
              <th scope="row">{person.name}</th>
              <td>{person.owner}</td>
              <td data-align="end">{person.calls}</td>
            </tr>
          ))}
        </tbody>
      </Table>
    </div>
  );
}

Empty table

One cell that spans every column holds an Empty, so the headings stay in place.

RoleTeamOpenings

No roles match these filters

import { TrayIcon } from "@phosphor-icons/react";
import { Button } from "@etched/monet/button";
import { Empty } from "@etched/monet/empty";
import { Table } from "@etched/monet/table";

export function EmptyTable() {
  return (
    <div className="card">
      <Table aria-label="Matching roles">
        <thead>
          <tr>
            <th>Role</th>
            <th style={{ inlineSize: "12rem" }}>Team</th>
            <th style={{ inlineSize: "8rem" }} data-align="end">
              Openings
            </th>
          </tr>
        </thead>
        <tbody>
          <tr>
            <td colSpan={3}>
              <Empty size="sm" icon={<TrayIcon />} title="No roles match these filters" contents={<Button size="sm">Clear filters</Button>} />
            </td>
          </tr>
        </tbody>
      </Table>
    </div>
  );
}

Loading rows

Skeleton lines hold the rows' place while they load, and aria-busy tells screen readers that the table is not ready.

CandidateInterviewerDate
import { SkeletonLine } from "@etched/monet/loader";
import { Table } from "@etched/monet/table";

export function LoadingRows() {
  return (
    <div className="card">
      <Table aria-label="Interviews" aria-busy="true">
        <thead>
          <tr>
            <th>Candidate</th>
            <th style={{ inlineSize: "12rem" }}>Interviewer</th>
            <th style={{ inlineSize: "8rem" }}>Date</th>
          </tr>
        </thead>
        <tbody>
          {["60%", "40%", "50%"].map((width) => (
            <tr key={width}>
              <td>
                <SkeletonLine style={{ inlineSize: width }} />
              </td>
              <td>
                <SkeletonLine style={{ inlineSize: "70%" }} />
              </td>
              <td>
                <SkeletonLine style={{ inlineSize: "60%" }} />
              </td>
            </tr>
          ))}
        </tbody>
      </Table>
    </div>
  );
}

Error row

When the rows do not load, one cell that spans every column holds a small error Banner with a way to try again.

CandidateOwnerUpdated
import { useState } from "react";
import { Banner } from "@etched/monet/banner";
import { Button } from "@etched/monet/button";
import { type PillTone } from "@etched/monet/pill";
import { Table } from "@etched/monet/table";

interface Candidate {
  id: string;
  name: string;
  role: string;
  stage: [string, PillTone];
  owner: string;
  calls: number;
  updated: string;
}

const candidates: Candidate[] = [
  { id: "ana", name: "Ana Ruiz", role: "ASIC design engineer", stage: ["Interviewing", "cyan"], owner: "Sam Lee", calls: 4, updated: "Oct 14" },
  { id: "jose", name: "José Núñez", role: "Firmware engineer", stage: ["Scheduled", "purple"], owner: "Kai Ito", calls: 12, updated: "Oct 13" },
  { id: "lee", name: "Lee Park", role: "Thermal engineer", stage: ["Hired", "green"], owner: "Sam Lee", calls: 11, updated: "Oct 9" },
  { id: "mira", name: "Mira Osei", role: "Physical design lead for the next tape-out, a title long enough to wrap", stage: ["We Passed", "red"], owner: "Ana Ruiz", calls: 1, updated: "Sep 30" },
];

export function ErrorRow() {
  const [failed, setFailed] = useState(true);

  return (
    <div className="card">
      <Table aria-label="Offers">
        <thead>
          <tr>
            <th>Candidate</th>
            <th style={{ inlineSize: "12rem" }}>Owner</th>
            <th style={{ inlineSize: "8rem" }}>Updated</th>
          </tr>
        </thead>
        <tbody>
          {failed ? (
            <tr>
              <td colSpan={3}>
                <Banner variant="error" size="sm" description="Offers did not load." action={<Button size="sm" onClick={() => setFailed(false)}>Try again</Button>} />
              </td>
            </tr>
          ) : (
            candidates.slice(0, 2).map((person) => (
              <tr key={person.id}>
                <th scope="row">{person.name}</th>
                <td>{person.owner}</td>
                <td>{person.updated}</td>
              </tr>
            ))
          )}
        </tbody>
      </Table>
    </div>
  );
}

Column visibility

A menu of check items shows and hides columns. The name column always shows.

NameRoleStageCalls
Ana RuizASIC design engineerInterviewing4
José NúñezFirmware engineerScheduled12
Lee ParkThermal engineerHired11
Mira OseiPhysical design lead for the next tape-out, a title long enough to wrapWe Passed1
import { SlidersHorizontalIcon } from "@phosphor-icons/react";
import { useState } from "react";
import { Button } from "@etched/monet/button";
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger } from "@etched/monet/dropdown";
import { Pill, type PillTone } from "@etched/monet/pill";
import { Table } from "@etched/monet/table";

interface Candidate {
  id: string;
  name: string;
  role: string;
  stage: [string, PillTone];
  owner: string;
  calls: number;
  updated: string;
}

const candidates: Candidate[] = [
  { id: "ana", name: "Ana Ruiz", role: "ASIC design engineer", stage: ["Interviewing", "cyan"], owner: "Sam Lee", calls: 4, updated: "Oct 14" },
  { id: "jose", name: "José Núñez", role: "Firmware engineer", stage: ["Scheduled", "purple"], owner: "Kai Ito", calls: 12, updated: "Oct 13" },
  { id: "lee", name: "Lee Park", role: "Thermal engineer", stage: ["Hired", "green"], owner: "Sam Lee", calls: 11, updated: "Oct 9" },
  { id: "mira", name: "Mira Osei", role: "Physical design lead for the next tape-out, a title long enough to wrap", stage: ["We Passed", "red"], owner: "Ana Ruiz", calls: 1, updated: "Sep 30" },
];

const optional = { role: "Role", stage: "Stage", owner: "Owner", calls: "Calls" };

type Optional = keyof typeof optional;

// SAFETY: optional is a literal object, so its keys are Optional.
const optionalKeys = Object.keys(optional) as Optional[];

export function ColumnVisibility() {
  const [shown, setShown] = useState<Record<Optional, boolean>>({ role: true, stage: true, owner: false, calls: true });
  const columns = optionalKeys.filter((key) => shown[key]);

  const cell = (person: Candidate, key: Optional) =>
    key === "stage" ? <Pill tone={person.stage[1]}>{person.stage[0]}</Pill> : key === "calls" ? person.calls : person[key];

  return (
    <>
      <DropdownMenu>
        <DropdownMenuTrigger render={<Button size="sm" icon={<SlidersHorizontalIcon />}>Columns</Button>} />
        <DropdownMenuContent align="start">
          {optionalKeys.map((key) => (
            <DropdownMenuCheckboxItem key={key} checked={shown[key]} onCheckedChange={(on) => setShown({ ...shown, [key]: on })}>
              {optional[key]}
            </DropdownMenuCheckboxItem>
          ))}
        </DropdownMenuContent>
      </DropdownMenu>
      <div className="card" style={{ marginBlockStart: "var(--et-space-3)" }}>
        <Table aria-label="Candidates, chosen columns">
          <thead>
            <tr>
              <th style={{ inlineSize: "14rem" }}>Name</th>
              {columns.map((key) => (
                <th key={key} data-align={key === "calls" ? "end" : undefined}>
                  {optional[key]}
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            {candidates.map((person) => (
              <tr key={person.id}>
                <th scope="row">{person.name}</th>
                {columns.map((key) => (
                  <td key={key} data-align={key === "calls" ? "end" : undefined}>
                    {cell(person, key)}
                  </td>
                ))}
              </tr>
            ))}
          </tbody>
        </Table>
      </div>
    </>
  );
}

Selection count and select all

The heading box selects the rows on this page. Then a button selects all 37 on every page. The count is a status, so screen readers hear it change.

NameOwner
Ana RuizSam Lee
José NúñezKai Ito
Lee ParkSam Lee
Mira OseiAna Ruiz
1 of 37 selected
import { useState } from "react";
import { Button } from "@etched/monet/button";
import { Checkbox } from "@etched/monet/checkbox";
import { type PillTone } from "@etched/monet/pill";
import { Select } from "@etched/monet/select";
import { Table } from "@etched/monet/table";

interface Candidate {
  id: string;
  name: string;
  role: string;
  stage: [string, PillTone];
  owner: string;
  calls: number;
  updated: string;
}

const candidates: Candidate[] = [
  { id: "ana", name: "Ana Ruiz", role: "ASIC design engineer", stage: ["Interviewing", "cyan"], owner: "Sam Lee", calls: 4, updated: "Oct 14" },
  { id: "jose", name: "José Núñez", role: "Firmware engineer", stage: ["Scheduled", "purple"], owner: "Kai Ito", calls: 12, updated: "Oct 13" },
  { id: "lee", name: "Lee Park", role: "Thermal engineer", stage: ["Hired", "green"], owner: "Sam Lee", calls: 11, updated: "Oct 9" },
  { id: "mira", name: "Mira Osei", role: "Physical design lead for the next tape-out, a title long enough to wrap", stage: ["We Passed", "red"], owner: "Ana Ruiz", calls: 1, updated: "Sep 30" },
];

const total = 37;

export function SelectionCountAndSelectAll() {
  const [picked, setPicked] = useState(() => new Set(["ana"]));
  const [all, setAll] = useState(false);
  const checked = (id: string) => all || picked.has(id);
  const pageAll = candidates.every((person) => checked(person.id));
  const pageSome = candidates.some((person) => checked(person.id));

  const choose = (ids: string[]) => {
    setAll(false);
    setPicked(new Set(ids));
  };

  const pick = (id: string, on: boolean) => {
    const next = new Set(all ? candidates.map((person) => person.id) : picked);

    if (on) next.add(id);
    else next.delete(id);

    choose([...next]);
  };

  return (
    <>
      <div className="card">
        <Table aria-label="Candidate selection">
          <thead>
            <tr>
              <th>
                <Checkbox
                  aria-label="Select the candidates on this page"
                  checked={pageAll}
                  indeterminate={pageSome && !pageAll}
                  onCheckedChange={(on) => choose(on ? candidates.map((person) => person.id) : [])}
                />
              </th>
              <th>Name</th>
              <th style={{ inlineSize: "14rem" }}>Owner</th>
            </tr>
          </thead>
          <tbody>
            {candidates.map((person) => (
              <tr key={person.id}>
                <td>
                  <Checkbox aria-label={`Select ${person.name}`} checked={checked(person.id)} onCheckedChange={(on) => pick(person.id, on)} />
                </td>
                <th scope="row">{person.name}</th>
                <td>{person.owner}</td>
              </tr>
            ))}
          </tbody>
        </Table>
      </div>
      <div className="row">
        <span role="status">
          {all ? total : picked.size} of {total} selected
        </span>
        {pageAll && !all && (
          <Button variant="quiet" onClick={() => setAll(true)}>
            Select all {total}
          </Button>
        )}
        {all && (
          <Button variant="quiet" onClick={() => choose([])}>
            Clear selection
          </Button>
        )}
      </div>
    </>
  );
}

Accessibility

  • Sort headings are buttons: Enter or Space sorts. A row with onActivate takes focus, Enter or Space opens it, and screen readers hear "Press Enter to open".
  • A TableResizeHandle is a focusable separator, named by its aria-label. ArrowLeft/ArrowRight change the width 16 px a step; Home resets it.
  • A native table, named by its caption or aria-label. TableSortHead sets aria-sort, and the page's polite live region says "Sorted by Calls, ascending" or "Not sorted".
  • A TableExpandButton opens and closes its row's TableChildRows with Enter or Space, and has aria-expanded. Closed child rows are out of the accessibility tree.
  • Give each check cell's Checkbox an aria-label, such as "Select Ana Ruiz", and each expand button one, such as "Applications of Ana Ruiz". Set aria-busy on the table while rows load.
  • Mark the open row with aria-current="true". Keep a link in a row that opens, for new tabs and screen readers.

API reference

TableChildRows

PropTypeDefaultDescription
parent *ReactNode–The parent row: a tr or a TableRow, with a TableExpandButton first in its first cell.
children *ReactNode–The child rows, tr elements. They show while the parent row is open, one step in.

TableExpandButton

PropTypeDefaultDescription
classNamestring–
aria-label *string–Names what opens, for example "Applications of Ana Ruiz". Screen readers add "collapsed" or "expanded".

Table

PropTypeDefaultDescription
density"base" | "dense""base"
truncateboolean–Body cells stay on one line and cut long text with an ellipsis; a cell alone in its row (a message or group row) still wraps. Text in a cell's own layout, such as a row with an avatar, needs <Text truncate>.
stickyHeaderboolean–The header row stays in place while the table scrolls, with a line under it. The table's container must scroll: give it a maximum height and overflow: auto.
stickyFirstColumnboolean–The first column stays in place while the table scrolls sideways, with a line at its edge. The table's container must scroll (overflow-x: auto).
stickyLastColumnboolean–The last column stays in place while the table scrolls sideways, with a line at its edge: for row actions. A cell alone in its row does not stick.
stripedboolean–Every second body row on the raised fill (Kumo). Off by default: Orbit has none.
compactHeaderboolean–Kumo's compact header: 12 px headings in the text ink on the raised fill. On a device that can hover, an unsorted column's arrow and an unused filter show only while the heading is hovered or holds focus.

TableResizeHandle

PropTypeDefaultDescription
width *number–The column's width now, in pixels.
onWidthChange *(width: number) => void–Called with each new width while the handle moves. The app keeps the width, and stores it if it wants.
defaultWidth *number–The width that Home and a double-click go back to.
minnumber48
maxnumber800
aria-label *string–Names the handle, for example "Candidate column width".

TableRow

PropTypeDefaultDescription
onActivate() => void–Makes the whole row open something: on a click, or on Enter or Space while the row has focus.

TableSortHead

PropTypeDefaultDescription
sort *"ascending" | "descending" | "none"–
alwaysSortedboolean–The table always has one sort column: the heading toggles between ascending and descending and never offers none.
startReactNode–Content before the sort button and outside it, such as a select-all Checkbox.
endReactNode–Content after the sort button and outside it, such as a filter.
sortLabel(heading: string, sort: TableSort) => string"Sorted by Calls, ascending", or "Not sorted"What screen readers hear when the sort changes, from the heading's text and its new sort ("none" when no column sorts). Pass translated text.
children *ReactNode–
onSortChange(sort: TableSort) => void–Called on click with the next sort: ascending, descending, then none. Set the other columns to "none".
renderReactElement<{ className?: string }>–A link in place of the sort button, for sorts in the URL: render={<a href="?sort=calls&order=descending" />}. The app points it at the next sort.