# Table

Shows rows of records in aligned columns, such as emails, tickets or people.

- **Import:** `import { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption } from "@booleanpress/ui/table"`
- **APG Table:** <https://www.w3.org/WAI/ARIA/apg/patterns/table/>
- **Page:** <https://ui.booleanpress.com/components/table> · @booleanpress/ui 0.1.0

## Usage

Table styles the native table elements and adds nothing else: no sorting, selection or paging. It wraps the `<table>` in a container that scrolls sideways when the columns do not fit.

```tsx
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@booleanpress/ui/table"

export function Emails() {
  return (
    <Table>
      <TableHeader>
        <TableRow>
          <TableHead>Recipient</TableHead>
          <TableHead>Status</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        <TableRow>
          <TableCell>ana@example.com</TableCell>
          <TableCell>Delivered</TableCell>
        </TableRow>
      </TableBody>
    </Table>
  )
}
```

The parts are `Table`, `TableHeader`, `TableBody`, `TableFooter`, `TableRow`, `TableHead`, `TableCell` and `TableCaption`. For a selected row, set `data-state="selected"` on the `TableRow`: it is tinted with the primary colour. `className` on `Table` goes on the `<table>`, not on the scroll container.

For sorting, filtering and paging, drive the rows from your own state or a table library, and render them with these parts. Combine with `Checkbox` for row selection, `Pagination` for paging and `Badge` for status.

## Examples

### Basic

A header row, three body rows and a status badge in a cell.

```tsx
import { Badge } from "@booleanpress/ui/badge"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@booleanpress/ui/table"

const ROWS = [
  { to: "ana@example.com", subject: "Your invoice", status: "Delivered", sent: "10:42" },
  { to: "li@example.com", subject: "Password reset", status: "Delivered", sent: "10:40" },
  { to: "sam@example.com", subject: "Welcome aboard", status: "Bounced", sent: "10:31" },
]

export default function TableBasic() {
  return (
    <Table className="max-w-xl">
      <TableHeader>
        <TableRow>
          <TableHead>Recipient</TableHead>
          <TableHead>Subject</TableHead>
          <TableHead>Status</TableHead>
          <TableHead className="text-end">Sent</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {ROWS.map((row) => (
          <TableRow key={row.to}>
            <TableCell className="font-medium">{row.to}</TableCell>
            <TableCell>{row.subject}</TableCell>
            <TableCell>
              <Badge variant={row.status === "Bounced" ? "destructive" : "secondary"}>{row.status}</Badge>
            </TableCell>
            <TableCell className="text-end tabular-nums">{row.sent}</TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  )
}
```

### Caption and footer

`TableCaption` names the table; `TableFooter` holds the totals.

```tsx
import { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@booleanpress/ui/table"

const ROWS = [
  { mailer: "Amazon SES", sent: 1840, failed: 12 },
  { mailer: "Postmark", sent: 960, failed: 3 },
  { mailer: "SMTP relay", sent: 215, failed: 9 },
]

export default function TableCaptionAndFooter() {
  const sent = ROWS.reduce((sum, row) => sum + row.sent, 0)
  const failed = ROWS.reduce((sum, row) => sum + row.failed, 0)

  return (
    <Table className="max-w-md">
      <TableCaption>Emails by mailer, last 7 days.</TableCaption>
      <TableHeader>
        <TableRow>
          <TableHead>Mailer</TableHead>
          <TableHead className="text-end">Sent</TableHead>
          <TableHead className="text-end">Failed</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {ROWS.map((row) => (
          <TableRow key={row.mailer}>
            <TableCell className="font-medium">{row.mailer}</TableCell>
            <TableCell className="text-end tabular-nums">{row.sent.toLocaleString("en")}</TableCell>
            <TableCell className="text-end tabular-nums">{row.failed}</TableCell>
          </TableRow>
        ))}
      </TableBody>
      <TableFooter>
        <TableRow>
          <TableCell>Total</TableCell>
          <TableCell className="text-end tabular-nums">{sent.toLocaleString("en")}</TableCell>
          <TableCell className="text-end tabular-nums">{failed}</TableCell>
        </TableRow>
      </TableFooter>
    </Table>
  )
}
```

### Selectable rows

Row checkboxes and a select-all box that shows a dash when only some rows are chosen. Chosen rows are tinted through `data-state="selected"`.

```tsx
import { useState } from "react"
import { Checkbox } from "@booleanpress/ui/checkbox"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@booleanpress/ui/table"

const ROWS = [
  { id: "t-1041", subject: "Cannot connect to SES", requester: "Ana Ruiz" },
  { id: "t-1040", subject: "Invoice is missing a VAT number", requester: "Li Wei" },
  { id: "t-1039", subject: "Import stops at row 200", requester: "Sam Okoye" },
]

export default function TableSelectableRows() {
  const [selected, setSelected] = useState<string[]>(["t-1040"])
  const all = selected.length === ROWS.length ? true : selected.length > 0 ? "indeterminate" : false
  const toggle = (id: string, on: boolean) => setSelected((cur) => (on ? [...cur, id] : cur.filter((x) => x !== id)))

  return (
    <Table className="max-w-xl">
      <TableHeader>
        <TableRow>
          <TableHead className="w-8">
            <Checkbox
              aria-label="Select all tickets"
              checked={all}
              onCheckedChange={(checked) => setSelected(checked === true ? ROWS.map((r) => r.id) : [])}
            />
          </TableHead>
          <TableHead>Ticket</TableHead>
          <TableHead>Subject</TableHead>
          <TableHead>Requester</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {ROWS.map((row) => (
          <TableRow key={row.id} data-state={selected.includes(row.id) ? "selected" : undefined}>
            <TableCell>
              <Checkbox
                aria-label={`Select ${row.id}`}
                checked={selected.includes(row.id)}
                onCheckedChange={(checked) => toggle(row.id, checked === true)}
              />
            </TableCell>
            <TableCell className="font-medium tabular-nums">{row.id}</TableCell>
            <TableCell>{row.subject}</TableCell>
            <TableCell>{row.requester}</TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  )
}
```

### Empty

A single cell spans every column and says why there are no rows.

```tsx
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@booleanpress/ui/table"

export default function TableEmpty() {
  return (
    <Table className="max-w-xl">
      <TableHeader>
        <TableRow>
          <TableHead>Recipient</TableHead>
          <TableHead>Subject</TableHead>
          <TableHead>Status</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        <TableRow>
          <TableCell colSpan={3} className="h-24 text-center text-muted-foreground">
            No emails match these filters.
          </TableCell>
        </TableRow>
      </TableBody>
    </Table>
  )
}
```

### Horizontal scroll

In a narrow space the container scrolls sideways, and the cells do not wrap.

```tsx
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@booleanpress/ui/table"

export default function TableHorizontalScroll() {
  return (
    <div className="w-full max-w-xs rounded-md border">
      <Table>
        <TableHeader>
          <TableRow>
            <TableHead>Message ID</TableHead>
            <TableHead>Recipient</TableHead>
            <TableHead>Subject</TableHead>
            <TableHead>Mailer</TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          <TableRow>
            <TableCell className="font-mono text-xs">0100019a2b3c4d5e-7f8a9b0c-1d2e</TableCell>
            <TableCell>ana@example.com</TableCell>
            <TableCell>Your monthly delivery summary</TableCell>
            <TableCell>Amazon SES</TableCell>
          </TableRow>
        </TableBody>
      </Table>
    </div>
  )
}
```

## Accessibility

**Semantics.** Native `table`, `thead`, `tbody`, `tfoot`, `tr`, `th` and `td`, so a screen reader announces the row and column of each cell. `TableCaption` is a native `caption`.

**Labels.** Name the table with a `TableCaption`, or `aria-label` on `Table` when a visible caption does not fit. Give a checkbox in a row or in the header an `aria-label` that says what it selects ("Select t-1040", "Select all tickets"). An empty header cell, such as one above an action menu, needs visually hidden text.

**Focus.** The table has no tab stop of its own. Controls inside cells (checkboxes, links, menus) are tab stops in reading order.

**Known limits.**

- Selection is shown by colour alone. The tint tells a sighted person which rows are chosen; the checkbox state (`aria-checked`) tells everyone else. Keep a checkbox in each selectable row.
- The scroll container is not a tab stop, so a keyboard user cannot scroll a wide table unless a control inside it takes focus. Add `tabIndex={0}`, with a name, to your own wrapper if the table can have no focusable content.
- There is no sorting or `aria-sort`. Add `aria-sort` on the `TableHead` you sort by.
- Cells do not wrap (`whitespace-nowrap`). Long text makes the table scroll; add `whitespace-normal` to a cell that should wrap.

### Keyboard

| Key | Behaviour |
| --- | --- |

## API

### Table

Renders a `table` and passes it every other prop.

### TableHeader

Renders a `thead` and passes it every other prop.

### TableBody

Renders a `tbody` and passes it every other prop.

### TableFooter

Renders a `tfoot` and passes it every other prop.

### TableHead

Renders a `th` and passes it every other prop.

### TableRow

Renders a `tr` and passes it every other prop.

### TableCell

Renders a `td` and passes it every other prop.

### TableCaption

Renders a `caption` and passes it every other prop.

Every part takes `className`, merged with its defaults by `cn()`, and `ref`, which reaches the element it renders.

**Data attributes:** `data-slot="table-container"` (Table), `data-slot="table-header"` (TableHeader), `data-slot="table-body"` (TableBody), `data-slot="table-footer"` (TableFooter), `data-slot="table-head"` (TableHead), `data-slot="table-row"` (TableRow), `data-slot="table-cell"` (TableCell), `data-slot="table-caption"` (TableCaption).

## Theming

A row darkens on hover (`bg-muted/50`) and a selected row is tinted `bg-primary/10`. The footer is `bg-muted/50`.

| Token | Used for |
| --- | --- |
| `--foreground` | text |
| `--muted` | background |
| `--muted-foreground` | text |
| `--primary` | background |
