Table
A data table. Inside it, use the native parts: thead, tbody, tfoot, tr, th and td. Reticle 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".
| Name | Stage |
|---|---|
| Ana Ruiz | Interviewing |
| José Núñez | Scheduled |
| Lee Park | Hired |
import { Pill, type PillTone } from "@etched/reticle/pill";
import { Table } from "@etched/reticle/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, TableResizeHandle, TableRow, TableSortHead } from "@etched/reticle/table";Examples
Check cells, row headings, sorting and pagination
The heading box selects every row, and a checked row shows as selected.
| 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/reticle/checkbox";
import { Pagination } from "@etched/reticle/pagination";
import { Pill, type PillTone } from "@etched/reticle/pill";
import { Select } from "@etched/reticle/select";
import { Table, TableSortHead, type TableSort } from "@etched/reticle/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; `required` keeps one of them sorted at all times. */
function Head({ sort, setSort, required }: { sort: Sort; setSort: (sort: Sort) => void; required?: boolean }) {
const sortable = (column: Sort["column"], label: string, inlineSize: string) => (
<TableSortHead
style={{ inlineSize }}
sort={sort.column === column ? sort.order : "none"}
required={required}
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>{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} setPage={setPage} perPage={4} totalCount={37} />
</>
);
}Dense, always sorted, with the open row marked
Like Orbit's leaderboard: required 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/reticle/pill";
import { Table, TableSortHead, type TableSort } from "@etched/reticle/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; `required` keeps one of them sorted at all times. */
function Head({ sort, setSort, required }: { sort: Sort; setSort: (sort: Sort) => void; required?: boolean }) {
const sortable = (column: Sort["column"], label: string, inlineSize: string) => (
<TableSortHead
style={{ inlineSize }}
sort={sort.column === column ? sort.order : "none"}
required={required}
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>{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} required />
</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/reticle/combobox";
import { Pill, type PillTone } from "@etched/reticle/pill";
import { Table, TableSortHead, type TableSort } from "@etched/reticle/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>{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%" }} 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/reticle/pill";
import { Table } from "@etched/reticle/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>
);
}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/reticle/avatar";
import { Checkbox } from "@etched/reticle/checkbox";
import { Combobox, type ComboboxItemData } from "@etched/reticle/combobox";
import { Pill, type PillTone } from "@etched/reticle/pill";
import { Select, type SelectItem } from "@etched/reticle/select";
import { Text } from "@etched/reticle/text";
import { Table, TableResizeHandle, TableSortHead, type TableSort } from "@etched/reticle/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}
required
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/reticle/pill";
import { Link } from "@etched/reticle/link";
import { Table, TableRow, TableSortHead } from "@etched/reticle/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" }}>
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>{person.calls}</td>
</TableRow>
))}
</tbody>
</Table>
</div>
<p>{open ? `Opened ${open}.` : "No row open."}</p>
</>
);
}API reference
Table
| Prop | Type | Default | Description |
|---|---|---|---|
| density | "default" | "dense" | "default" | |
| 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>. |
| 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). |
| 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" | – | |
| required | 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. |
| 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. |