# Base table conventions — the full contract

_Grounded in `packages/web-components/components/table/table.yaml`, `table.class.js`, `table.css`,
and `cell-types.js`. `table-ui` composes NINE primitives (`grep "createElement.*-ui"` in
`table.class.js`/`cell-types.js` is the live census): `check-ui`, `icon-ui`, `progress-ui`,
`pagination-ui`, `skeleton-ui`, `badge-ui`, `row-ui`, `avatar-ui`, `button-ui` — per ADR-0027,
primitives that programmatically create other primitives do NOT auto-import them, so a consumer
page must explicitly import whichever of the nine it actually renders (badges for status cells,
progress + row for percent cells, avatar + row for avatar cells, button for actions cells, etc.)._

## Contained vs uncontained — the two code shapes

Uncontained (edge-to-edge, composed inside a card):

```html
<card-ui>
  <header>…</header>
  <section bleed>
    <table-ui sortable striped></table-ui>
  </section>
</card-ui>
```

`[raw]` (consumer supplies the entire body — see the SKILL.md's separate-axis rule):

```html
<table-ui raw>
  <table>…hand-authored rows, e.g. per-cell input-ui editors…</table>
</table-ui>
```

## Columns — declarative and JS forms

Two equivalent ways to define columns; pick one per table, don't mix:

```html
<!-- Declarative: <col-def> children, parsed once at connect and then removed from the DOM -->
<table-ui sortable>
  <col-def key="name" label="Name" sortable></col-def>
  <col-def key="created" label="Created" type="date" width="140"></col-def>
</table-ui>
```

```js
// JS: el.columns = [...] — the richer form (render/format/accessor/sortFn functions
// aren't expressible as HTML attributes)
el.columns = [
  { key: 'name', label: 'Name', sortable: true },
  { key: 'status', label: 'Status', type: 'badge' },
];
el.data = rows; // array of plain objects keyed to columns[].key
```

Column shape: `{key, label, type?, width?, minWidth?, maxWidth?, flex?, sortable?, resizable?,
pinned?, hidden?, wrap?, accessor?, format?, render?, sortFn?, sortDescFirst?, aggregate?, meta?}`.
`accessor` resolves a value from a shape the plain `key` dot-path can't reach;
`render(value, row, cell, dataIndex)` returns a Node or HTML string for full custom cells;
`format(value, row)` returns a display string (also used for CSV export) while leaving the
built-in cell-type rendering in place for everything else. `sortFn(a, b)` overrides the
type-derived comparator and IS wired into the multi-sort pipeline (`table.class.js:573-578`).

**`filterable`, `filterType`, and `filterFn` are vestigial** — `table.yaml:23` and the class's own
top-of-file doc comment (`table.class.js:37`) all list them, but grep confirms zero reads of
`col.filterable`, `col.filterType`, or `col.filterFn` anywhere in the filter pipeline
(`#getProcessedIndices`, `#buildFilterDropdown`) — only `col.filter`
(`'select' | 'number' | <any truthy for text>'` — see Header conventions below) is ever consumed.
Setting any of the three on a column silently does nothing; don't reach for them even though
they're documented alongside the real `sortFn`.

## Defaults — full rationale and citations

(SKILL.md's defaults table gives the short form; this is the grounding.)

- **`striped`** defaults `false` — opt-in, not the table's baseline appearance despite how common
  it looks in demos.
- **Per-column `resizable`/`sortable`, JS `.columns` form** — opt-out: `col.resizable !== false`
  (`table.class.js:785`) and `col.sortable !== false && this.sortable` (`:760`) mean a plain JS
  column object gets a resize handle and (with the host `[sortable]` gate) a clickable header
  unless explicitly set `false`.
- **Per-column `resizable`/`sortable`, declarative `<col-def>` form** — opt-in, the inverse of the
  JS form: `#parseColDefs` reads `resizable: el.hasAttribute('resizable')` and
  `sortable: el.hasAttribute('sortable')` (`table.class.js:416-417`) — an absent attribute becomes
  `false`; `resizable="false"` isn't even expressible as a col-def attribute (only
  presence/absence). Pick the form deliberately: `<col-def>` needs the attribute stated on every
  resizable/sortable column, `.columns` needs it stated only to turn one OFF.
- **`sortable` (host)** defaults `false` — a host-level gate required in addition to whichever
  per-column default above applies.
- **`wrap` (host) / `data-wrap` (per-cell)** default `false` — cells truncate single-line with
  ellipsis by default (matches `<select-ui>`/`<nav-item-ui>` row convention); `[wrap]` opts the
  whole table into multi-line, `[data-wrap]` on one `col-def`/cell opts in surgically.
- **`selectable`, `expandable`, `loading`, `paginate`** default `false`/`false`/`false`/`0` — all
  opt-in; `paginate="0"` means "render every row, no pager."

## Header conventions

- **Label** falls back to `key` when `label` is omitted.
- **Sortable header** — a column is clickable only when BOTH the host has `[sortable]` AND the
  column's own sortability resolves true. **The default direction flips by form**: a JS `.columns`
  object is sortable unless `sortable: false` is set (`col.sortable !== false`, `:760`); a
  declarative `<col-def>` needs `sortable` stated as a bare attribute — its absence means
  unsortable (`sortable: el.hasAttribute('sortable')`, `:416`). Click cycles asc → desc → cleared;
  Shift+click adds the column to a multi-sort stack instead of replacing it. Listen for the `sort`
  event — `detail.key` + `detail.dir` (**not** `.column`/`.direction`) + `detail.sortState` (the
  full multi-sort array).
- **Pinned** — `pinned: 'left' | 'right'` on a column sticks it during horizontal scroll (used
  for an identity column or a trailing actions column).
- **Resize handle** — same form-dependent default as sortable: JS `.columns` renders the handle
  unless `resizable: false` (`col.resizable !== false`, `:785`); `<col-def>` needs the bare
  `resizable` attribute present (`:412`) — absent means no handle, and `resizable="false"` isn't
  expressible as an HTML attribute at all. Drag fires a `resize` event (`detail.key`,
  `detail.width`) on release; widths persist via `getState()`/`setState()` when the host has a
  `state-key` attribute (localStorage-backed).
- **Filter button** — appears only when a column sets `filter: 'select' | 'number'` (or any
  truthy value for a default text-contains filter). `'select'` auto-builds a checkbox list from
  the column's unique values; free typing in a text filter debounces via the `input` listener.
  Listen for `filter-change` (`detail.filters` — the whole active filter map).

## Cell types

`col.type` selects a built-in renderer (`cell-types.js`); omit for `'text'`. Render priority is
`col.render` > `col.format` > the type's own renderer > plain text.

| Type | Renders | Align | Sort |
| --- | --- | --- | --- |
| `text` (default) | plain string | left | alphanumeric |
| `number` | `Intl.NumberFormat` | right | numeric |
| `currency` | `Intl.NumberFormat` style `currency`, `meta.currency` (default `USD`) | right | numeric |
| `percent` | formatted percentage | right | numeric |
| `date` / `datetime` | localized date/date-time | — | chronological |
| `boolean` | check/x glyph | — | — |
| `badge` | `badge-ui` (needs explicit import — ADR-0027) | — | — |
| `avatar` | `avatar-ui` inside `row-ui` (both need explicit import) | — | — |
| `link` | `<a>` | — | alphanumeric |
| `markdown` | inline markdown render | — | — |
| `progress` | `progress-ui` inside `row-ui` (needs explicit import) | — | numeric |
| `actions` | `button-ui` action buttons (needs explicit import) | — | — |

## Row-level conventions

- **Single-line truncation is the default.** Body cells clip with an ellipsis; `[wrap]` on the
  host opts every cell into multi-line (row height auto-grows); `[data-wrap]` on one `col-def` or
  cell opts in surgically. Long unbreakable strings (URLs, IDs) clip gracefully rather than
  rewrapping — this is a deliberate row-convention match with `<select-ui>`/`<nav-item-ui>`, not
  an oversight.
- **`row-click`** fires once per row regardless of which cell was hit — the row-identity
  companion to `cell-click` (`detail: {row, dataIndex}`, a strict subset of `cell-click`'s
  `{key, row, value, dataIndex}`). Use `row-click` for master-detail/flyout wiring; use
  `cell-click` when the specific column matters.
- **Selection** — `[selectable]` adds a checkbox column with a header select-all and Shift+click
  range-select. `select` event detail is `{selected: [...indices]}`; read/write the live set via
  `el.selected` (getter/setter) or `el.clearSelection()`.
- **Expansion** — `[expandable]` reserves a caret column; per-row gating via `el.rowExpandable =
  (row, index) => boolean` lets heterogeneous rows opt out while keeping column alignment (the
  caret cell still renders empty rather than shifting columns). `row-expand`/`row-collapse` events
  carry `{index, row}`; `el.expandRenderer = (row, index) => Node` supplies the detail-row content.
- **Aggregation footer** — any column with `aggregate: 'sum'|'avg'|'min'|'max'|'count'` gets a
  totals row across the currently filtered/sorted set (not the raw unfiltered data).

## Loading, empty, and pagination

- **`[loading]`** renders N skeleton rows (`skeleton-ui` cells, varied widths so they read as
  natural data, not a uniform bar) — count derived from `paginate` (capped at 8) or 5 when
  unpaginated. Header + columns stay intact so layout doesn't shift; `aria-busy="true"` is set on
  the host. This is a skeleton-row overlay, not a spinner — don't build a separate loading
  indicator around the table.
- **Empty state** — when `.data` is `[]` (and not loading), table-ui renders its own `[data-empty]`
  "No data" panel automatically. Combined with the loading skeleton, table-ui natively covers the
  default/loading/empty three of `data-wiring`'s four-state region rule — only the **error** state
  (an `alert-ui[variant="danger"]`, per that rule) is the consumer's own responsibility.
- **Pagination** — `paginate="N"` (rows per page; `0` = show all, no pager) renders a
  `pagination-ui` footer and emits `page` on navigation (`detail.page`, 0-based). **`[virtual]` is
  not a recognized prop** — it's silently accepted (unknown attributes on custom elements are
  ignored) but does nothing; there is no DOM-level virtual scrolling. `[paginate]` is the only
  supported performance mechanism, and only up to the hundreds-of-rows range (every row still
  mounts a real skeleton/data row DOM node when its page is active — paginate slices which page,
  it doesn't reduce total DOM churn across pages). For genuinely huge collections (1,000+ rows —
  chat threads, feeds, log streams), reach for `list-window-ui` instead, a different primitive
  that windows to only the visible slice; it is not table-shaped (no columns/header/sort), so it
  fits a single-column list read, not a multi-column grid. **Server-driven pagination (gh#1754,
  ADR-0082)** — set `paginate="N"` (page size) TOGETHER with `range-total="M"` (the server's total
  row count across all pages): `[paginate]` becomes purely presentational (no local slicing —
  `.data` IS the current server page and renders whole, after any local search/sort/filter), the
  internal pager's page count derives from `range-total`, and the existing `page` event (0-based)
  becomes the fetch trigger — listen for it, fetch that server page, write the rows back to
  `.data` (the page position holds; no reset) — **except** when a NEW `range-total` shrinks the
  page count below the current page: `table-ui` clamps the RENDERED page to the last valid page
  in that case (a render-time display clamp, never a `page` event dispatch — REQ-D-005), so a
  consumer must not assume every server response preserves an out-of-range page. `range-total` is presence-gated: absent means
  client mode (today's behavior); an explicit `range-total="0"` is server-confirmed empty, not
  "not yet known." A bound `table-footer-ui` is optional chrome in this shape (it derives its own
  range label from the table's `range-total` with zero footer attributes) — or omit the footer
  entirely and let the table's own internal pager work server-side alone, the ticket's literal
  headline case. See table.yaml's `[range-total]` prop doc and
  `docs/ops/adr/adr-0082-table-ui-server-range-total.md` for the full contract.
  **[extended 2026-08-23, ADR-0082 amendment, gh#1877]** for cursor/`hasMore`
  server paging where the total isn't known yet, `range-total="?"` is an open/
  unproven-total state (still presence-gated into server mode — "?" is a value
  of the same attribute, not a new one): `table-ui` keeps the pager exactly one
  page ahead of the current page (`next` stays enabled indefinitely, never a
  false "last page") instead of a fixed `ceil(...)`; `table-toolbar-ui` /
  `table-footer-ui` render "Showing X–Y" with no "of N" until a real number
  replaces the "?", at which point it resolves to the ordinary finite-total
  behavior above with nothing else changed. Two more, additive, on
  `table-toolbar-ui`/`table-footer-ui` only (never `table-ui` — no independent
  second-total concern there): `range-of="<whole>"` appends "(of `<range-of>`
  total)" onto the finite range text — a secondary filtered-of-whole total,
  unset by default, never applies while the range is open or loading/empty —
  and `range-noun="<noun>"` appends a per-slice noun after the range text in
  both the finite and open cases ("of 80 users", "Showing 51–100
  subscriptions"), also unset by default.

## table-toolbar-ui — the companion search/filter/sort/columns bar

`<table-toolbar-ui for="<table-id>">` binds to a sibling `table-ui` (or the first `table-ui`
sibling within the same parent, when `[for]` is omitted) and renders title + count, and
search/filter/sort/columns-visibility controls — all four **default ON**, opted OUT individually
via `[no-search]`/`[no-filter]`/`[no-sort]`/`[no-columns]`. It auto-wires its own state changes
into the bound table; don't hand-wire `search`/`filter-change`/`sort-change`/`columns-change`
except to mirror state elsewhere (URL, persistence, analytics). Place it ABOVE the `card-ui`
containing the table (not inside the card's own `header`, which would double the chrome row), or
set `[variant="card"]` when the toolbar stands alone.

```html
<table-toolbar-ui for="members" text="All Employees" count="32"></table-toolbar-ui>
<card-ui>
  <section bleed>
    <table-ui id="members" sortable></table-ui>
  </section>
</card-ui>
```

No `[raw]` here — this table still renders from `.columns`/`.data`; `<section bleed>` alone is
what makes it sit edge-to-edge inside the card (see the SKILL.md contained/uncontained section).

## CSV export

`el.exportCSV(filename?)` exports the currently search/filter/sort-processed rows (respects
`col.format` / the cell type's own `format`, falling back to `String(value)`), triggers a
browser download, and needs no extra wiring.

## Keyboard navigation

`table-ui` implements a WAI-ARIA grid pattern out of the box: arrow keys move a virtual focus
cell (including into the header, row `-1`), Tab/Shift+Tab move linearly with row wraparound,
Enter on a header cell triggers its sort click, Enter on a body cell replays the same
click → `cell-click`/`row-click` pipeline a mouse click would — consumers get one event path
regardless of input device.
