Table
PortedA 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.
| Name | Stage |
|---|---|
| Ana Ruiz | Interviewing |
| José Núñez | Scheduled |
| Lee Park | Hired |
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.
| Role | Stage | Owner | Updated | |||
|---|---|---|---|---|---|---|
| Ana Ruiz | ASIC design engineer | Interviewing | Sam Lee | 4 | Oct 14 | |
| José Núñez | Firmware engineer | Scheduled | Kai Ito | 12 | Oct 13 | |
| Lee Park | Thermal engineer | Hired | Sam Lee | 11 | Oct 9 | |
| Mira Osei | Physical design lead for the next tape-out, a title long enough to wrap | We Passed | Ana Ruiz | 1 | Sep 30 |
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.
| Role | Stage | Owner | Updated | ||
|---|---|---|---|---|---|
| José Núñez | Firmware engineer | Scheduled | Kai Ito | 12 | Oct 13 |
| Lee Park | Thermal engineer | Hired | Sam Lee | 11 | Oct 9 |
| Ana Ruiz | ASIC design engineer | Interviewing | Sam Lee | 4 | Oct 14 |
| Mira Osei | Physical design lead for the next tape-out, a title long enough to wrap | We Passed | Ana Ruiz | 1 | Sep 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 | Owner | Updated | ||
|---|---|---|---|---|---|
| Ana Ruiz | ASIC design engineer | Interviewing | Sam Lee | 4 | Oct 14 |
| José Núñez | Firmware engineer | Scheduled | Kai Ito | 12 | Oct 13 |
| Lee Park | Thermal engineer | Hired | Sam Lee | 11 | Oct 9 |
| Mira Osei | Physical design lead for the next tape-out, a title long enough to wrap | We Passed | Ana Ruiz | 1 | Sep 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.
| Name | Role | Owner |
|---|---|---|
| Silicon | ||
| Ana Ruiz | ASIC design engineer | Sam Lee |
| José Núñez | Firmware engineer | Kai Ito |
| Systems | ||
| Lee Park | Thermal engineer | Sam Lee |
| Mira Osei | Physical design lead for the next tape-out, a title long enough to wrap | Ana 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.
| Candidate | Role | Stage | Recruiter | Updated |
|---|---|---|---|---|
| Ana Ruiz | 2 applications | Oct 14 | ||
| Application 1 | ASIC design engineer | Interviewing | Sam Lee | Oct 14 |
| Application 2 | Verification engineer | We Passed | Kai Ito | Sep 2 |
| José Núñez | Firmware engineer | Scheduled | Kai Ito | Oct 13 |
| Lee Park | 3 applications | Oct 9 | ||
| Application 1 | Thermal engineer | Hired | Sam Lee | Oct 9 |
| Application 2 | Packaging engineer | Revisit | Ana Ruiz | Aug 21 |
| Application 3 | Power engineer | No Response | Kai Ito | Jul 30 |
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 | Role | Stage | Recruiter | Updated |
|---|---|---|---|---|
Ana Ruiz | 2 applications | Oct 14 | ||
Application 1 | ASIC design engineer | Interviewing | Sam Lee | Oct 14 |
Application 2 | Verification engineer | We Passed | Kai Ito | Sep 2 |
José Núñez | Firmware engineer | Scheduled | Kai Ito | Oct 13 |
Lee Park | 3 applications | Oct 9 | ||
Application 1 | Thermal engineer | Hired | Sam Lee | Oct 9 |
Application 2 | Packaging engineer | Revisit | Ana Ruiz | Aug 21 |
Application 3 | Power engineer | No Response | Kai Ito | Jul 30 |
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.
| Role | Conviction | Stage | Notes | |
|---|---|---|---|---|
Ana Ruiz | Interviewing | Led the clock tree rework on the last tape-out; wants to stay close to silicon bring-up. | ||
José Núñez | Scheduled | Strong on bootloaders. Asked about the on-call rotation. | ||
Lee Park | Hired | Built the liquid cooling test bench at their last job. | ||
Mira Osei | We Passed | Referred 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.
| Name | Calls |
|---|---|
| José Núñez | 12 |
| Lee Park | 11 |
| Ana Ruiz | 4 |
| Mira Osei | 1 |
No row open.
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>
</>
);
}Sticky header
stickyHeader keeps the headings in view while the rows scroll, with a line under them. The box around the table has a maximum height and scrolls; here it also scrolls sideways under a sticky first column, and the Role heading resizes.
| Role | Stage | Owner | Updated | ||
|---|---|---|---|---|---|
| Ana Ruiz | ASIC design engineer | Interviewing | Sam Lee | 4 | Oct 14 |
| Chen Wei | Packaging engineer | Offer | Ana Ruiz | 9 | Oct 10 |
| Eva Novak | Systems engineer | We Passed | Ana Ruiz | 2 | Oct 3 |
| Ivan Petrov | DFT engineer | Scheduled | Sam Lee | 3 | Oct 11 |
| José Núñez | Firmware engineer | Scheduled | Kai Ito | 12 | Oct 13 |
| Lee Park | Thermal engineer | Hired | Sam Lee | 11 | Oct 9 |
| Lena Vogel | Test engineer | Interviewing | Sam Lee | 8 | Sep 27 |
| Mira Osei | Physical design lead for the next tape-out, a title long enough to wrap | We Passed | Ana Ruiz | 1 | Sep 30 |
| Noor Haddad | Verification engineer | Interviewing | Kai Ito | 7 | Oct 12 |
| Omar Farouk | Compiler engineer | Hired | Sam Lee | 14 | Oct 6 |
| Raj Mehta | Board designer | Scheduled | Kai Ito | 6 | Sep 29 |
| Sara Lind | Power engineer | Interviewing | Kai Ito | 5 | Oct 8 |
import { useState } from "react";
import { Pill, type PillTone } from "@etched/monet/pill";
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" },
];
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 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 StickyHeader() {
const [sort, setSort] = useState<Sort>({ column: "name", order: "ascending" });
const [role, setRole] = useState(256);
const order = (column: Sort["column"]) => (sort.column === column ? sort.order : "none");
return (
<div className="card" style={{ maxBlockSize: "17rem" }}>
<Table aria-label="All candidates" density="dense" stickyHeader stickyFirstColumn style={{ minInlineSize: "72rem" }}>
<thead>
<tr>
<TableSortHead style={{ inlineSize: "14rem" }} sort={order("name")} onSortChange={(next) => setSort({ column: "name", order: next })}>
Name
</TableSortHead>
<th style={{ inlineSize: role }}>
Role
<TableResizeHandle aria-label="Role column width" width={role} defaultWidth={256} onWidthChange={setRole} />
</th>
<th>Stage</th>
<th>Owner</th>
<TableSortHead data-align="end" sort={order("calls")} onSortChange={(next) => setSort({ column: "calls", order: next })}>
Calls
</TableSortHead>
<th>Updated</th>
</tr>
</thead>
<tbody>
{sorted(crowd, sort).map((person) => (
<tr key={person.id}>
<Cells person={person} />
</tr>
))}
</tbody>
</Table>
</div>
);
}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.
| Name | Role | Stage | Owner | |
|---|---|---|---|---|
| Ana Ruiz | ASIC design engineer | Interviewing | Sam Lee | |
| José Núñez | Firmware engineer | Scheduled | Kai Ito | |
| Lee Park | Thermal engineer | Hired | Sam Lee | |
| Mira Osei | Physical design lead for the next tape-out, a title long enough to wrap | We Passed | Ana 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.
| Name | Owner | Calls |
|---|---|---|
| Ana Ruiz | Sam Lee | 4 |
| José Núñez | Kai Ito | 12 |
| Lee Park | Sam Lee | 11 |
| Mira Osei | Ana Ruiz | 1 |
| Noor Haddad | Kai Ito | 7 |
| Ivan Petrov | Sam Lee | 3 |
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.
| Role | Team | Openings |
|---|---|---|
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.
| Candidate | Interviewer | Date |
|---|---|---|
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.
| Candidate | Owner | Updated |
|---|---|---|
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.
| Name | Role | Stage | Calls |
|---|---|---|---|
| Ana Ruiz | ASIC design engineer | Interviewing | 4 |
| José Núñez | Firmware engineer | Scheduled | 12 |
| Lee Park | Thermal engineer | Hired | 11 |
| Mira Osei | Physical design lead for the next tape-out, a title long enough to wrap | We Passed | 1 |
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.
| Name | Owner | |
|---|---|---|
| Ana Ruiz | Sam Lee | |
| José Núñez | Kai Ito | |
| Lee Park | Sam Lee | |
| Mira Osei | Ana Ruiz |
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:
EnterorSpacesorts. A row withonActivatetakes focus,EnterorSpaceopens it, and screen readers hear "Press Enter to open". - A
TableResizeHandleis a focusableseparator, named by itsaria-label.ArrowLeft/ArrowRightchange the width 16 px a step;Homeresets it. - A native
table, named by itscaptionoraria-label.TableSortHeadsetsaria-sort, and the page's polite live region says "Sorted by Calls, ascending" or "Not sorted". - A
TableExpandButtonopens and closes its row'sTableChildRowswithEnterorSpace, and hasaria-expanded. Closed child rows are out of the accessibility tree. - Give each check cell's
Checkboxanaria-label, such as "Select Ana Ruiz", and each expand button one, such as "Applications of Ana Ruiz". Setaria-busyon 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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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. |
Table
| Prop | Type | Default | Description |
|---|---|---|---|
| density | "base" | "dense" | "base" | |
| truncate | boolean | – | 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>. |
| stickyHeader | boolean | – | 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. |
| stickyFirstColumn | boolean | – | 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). |
| stickyLastColumn | boolean | – | 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. |
| striped | boolean | – | Every second body row on the raised fill (Kumo). Off by default: Orbit has none. |
| compactHeader | boolean | – | 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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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. |
| min | number | 48 | |
| max | number | 800 | |
| aria-label * | string | – | Names the handle, for example "Candidate column width". |
TableRow
| Prop | Type | Default | Description |
|---|---|---|---|
| onActivate | () => void | – | Makes the whole row open something: on a click, or on Enter or Space while the row has focus. |
TableSortHead
| Prop | Type | Default | Description |
|---|---|---|---|
| sort * | "ascending" | "descending" | "none" | – | |
| alwaysSorted | boolean | – | The table always has one sort column: the heading toggles between ascending and descending and never offers none. |
| start | ReactNode | – | Content before the sort button and outside it, such as a select-all Checkbox. |
| end | ReactNode | – | 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". |
| render | ReactElement<{ 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. |