# Table

> Accessible data table with sorting, row selection, expandable rows, column truncation, skeleton loading, and integrated pagination.

- Available in: `@sisyphos-ui/react`, `@sisyphos-ui/vue`, `@sisyphos-ui/angular`
- Docs: https://sisyphosui.com/docs/components/table

## Installation

Pick the framework binding that matches your stack:

```bash
pnpm add @sisyphos-ui/react   # React 18+
pnpm add @sisyphos-ui/vue     # Vue 3+
pnpm add @sisyphos-ui/angular # Angular 17+
```

## Import

```tsx
import "@sisyphos-ui/react/styles.css";
import { Table } from "@sisyphos-ui/react";
```

## Framework usage

### React 18+

```tsx
import { Table } from "@sisyphos-ui/react";
import type { TableColumn } from "@sisyphos-ui/react";

interface User { id: number; name: string; role: string; }

const data: User[] = [
  { id: 1, name: "Ada Lovelace", role: "Mathematician" },
  { id: 2, name: "Grace Hopper", role: "Compiler pioneer" },
];

const columns: TableColumn<User>[] = [
  { id: "name", header: "Name", accessor: "name", sortable: true },
  { id: "role", header: "Role", accessor: "role" },
];

export function People() {
  return <Table data={data} columns={columns} rowKey={(r) => r.id} striped />;
}
```

### Vue 3+

```vue
<script setup lang="ts">
import { Table } from "@sisyphos-ui/vue";
import type { TableColumn } from "@sisyphos-ui/vue";

interface User { id: number; name: string; role: string; }

const data: User[] = [
  { id: 1, name: "Ada Lovelace", role: "Mathematician" },
  { id: 2, name: "Grace Hopper", role: "Compiler pioneer" },
];
const columns: TableColumn<User>[] = [
  { id: "name", header: "Name", accessor: "name", sortable: true },
  { id: "role", header: "Role", accessor: "role" },
];
const rowKey = (r: User) => r.id;
</script>

<template>
  <Table :data="data" :columns="columns" :rowKey="rowKey" :striped="true" />
</template>
```

### Angular 17+

```ts
import { Component } from "@angular/core";
import { Table } from "@sisyphos-ui/angular";
import type { TableColumn } from "@sisyphos-ui/angular";

interface User { id: number; name: string; role: string; }

@Component({
  selector: "app-people",
  standalone: true,
  imports: [Table],
  template: `
    <sui-table
      [data]="data"
      [columns]="columns"
      [rowKey]="rowKey"
      [striped]="true"
    />
  `,
})
export class PeopleComponent {
  data: User[] = [
    { id: 1, name: "Ada Lovelace", role: "Mathematician" },
    { id: 2, name: "Grace Hopper", role: "Compiler pioneer" },
  ];
  columns: TableColumn<User>[] = [
    { id: "name", header: "Name", accessor: "name", sortable: true },
    { id: "role", header: "Role", accessor: "role" },
  ];
  rowKey = (r: User) => r.id;
}
```

## Examples

### Default

Pass `data` + `columns` — use `render` in a column for custom cells.

```tsx
import { Chip, Table } from "@sisyphos-ui/react";

interface User {
  id: number;
  name: string;
  role: string;
  status: "active" | "invited" | "paused";
}

const USERS: User[] = [
  { id: 1, name: "Ada Lovelace",   role: "Admin",    status: "active" },
  { id: 2, name: "Linus Torvalds", role: "Engineer", status: "invited" },
  { id: 3, name: "Grace Hopper",   role: "Engineer", status: "active" },
  { id: 4, name: "Alan Turing",    role: "Admin",    status: "paused" },
];

const STATUS_COLOR = {
  active:  "success",
  invited: "info",
  paused:  "warning",
} as const;

export function Example() {
  return (
    <Table
      data={USERS}
      columns={[
        { id: "name", header: "Name", accessor: "name", sortable: true },
        { id: "role", header: "Role", accessor: "role" },
        {
          id: "status",
          header: "Status",
          accessor: "status",
          render: (row) => (
            <Chip variant="soft" color={STATUS_COLOR[row.status]} size="sm">
              {row.status}
            </Chip>
          ),
        },
      ]}
    />
  );
}
```

### Sortable

Mark columns `sortable`, then control the sort state with `sort` + `onSortChange`.

```tsx
import { useState } from "react";
import { Table } from "@sisyphos-ui/react";

const USERS = [
  { id: 1, name: "Ada Lovelace", role: "Admin" },
  { id: 2, name: "Linus Torvalds", role: "Engineer" },
  { id: 3, name: "Grace Hopper", role: "Engineer" },
];

export function Example() {
  const [sort, setSort] = useState({ key: "name", direction: "asc" as const });

  const sorted = [...USERS].sort((a, b) => {
    const dir = sort.direction === "asc" ? 1 : -1;
    const k = sort.key as keyof typeof USERS[number];
    return String(a[k]).localeCompare(String(b[k])) * dir;
  });

  return (
    <Table
      data={sorted}
      sort={sort}
      onSortChange={(next) => next && setSort(next)}
      columns={[
        { id: "name", header: "Name", accessor: "name", sortable: true },
        { id: "role", header: "Role", accessor: "role", sortable: true },
      ]}
    />
  );
}
```

### Row selection

`selectable` shows a checkbox column. Controlled via `selectedIds` + `onSelectionChange`.

```tsx
import { useState } from "react";
import { Table } from "@sisyphos-ui/react";

const USERS = [
  { id: 1, name: "Ada Lovelace",   role: "Admin" },
  { id: 2, name: "Linus Torvalds", role: "Engineer" },
  { id: 3, name: "Grace Hopper",   role: "Engineer" },
];

export function Example() {
  const [selected, setSelected] = useState<(string | number)[]>([]);

  return (
    <Table
      data={USERS}
      rowKey={(row) => row.id}
      selectable
      selectedIds={selected}
      onSelectionChange={setSelected}
      columns={[
        { id: "name", header: "Name", accessor: "name" },
        { id: "role", header: "Role", accessor: "role" },
      ]}
    />
  );
}
```

### Dense & striped

`size="sm"` + `striped` + `hoverable` + `bordered`.

```tsx
import { Table } from "@sisyphos-ui/react";

const USERS = [
  { id: 1, name: "Ada Lovelace",   role: "Admin" },
  { id: 2, name: "Linus Torvalds", role: "Engineer" },
  { id: 3, name: "Grace Hopper",   role: "Engineer" },
];

export function Example() {
  return (
    <Table
      data={USERS}
      size="sm"
      striped
      hoverable
      bordered
      columns={[
        { id: "name", header: "Name", accessor: "name" },
        { id: "role", header: "Role", accessor: "role" },
      ]}
    />
  );
}
```

### Loading skeleton

`loading` renders `skeletonRows` placeholder rows instead of data.

```tsx
import { Table } from "@sisyphos-ui/react";

export function Example() {
  return (
    <Table
      data={[]}
      loading
      skeletonRows={4}
      columns={[
        { id: "name", header: "Name", accessor: "name" },
        { id: "role", header: "Role", accessor: "role" },
      ]}
    />
  );
}
```

### Empty state

Pass any ReactNode to `empty` — ideal spot for a primary call-to-action.

```tsx
import { Table } from "@sisyphos-ui/react";

export function Example() {
  return (
    <Table
      data={[]}
      empty={<div>No users yet. <button>Invite someone</button>.</div>}
      columns={[
        { id: "name", header: "Name", accessor: "name" },
        { id: "role", header: "Role", accessor: "role" },
      ]}
    />
  );
}
```

### With pagination

Slice your data by page, then render the companion `Pagination` component below the table. Add your own row-count summary alongside.

```tsx
import { useState } from "react";
import { Pagination, Table } from "@sisyphos-ui/react";

const PAGED_USERS = Array.from({ length: 48 }, (_, i) => ({
  id: i + 1,
  name: ["Ada","Linus","Grace","Alan","Rich","Margaret","Dennis","Ken","Barbara","Donald"][i % 10] + " " + (i + 1),
  role: i % 3 === 0 ? "Admin" : "Engineer",
  status: (["active", "invited", "paused"] as const)[i % 3],
}));

export function Example() {
  const [page, setPage] = useState(1);
  const pageSize = 10;
  const pageCount = Math.ceil(PAGED_USERS.length / pageSize);
  const slice = PAGED_USERS.slice((page - 1) * pageSize, page * pageSize);

  return (
    <div className="flex flex-col gap-3">
      <Table
        data={slice}
        rowKey={(r) => r.id}
        columns={[
          { id: "name", header: "Name", accessor: "name" },
          { id: "role", header: "Role", accessor: "role" },
          { id: "status", header: "Status", accessor: "status" },
        ]}
      />
      <div className="flex items-center justify-between">
        <span>
          Showing {(page - 1) * pageSize + 1}–
          {Math.min(page * pageSize, PAGED_USERS.length)} of {PAGED_USERS.length}
        </span>
        <Pagination page={page} pageCount={pageCount} onPageChange={setPage} />
      </div>
    </div>
  );
}
```

### With search

Filter rows externally and pass the result as `data`. A standalone `Input` + `empty` handles the zero-state message.

```tsx
import { useState } from "react";
import { Input, Table } from "@sisyphos-ui/react";

const PAGED_USERS = Array.from({ length: 48 }, (_, i) => ({
  id: i + 1,
  name: ["Ada","Linus","Grace","Alan","Rich","Margaret","Dennis","Ken","Barbara","Donald"][i % 10] + " " + (i + 1),
  role: i % 3 === 0 ? "Admin" : "Engineer",
  status: (["active", "invited", "paused"] as const)[i % 3],
}));

export function Example() {
  const [query, setQuery] = useState("");
  const filtered = PAGED_USERS.filter((r) =>
    r.name.toLowerCase().includes(query.toLowerCase())
  );

  return (
    <div className="flex flex-col gap-3">
      <Input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search by name…"
      />
      <Table
        data={filtered.slice(0, 8)}
        rowKey={(r) => r.id}
        empty={query ? `No matches for "${query}"` : "No teammates yet."}
        columns={[
          { id: "name", header: "Name", accessor: "name" },
          { id: "role", header: "Role", accessor: "role" },
          { id: "status", header: "Status", accessor: "status" },
        ]}
      />
    </div>
  );
}
```

### Row detail panel

`onRowClick` toggles an active row; render the detail panel anywhere in your layout — no built-in expandable API required.

```tsx
import { useState } from "react";
import { Table } from "@sisyphos-ui/react";

const USERS = [
  { id: 1, name: "Ada Lovelace",   role: "Admin" },
  { id: 2, name: "Linus Torvalds", role: "Engineer" },
  { id: 3, name: "Grace Hopper",   role: "Engineer" },
];

export function Example() {
  const [activeId, setActiveId] = useState<number | null>(1);
  const active = USERS.find((u) => u.id === activeId) ?? null;

  return (
    <div className="flex flex-col gap-3">
      <Table
        data={USERS}
        rowKey={(r) => r.id}
        hoverable
        onRowClick={(row) => setActiveId(row.id === activeId ? null : row.id)}
        columns={[
          { id: "name", header: "Name", accessor: "name" },
          { id: "role", header: "Role", accessor: "role" },
          { id: "status", header: "Status", accessor: "status" },
        ]}
      />
      {active && (
        <div>
          <strong>{active.name}</strong> — row detail
          <div>User ID: {active.id}</div>
          <div>Record: <code>{JSON.stringify(active)}</code></div>
        </div>
      )}
    </div>
  );
}
```

### Real-world composition

Search + sort + select + pagination in one table — the patterns compose cleanly without a bespoke table toolbar API.

```tsx
import { useState } from "react";
import { Input, Pagination, Table } from "@sisyphos-ui/react";

const PAGED_USERS = Array.from({ length: 48 }, (_, i) => ({
  id: i + 1,
  name: ["Ada","Linus","Grace","Alan","Rich","Margaret","Dennis","Ken","Barbara","Donald"][i % 10] + " " + (i + 1),
  role: i % 3 === 0 ? "Admin" : "Engineer",
  status: (["active", "invited", "paused"] as const)[i % 3],
}));

export function Example() {
  const [page, setPage] = useState(1);
  const pageSize = 5;
  const [query, setQuery] = useState("");
  const [selected, setSelected] = useState<Array<string | number>>([]);
  const [sort, setSort] = useState<{ key: string; direction: "asc" | "desc" } | null>({
    key: "name",
    direction: "asc",
  });

  const filtered = PAGED_USERS.filter((r) =>
    r.name.toLowerCase().includes(query.toLowerCase())
  );
  const sorted = [...filtered].sort((a, b) => {
    if (!sort) return 0;
    const dir = sort.direction === "asc" ? 1 : -1;
    return String(a[sort.key as keyof typeof a]).localeCompare(String(b[sort.key as keyof typeof b])) * dir;
  });
  const pageCount = Math.max(1, Math.ceil(sorted.length / pageSize));
  const slice = sorted.slice((page - 1) * pageSize, page * pageSize);

  return (
    <div className="flex flex-col gap-3">
      <div className="flex items-center justify-between gap-3">
        <Input
          value={query}
          onChange={(e) => { setQuery(e.target.value); setPage(1); }}
          placeholder="Search teammates…"
        />
        <span>
          {selected.length > 0 ? `${selected.length} selected` : `${sorted.length} teammates`}
        </span>
      </div>
      <Table
        data={slice}
        rowKey={(r) => r.id}
        selectable
        selectedIds={selected}
        onSelectionChange={setSelected}
        sort={sort ?? undefined}
        onSortChange={(next) => setSort(next ?? null)}
        empty={query ? `No results for "${query}"` : "No teammates yet."}
        columns={[
          { id: "name", header: "Name", accessor: "name", sortable: true },
          { id: "role", header: "Role", accessor: "role", sortable: true },
          { id: "status", header: "Status", accessor: "status" },
        ]}
      />
      <div className="flex items-center justify-between">
        <span>Page {page} of {pageCount}</span>
        <Pagination page={page} pageCount={pageCount} onPageChange={setPage} />
      </div>
    </div>
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| **data** (required) | `T[]` | — | Row data. |
| **columns** (required) | `TableColumn<T>[]` | — | Column definitions. |
| rowKey | `(row, index) => string \| number` | — | Returns a unique id for a row. Required for selection and expansion. |
| loading | `boolean` | `false` | Renders skeleton rows in place of data. |
| loadingDelay | `number` | `0` | Ms to wait before showing the skeleton — smooths flicker on fast loads. |
| skeletonRows | `number` | `5` | Skeleton row count while loading. |
| empty | `ReactNode` | `"No data"` | Content shown when `data` is empty. |
| selectable | `boolean` | `false` | Adds a checkbox column for row selection. |
| selectedIds | `(string \| number)[]` | — | Controlled selection ids. |
| onSelectionChange | `(ids) => void` | — | Called when the selection set changes. |
| rowSelectionMode | `"checkbox" \| "click" \| "doubleClick"` | `"checkbox"` | How a row toggles its selected state. |
| sort | `SortState \| null` | — | Controlled sort state ({ key, direction }). |
| onSortChange | `(sort) => void` | — | Called as sortable headers cycle asc → desc → cleared. |
| actions | `(row, index) => ReactNode` | — | Renders the rightmost actions cell per row. |
| onRowClick | `(row, index) => void` | — | Fires on row click (skips checkbox / actions cells). |
| onRowDoubleClick | `(row, index) => void` | — | Fires on row double-click — independent of selection. |
| onRowContextMenu | `(event, row, index) => void` | — | Fires on row right-click; useful for wiring a context menu. |
| rowClassName | `(row, index) => string \| undefined` | — | Returns an extra class for state-driven row highlighting. |
| expandable | `boolean` | `false` | Adds an expand chevron column with detail rows. |
| renderExpanded | `(row, index) => ReactNode` | — | Renderer for the expanded detail row. |
| searchable | `boolean` | `false` | Renders the built-in search input inside the toolbar. |
| filters | `TableFilterField[]` | — | Declarative filter controls rendered as a second toolbar row. |
| pagination | `TablePaginationConfig` | — | Renders a `<Pagination>` footer wired to the supplied config. |
| heightMode | `"auto" \| "flex" \| "content"` | `"auto"` | Whether the body fits content, fills remaining space, or scrolls. |
| stickyHeader | `boolean` | `false` | Pin the header row when scrolling a constrained container. |
| size | `"sm" \| "md" \| "lg"` | `"md"` | Density variant. |
| striped | `boolean` | `false` | Alternating-row background. |
| hoverable | `boolean` | `true` | Apply hover background on rows. |
| bordered | `boolean` | `false` | Outer border + cell borders. |

## Keyboard interactions

- **Enter + Space** — Activates the focused checkbox or expand button.
- **Tab** — Moves focus through interactive cells (sortable headers, checkboxes, action buttons).

## Accessibility notes

- `<thead>` cells use `aria-sort="ascending" | "descending" | "none"` when sortable.
- Selected rows expose `aria-selected="true"`.
- Header checkbox toggles tristate via the DOM `indeterminate` flag when only some rows are selected.
- Skeleton rows are decorative — they are not announced as data rows.

<!-- exports: { "Table": "@sisyphos-ui/react" } -->