# Table

> Rows of data with column headings, which people can sort: applicants, classes, invoices.

- Import: `import { Table } from '@thekestongroup/ui'`
- Group: Data
- Live example: https://ui.keston.co/storybook/?path=/story/components-data-table--default
- Source: https://github.com/thekestongroup/ui/blob/main/src/components/Table/Table.vue

## When to use

| Use a Table for | Use instead |
|---|---|
| Many records with the same fields, to compare or scan | One record's details (SummaryList) |
| Data people sort or look across by column | A few items with mixed content (a list in a Card) |
| Totals under a list of amounts | A single headline number (StatTile) |

## Anatomy

| Part | Required | Notes |
|---|---|---|
| scroll area | Yes | Scrolls sideways when the columns don't fit; up and down too with a max height. |
| caption | Recommended | Names the table. Hidden unless `showCaption`. |
| header row | Yes | Column headings, black 65%, bold. Sortable ones are buttons with a sort icon. |
| rows | Yes | One per record, with a line between. |
| row header | No | One column (usually the first, e.g. a name) can head its row. Bold. |
| footer | No | Rows in a `<tfoot>`, e.g. totals. Bold, with a line above. |
| empty / loading | No | One centred row when there are no rows. |

No border or background of its own: put it in a `Card` with `padding="none"`.

## Properties

| Property | Type | Default | Description |
|---|---|---|---|
| columns | TableColumn[] | — | Required. See Columns. |
| rows | object[] | — | Required. |
| rowKey | string \| (row, index) => key | index | A unique field, so rows stay stable when sorted. |
| caption | string | — | The table's accessible name. |
| showCaption | boolean | false | Show the caption above the table. |
| sort | { key, direction } \| null | null | `v-model:sort`. |
| manualSort | boolean | false | Only report the sort (e.g. to sort on the server); don't re-order rows. |
| size | md \| sm | md | See Sizes. |
| stickyHeader | boolean | false | Keep the headings in view. Give the table a max height. |
| loading | boolean | false | No rows: a spinner. With rows: rows dimmed to 50%. |
| emptyText | string | "Nothing to show." | Slot `empty` for richer content. |
| ui | { table, header, row } | — | Extra classes per part. |

## Columns

| Field | Type | Default | Description |
|---|---|---|---|
| key | string | — | Required. The field shown, and the slot name `cell-{key}`. |
| label | string | — | Required. Heading text. |
| hideLabel | boolean | false | Heading read out but not shown, e.g. an actions column. |
| align | start \| end | start | End for numbers and amounts, so digits line up (they also use tabular figures). |
| sortable | boolean | false | Heading becomes a sort button. |
| sortValue | (row) => value | `row[key]` | What to sort by, e.g. a date's timestamp. |
| rowHeader | boolean | false | Cells are row headings (`<th scope="row">`). One column at most. |
| nowrap | boolean | true | Keep cells on one line. Turn off for long text. |
| class | string | — | Extra classes for the column, e.g. a width. |

## Sizes

| Size | Cell padding | Row height (one line) |
|---|---|---|
| md | 16px sides, 12px top and bottom | 44px |
| sm | 16px sides, 8px top and bottom | 36px |

| Part | Value |
|---|---|
| Text | 14px / 20px; headings bold, black 65%; the sorted heading black |
| Sort icon | 16px, 4px after the label; unsorted black 40% |
| Lines | 1px black 15%, under the header and between rows (not after the last) |
| Empty / loading row | 32px top and bottom, centred, black 65% |

## Sorting

| Action | Result |
|---|---|
| Click a sortable heading | Ascending, then descending, then back to the original order |
| Text | Natural order ("Year 2" before "Year 10"), ignoring case |
| Empty values | Last, whichever direction |
| Ties | Keep their original order |

## States

| State | Appearance |
|---|---|
| Heading hover | Text goes from black 65% to black |
| Heading focused | 2px focus ring `#007FC9`, 1px outside (keyboard only) |
| Scroll area focused | The same ring, 2px inside the edge so a Card's corners don't clip it (only when it scrolls) |
| Loading, no rows | 16px spinner and "Loading…" |
| Loading, with rows | Rows at 50% opacity |

## Accessibility

| Part | Announced as |
|---|---|
| Table | `table`, named by its caption; `aria-busy` while loading |
| Sortable heading | `columnheader` with `aria-sort` (none / ascending / descending), containing a button named by the label |
| Row header cells | `rowheader`, so each cell is read with its row's name |
| Scroll area (when it scrolls) | `region` named by the caption, focusable so the keyboard can scroll it |
| Icon-only cells (e.g. a ⋯ menu) | Name the button with the row, e.g. "More for Amelia Clarke" |

## Platform adaptation

| Platform | Implementation | Differences |
|---|---|---|
| Web / desktop (Vue) | `Table.vue` | — |
| iOS (SwiftUI) | — | `Table` on iPad and Mac; a `List` of rows on iPhone. |
| Android (Compose) | — | A `LazyColumn` of rows; columns become label / value pairs on phones. |

## Tokens used

| Token | Used for |
|---|---|
| color black | text, sorted heading |
| color black (65%) | headings, empty text |
| color black (40%) | unsorted icon |
| color black (15%) | lines |
| color white | sticky header background |
| color focus | focus rings |

## Do / Don't

| Do | Don't |
|---|---|
| Give every table a caption | Rely on the surrounding heading alone |
| Right-align numbers and amounts | Centre-align columns |
| Make the name column the row header | Make a whole row clickable with no link or button in it |
| Use slots for Badges, links and menus | Put more than one or two actions in a row |

## API (from the source)

The component's exact props, model, events, slots and types, with their documentation comments.

```ts
// Table.vue
/**
 * Rows of data with column headings: applicants, classes, invoices.
 *
 * No border or background of its own: put it in a `<Card padding="none">`,
 * which gives the white panel, and the table runs to its edges.
 *
 * Cells show `row[column.key]` as text. For anything else (a Badge, a link,
 * a menu) use the slot `#cell-{key}`, which gets `{ row, value, index }`.
 *
 * Sorting: mark columns `sortable`. The table sorts the rows itself; with
 * `manualSort` it only reports the sort (`v-model:sort`) so you can sort on
 * the server.
 *
 * Slots: cell-{key}, header-{key}, empty, footer (rows for a `<tfoot>`,
 * e.g. totals), caption.
 */
export interface TableProps<Row = Record<string, any>> {
  columns: TableColumn<Row>[]
  rows: Row[]
  /** Unique field on each row, or a function returning one. Used to keep rows stable when sorting. */
  rowKey?: keyof Row & string | ((row: Row, index: number) => string | number)
  /**
   * What the table shows, e.g. "Applications for September 2027". The
   * table's accessible name. Hidden unless `showCaption`.
   */
  caption?: string
  showCaption?: boolean
  /** Shown when there are no rows. */
  emptyText?: string
  /** Show a spinner (no rows yet), or dim the rows (refreshing). */
  loading?: boolean
  /** md (default) 12px above and below each cell, sm 8px for dense tables. */
  size?: 'sm' | 'md'
  /** Keep the header row in view while the table scrolls. Give the table a max height (`class="max-h-96"`). */
  stickyHeader?: boolean
  /** Don't sort the rows: just update `sort`, e.g. to sort on the server. */
  manualSort?: boolean
  class?: HTMLAttributes['class']
  ui?: {
    table?: HTMLAttributes['class']
    header?: HTMLAttributes['class']
    row?: HTMLAttributes['class']
  }
}

const sort = defineModel<TableSort | null>('sort', { default: null })

defineSlots<
  {
    caption?(): unknown
    empty?(): unknown
    footer?(): unknown
  } & {
    [key: `cell-${string}`]: ((props: { row: Row; value: any; index: number }) => unknown) | undefined
  } & {
    [key: `header-${string}`]: ((props: { column: TableColumn<Row> }) => unknown) | undefined
  }
>()

// table.ts
export type SortDirection = 'asc' | 'desc'

export interface TableSort {
  key: string
  direction: SortDirection
}

export interface TableColumn<Row = Record<string, unknown>> {
  /** Field on each row, and the name of the cell slot (`#cell-{key}`). */
  key: string
  /** Header text. */
  label: string
  /**
   * Visually hide the header (it's still read out), e.g. for an actions
   * column. The label is still required.
   */
  hideLabel?: boolean
  /** start (default) or end. Use end for numbers, so the digits line up. */
  align?: 'start' | 'end'
  /** Let people sort by this column. */
  sortable?: boolean
  /** What to sort by, when it isn't `row[key]` (e.g. a date's timestamp). */
  sortValue?: (row: Row) => unknown
  /** The row's heading: its first cell is a `<th scope="row">`. One column at most. */
  rowHeader?: boolean
  /** Keep the text on one line. Default true; turn off for long text. */
  nowrap?: boolean
  /** Extra classes for this column's header and cells, e.g. a width. */
  class?: string
}

/**
 * Sorts a copy of `rows`. Stable, so rows that tie keep their order. Text
 * sorts naturally ("Year 2" before "Year 10") and ignores case; empty values
 * go last in both directions.
 */
export function sortRows<Row>(rows: readonly Row[], columns: readonly TableColumn<Row>[], sort?: TableSort | null): Row[] { … }

/** Clicking a header: ascending, then descending, then back to unsorted. */
export function nextSort(current: TableSort | null | undefined, key: string): TableSort | null { … }
```
