# Table

## Usage

`Table` renders from `data` and `columns`. Each column has a `key`, a `header`, and an optional `width` from `proportional()` or `pixel()`. A cell shows `row[key]` unless `renderCell` returns something else. `idKey` identifies a row. The default is `id`.

Behavior is added with plugins, composed in the `plugins` object:

- `useTableSortable` cycles a header through ascending, descending, and unsorted. Shift-click adds a column when `isMultiSortEnabled` is set. `useTableSortableState` holds the sort and returns the sorted rows.
- `useTableSelection` adds a checkbox column and highlights selected rows. `useTableSelectionState` holds the selected ids. Pass its `selectionState` to `TableSelectionToolbar` to run bulk actions on the selection; the bar stays hidden until something is selected. Set `hasRowHighlight` to `false` when the row background already means something else. Checked rows stay `aria-selected`.
- `useTablePagination` renders a pager under the table. Slice the rows with `paginateData` before passing `data`.
- `useTableColumnSettings` shows only the active column keys, and keeps `isAlwaysVisible` columns on.
- `useTableColumnResize` lets a header edge be dragged. The smallest width is 60px.
- `useTableFiltering` puts a string or enum filter on any column whose `filter` names a field. `applyTableFilters` returns the matching rows. String fields match a substring. Enum fields match exactly. This filtering stays separate from Power Search.
- `useTableGroupedRows` turns the rows you already filtered, sorted, and sliced into sections. Pass its data, plugin, and row identity back to the table together. A collapsed section keeps its header. Replace the collapsed set instead of mutating it.
- `useTableTreeState` decides which nodes are open. `useTableTreeData` draws the indent and the buttons. Child rows reuse the parent columns. The header button expands or collapses every branch, and a cycle is skipped.
- `useTableRowExpansion` places a detail panel under a row. The same action is on the row’s context menu. `hasRowClickExpansion` also toggles from a click on the row, and leaves buttons, links, editable text, and an existing text selection alone. The panel lines up with the first column and is not counted as a striped row. Use the tree plugins when the children should reuse the columns.
- `useTableStickyColumns` pins the contiguous run from the first column through a listed key, or from a listed key through the last column. The shadow appears only while the table overflows. An empty config pins nothing.
- `useTableRowStatus` puts a success, warning, or error mark at the start of a row. A custom color can be a named color or a plain dot.
- `useTableRowIndex` numbers real rows. Group headers do not take a number.

A column with no width stays at least 60px wide. A `style.minWidth` smaller than the sum of those floors is raised so the columns stay readable; a larger value is kept. A table that fits does not trap the scroll chain. Horizontal overscroll is contained only while the table actually overflows.

`density` is `compact`, `balanced`, or `spacious`. `dividers` is `rows`, `columns`, `grid`, or `none`. `isStriped` and `hasHover` are independent. An empty `data` array renders the localized empty message, or the `empty` slot.

Without `data`, the table is composed from `TableHeader`, `TableBody`, `TableFooter`, `TableRow`, `TableHeaderCell`, and `TableCell`.

```vue
<script setup lang="ts">
import { proportional, Table } from '@astryx-vue/core/Table'

const rows = [
  { id: '1', name: 'Alice Johnson', email: 'alice@example.com' },
]
const columns = [
  { key: 'name', header: 'Name', width: proportional(1) },
  { key: 'email', header: 'Email', width: proportional(2) },
]
</script>

<template>
  <Table :data="rows" :columns="columns" has-hover />
</template>
```

Pass the column filter state through a getter so the table reads the latest value:

```ts
const { filters, onFilterChange } = useTableFilterState()
const filterPlugin = useTableFiltering(() => ({
  filters: filters.value,
  onFilterChange,
  fields,
}))
```

`rowCount` and `rowIndexStart` report where the visible window sits in the full result. Setting either one numbers the data rows. When only the start is set, the total count is unknown. Group headers and detail panels are not numbered.

### Anatomy

- **Table**: Semantic table element that groups the table sections, rows, and cells.
- **Scroll region**: Outer region that scrolls horizontally, enters the keyboard order, and contains overscroll only while the columns overflow.
- **Header section**: Column-heading section generated when data-driven columns are present, or supplied with TableHeader when composing the table from slots.
- **Column header cell**: Cell that identifies one column and may contain sorting or bulk-selection controls.
- **Sort control**: Button that wraps a sortable column label and changes that column's sort direction.
- **Sort indicator glyph**: Directional symbol rendered by Icon inside a Sort control.
- **Sort priority**: Number shown for a sorted column when multi-column sorting is active.
- **Selection control**: CheckboxInput rendered in the header and selectable body rows by the selection plugin.
- **Body section**: Section containing data rows or the current empty state; data-driven mode renders it automatically.
- **Row**: Repeated TableRow that groups cells in a standard header, body, or footer row.
- **Cell**: TableCell containing one value or caller-provided content in a standard body or footer row.
- **Default empty state**: Compact EmptyState shown for an empty data array unless it is replaced or disabled.
- **Expansion control**: Button in a leading cell that expands or collapses one expandable row.
- **Expansion glyph**: Directional symbol rendered by Icon inside an Expansion control.
- **Expanded detail panel**: Detail row and spanning cell rendered below an expanded row around caller-provided content.
- **Footer section**: Optional summary or totals section supplied with TableFooter when composing the table from slots.

## Best practices

| Guidance | Practices |
| --- | --- |
| Do | Give every row a stable `idKey`. Selection and row identity depend on it. |
| Do | Use `pixel()` for short numeric columns and `proportional()` for text that should share the remaining width. |
| Do | Keep sort, selection, and filter state outside the table and pass the derived rows in `data`. |
| Don't | Sort or paginate inside `renderCell`. The cell only paints the row it is given. |
| Don't | Turn on every plugin in one table. A dense grid rarely also needs filters, resize, and selection at once. |

## Examples

### Table — Column Settings

Table with a column visibility picker in the toolbar. Toggle columns on and off.

```vue
<script setup lang="ts">
import type { ColumnSettingsOption, TableColumn } from '@astryx-vue/core/Table'
import { MultiSelector } from '@astryx-vue/core/MultiSelector'
import { Stack } from '@astryx-vue/core/Stack'
import { proportional, Table, useTableColumnSettings, useTableColumnSettingsState } from '@astryx-vue/core/Table'
import { Text } from '@astryx-vue/core/Text'
import { Toolbar } from '@astryx-vue/core/Toolbar'
import { ref } from 'vue'

interface User extends Record<string, unknown> {
  id: string
  name: string
  email: string
  role: string
  department: string
  status: string
}

const users: User[] = [
  { id: '1', name: 'Alice Johnson', email: 'alice@example.com', role: 'Engineer', department: 'Platform', status: 'Active' },
  { id: '2', name: 'Bob Smith', email: 'bob@example.com', role: 'Designer', department: 'Product', status: 'Active' },
  { id: '3', name: 'Charlie Brown', email: 'charlie@example.com', role: 'Manager', department: 'Platform', status: 'Away' },
  { id: '4', name: 'Diana Prince', email: 'diana@example.com', role: 'Engineer', department: 'Infra', status: 'Active' },
  { id: '5', name: 'Eve Davis', email: 'eve@example.com', role: 'Admin', department: 'Operations', status: 'Inactive' },
]

const allColumns: TableColumn<User>[] = [
  { key: 'name', header: 'Name', width: proportional(1) },
  { key: 'email', header: 'Email', width: proportional(2) },
  { key: 'role', header: 'Role', width: proportional(1) },
  { key: 'department', header: 'Department', width: proportional(1) },
  { key: 'status', header: 'Status', width: proportional(1) },
]

const columnOptions: ColumnSettingsOption<string>[] = [
  { key: 'name', label: 'Name', isAlwaysVisible: true },
  { key: 'email', label: 'Email' },
  { key: 'role', label: 'Role' },
  { key: 'department', label: 'Department' },
  { key: 'status', label: 'Status' },
]

const activeKeys = ref(columnOptions.map(column => column.key))
const state = useTableColumnSettingsState<string>({
  columns: columnOptions,
  activeColumnKeys: activeKeys,
  onChangeActiveColumnKeys(keys) {
    activeKeys.value = [...keys]
  },
})
const plugin = useTableColumnSettings<User>(state.columnSettingsConfig)
const selectorOptions = columnOptions.map(column => ({
  value: column.key,
  label: column.label,
  disabled: column.isAlwaysVisible === true,
}))

function onColumnsChange(keys: (string | number)[]) {
  state.setActiveColumnKeys(keys.map(String))
}
</script>

<template>
  <Stack direction="vertical" :gap="3" width="100%">
    <Toolbar label="Table actions">
      <template #start>
        <Text type="label">
          Team
        </Text>
      </template>
      <template #end>
        <MultiSelector
          label="Columns"
          label-hidden
          :options="selectorOptions"
          :model-value="[...state.activeColumnKeys]"
          @update:model-value="onColumnsChange"
        />
      </template>
    </Toolbar>
    <Table
      :data="users"
      :columns="allColumns"
      id-key="id"
      has-hover
      :plugins="{ columnSettings: plugin }"
    />
  </Stack>
</template>
```

### Table — Popover Filters

Table with popover filter controls triggered by icons in column headers.

```vue
<script setup lang="ts">
import type { TableColumn, TableFilterField } from '@astryx-vue/core/Table'
import { applyTableFilters, proportional, Table, useTableFiltering, useTableFilterState } from '@astryx-vue/core/Table'
import { computed } from 'vue'

interface Employee extends Record<string, unknown> {
  id: string
  name: string
  email: string
  role: string
  department: string
}

const employees: Employee[] = [
  { id: '1', name: 'Alice', email: 'alice@example.com', role: 'Engineer', department: 'Platform' },
  { id: '2', name: 'Bob', email: 'bob@example.com', role: 'Designer', department: 'Product' },
  { id: '3', name: 'Charlie', email: 'charlie@example.com', role: 'Manager', department: 'Platform' },
  { id: '4', name: 'Diana', email: 'diana@example.com', role: 'Engineer', department: 'Infrastructure' },
  { id: '5', name: 'Eve', email: 'eve@example.com', role: 'Admin', department: 'Operations' },
]

const fields: TableFilterField[] = [
  { key: 'name', type: 'string', label: 'Name' },
  { key: 'email', type: 'string', label: 'Email' },
  {
    key: 'role',
    type: 'enum',
    label: 'Role',
    options: [
      { value: 'Engineer', label: 'Engineer' },
      { value: 'Designer', label: 'Designer' },
      { value: 'Manager', label: 'Manager' },
      { value: 'Admin', label: 'Admin' },
    ],
  },
]

const columns: TableColumn<Employee>[] = [
  { key: 'name', header: 'Name', width: proportional(1), filter: 'name' },
  { key: 'email', header: 'Email', width: proportional(2), filter: 'email' },
  { key: 'role', header: 'Role', width: proportional(1), filter: 'role' },
  { key: 'department', header: 'Department', width: proportional(1) },
]

const { filters, onFilterChange } = useTableFilterState()
const filterPlugin = useTableFiltering<Employee>(() => ({
  filters: filters.value,
  onFilterChange,
  fields,
}))
const rows = computed(() => applyTableFilters(employees, filters.value, fields))
</script>

<template>
  <Table
    :data="rows"
    :columns="columns"
    id-key="id"
    :plugins="{ filter: filterPlugin }"
  />
</template>
```

### Table — Grid Dividers

Compact table with grid dividers showing both row and column borders, suited for dense numeric data.

```vue
<script setup lang="ts">
import type { TableColumn } from '@astryx-vue/core/Table'
import { pixel, proportional, Table } from '@astryx-vue/core/Table'

interface Metric extends Record<string, unknown> {
  id: string
  metric: string
  q1: string
  q2: string
  q3: string
  q4: string
}

const metrics: Metric[] = [
  { id: '1', metric: 'Revenue', q1: '$1.2M', q2: '$1.4M', q3: '$1.3M', q4: '$1.8M' },
  { id: '2', metric: 'Users', q1: '12,400', q2: '14,200', q3: '15,800', q4: '18,100' },
  { id: '3', metric: 'Retention', q1: '82%', q2: '85%', q3: '84%', q4: '88%' },
  { id: '4', metric: 'NPS', q1: '42', q2: '45', q3: '48', q4: '51' },
  { id: '5', metric: 'CSAT', q1: '4.2', q2: '4.3', q3: '4.5', q4: '4.6' },
]

const columns: TableColumn<Metric>[] = [
  { key: 'metric', header: 'Metric', width: proportional(2) },
  { key: 'q1', header: 'Q1', width: pixel(90), align: 'end' },
  { key: 'q2', header: 'Q2', width: pixel(90), align: 'end' },
  { key: 'q3', header: 'Q3', width: pixel(90), align: 'end' },
  { key: 'q4', header: 'Q4', width: pixel(90), align: 'end' },
]
</script>

<template>
  <Table
    :data="metrics"
    :columns="columns"
    id-key="id"
    dividers="grid"
    density="compact"
  />
</template>
```

### Table — In Card

Table composed inside a card with a heading, demonstrating container bleed alignment.

```vue
<script setup lang="ts">
import type { TableColumn } from '@astryx-vue/core/Table'
import { Card } from '@astryx-vue/core/Card'
import { Heading } from '@astryx-vue/core/Heading'
import { Stack } from '@astryx-vue/core/Stack'
import { proportional, Table } from '@astryx-vue/core/Table'

interface User extends Record<string, unknown> {
  id: string
  name: string
  role: string
  email: string
}

const users: User[] = [
  { id: '1', name: 'Alice Johnson', role: 'Engineer', email: 'alice@example.com' },
  { id: '2', name: 'Bob Smith', role: 'Designer', email: 'bob@example.com' },
  { id: '3', name: 'Charlie Brown', role: 'PM', email: 'charlie@example.com' },
  { id: '4', name: 'Diana Prince', role: 'Engineer', email: 'diana@example.com' },
]

const columns: TableColumn<User>[] = [
  { key: 'name', header: 'Name', width: proportional(1) },
  { key: 'role', header: 'Role', width: proportional(1) },
  { key: 'email', header: 'Email', width: proportional(2) },
]
</script>

<template>
  <Card :width="520">
    <Stack direction="vertical" :gap="3">
      <Heading :level="3">
        Team Members
      </Heading>
      <Table :data="users" :columns="columns" id-key="id" has-hover />
    </Stack>
  </Card>
</template>
```

### Table — Inline Filters

Table with inline filter controls rendered directly below each column header.

```vue
<script setup lang="ts">
import type { TableColumn, TableFilterField } from '@astryx-vue/core/Table'
import { applyTableFilters, proportional, Table, useTableFiltering, useTableFilterState } from '@astryx-vue/core/Table'
import { computed } from 'vue'

interface Employee extends Record<string, unknown> {
  id: string
  name: string
  email: string
  role: string
  department: string
}

const employees: Employee[] = [
  { id: '1', name: 'Alice Johnson', email: 'alice@example.com', role: 'Engineer', department: 'Platform' },
  { id: '2', name: 'Bob Smith', email: 'bob@example.com', role: 'Designer', department: 'Product' },
  { id: '3', name: 'Charlie Brown', email: 'charlie@example.com', role: 'Manager', department: 'Platform' },
  { id: '4', name: 'Diana Prince', email: 'diana@example.com', role: 'Engineer', department: 'Infra' },
  { id: '5', name: 'Eve Davis', email: 'eve@example.com', role: 'Admin', department: 'Operations' },
]

const fields: TableFilterField[] = [
  { key: 'name', type: 'string', label: 'Name' },
  { key: 'email', type: 'string', label: 'Email' },
  {
    key: 'role',
    type: 'enum',
    label: 'Role',
    options: [
      { value: 'Engineer', label: 'Engineer' },
      { value: 'Designer', label: 'Designer' },
      { value: 'Manager', label: 'Manager' },
      { value: 'Admin', label: 'Admin' },
    ],
  },
]

const columns: TableColumn<Employee>[] = [
  { key: 'name', header: 'Name', width: proportional(1), filter: 'name' },
  { key: 'email', header: 'Email', width: proportional(2), filter: 'email' },
  { key: 'role', header: 'Role', width: proportional(1), filter: 'role' },
  { key: 'department', header: 'Department', width: proportional(1) },
]

const { filters, onFilterChange } = useTableFilterState()
const filterPlugin = useTableFiltering<Employee>(() => ({
  filters: filters.value,
  onFilterChange,
  fields,
  variant: 'inline',
}))
const rows = computed(() => applyTableFilters(employees, filters.value, fields))
</script>

<template>
  <Table
    :data="rows"
    :columns="columns"
    id-key="id"
    :plugins="{ filter: filterPlugin }"
  />
</template>
```

### Table — Paginated Data

Paginated data table navigating through a larger dataset page by page.

```vue
<script setup lang="ts">
import type { TableColumn } from '@astryx-vue/core/Table'
import { paginateData, proportional, Table, useTablePagination } from '@astryx-vue/core/Table'
import { computed, ref } from 'vue'

interface User extends Record<string, unknown> {
  id: string
  name: string
  email: string
  role: string
}

const names = [
  'Alice Johnson',
  'Bob Smith',
  'Charlie Brown',
  'Diana Prince',
  'Eve Davis',
  'Frank Miller',
  'Grace Lee',
  'Hank Wilson',
  'Ivy Chen',
  'Jack Turner',
  'Karen White',
  'Leo Garcia',
  'Mia Thompson',
  'Noah Martinez',
  'Olivia Clark',
  'Paul Harris',
  'Quinn Walker',
  'Rachel Adams',
  'Sam Robinson',
  'Tina Scott',
]
const roles = ['Engineer', 'Designer', 'Manager', 'Admin', 'Analyst']
const users: User[] = names.map((name, index) => ({
  id: String(index + 1),
  name,
  email: `${name.split(' ')[0]!.toLowerCase()}@example.com`,
  role: roles[index % roles.length]!,
}))

const columns: TableColumn<User>[] = [
  { key: 'name', header: 'Name', width: proportional(1) },
  { key: 'email', header: 'Email', width: proportional(2) },
  { key: 'role', header: 'Role', width: proportional(1) },
]

const page = ref(1)
const pageSize = 5
const plugin = useTablePagination<User>(() => ({
  page: page.value,
  onPageChange(next) {
    page.value = next
  },
  totalItems: users.length,
  pageSize,
}))
const rows = computed(() => paginateData(users, page.value, pageSize))
</script>

<template>
  <Table
    :data="rows"
    :columns="columns"
    id-key="id"
    :plugins="{ pagination: plugin }"
  />
</template>
```

### Table — Resizable Columns

Table with draggable column resize handles. Drag the right edge of any header to resize.

```vue
<script setup lang="ts">
import type { TableColumn } from '@astryx-vue/core/Table'
import { pixel, proportional, Table, useTableColumnResize } from '@astryx-vue/core/Table'
import { ref } from 'vue'

interface User extends Record<string, unknown> {
  id: string
  name: string
  email: string
  role: string
}

const users: User[] = [
  { id: '1', name: 'Alice Johnson', email: 'alice@example.com', role: 'Engineer' },
  { id: '2', name: 'Bob Smith', email: 'bob@example.com', role: 'Designer' },
  { id: '3', name: 'Charlie Brown', email: 'charlie@example.com', role: 'PM' },
  { id: '4', name: 'Diana Prince', email: 'diana@example.com', role: 'Engineer' },
  { id: '5', name: 'Eve Davis', email: 'eve@example.com', role: 'Analyst' },
]

const columns: TableColumn<User>[] = [
  { key: 'name', header: 'Name' },
  { key: 'email', header: 'Email', width: proportional(2) },
  { key: 'role', header: 'Role', width: pixel(120) },
]

const columnWidths = ref<Record<string, number>>({})
const resizePlugin = useTableColumnResize<User>(() => ({
  columnWidths: columnWidths.value,
  onColumnResizeEnd: (updates) => {
    columnWidths.value = { ...columnWidths.value, ...updates }
  },
}))
</script>

<template>
  <Table
    :data="users"
    :columns="columns"
    id-key="id"
    has-hover
    :plugins="{ columnResize: resizePlugin }"
  />
</template>
```

### Table — Rich Cell Content

Table with rich cell content using Link for emails and Badge for role labels.

```vue
<script setup lang="ts">
import type { TableColumn } from '@astryx-vue/core/Table'
import { Badge } from '@astryx-vue/core/Badge'
import { Link } from '@astryx-vue/core/Link'
import { pixel, proportional, Table } from '@astryx-vue/core/Table'
import { h } from 'vue'

interface User extends Record<string, unknown> {
  id: string
  name: string
  email: string
  role: string
  age: number
}

const users: User[] = [
  { id: '1', name: 'Alice Johnson', email: 'alice@example.com', role: 'Engineer', age: 30 },
  { id: '2', name: 'Bob Smith', email: 'bob@example.com', role: 'Designer', age: 25 },
  { id: '3', name: 'Charlie Brown', email: 'charlie@example.com', role: 'PM', age: 35 },
  { id: '4', name: 'Diana Prince', email: 'diana@example.com', role: 'Engineer', age: 28 },
  { id: '5', name: 'Eve Davis', email: 'eve@example.com', role: 'Designer', age: 32 },
]

const roleVariant: Record<string, 'blue' | 'purple' | 'green'> = {
  Engineer: 'blue',
  Designer: 'purple',
  PM: 'green',
}

const columns: TableColumn<User>[] = [
  { key: 'name', header: 'Name' },
  {
    key: 'email',
    header: 'Email',
    width: proportional(2),
    renderCell: item => h(Link, { href: `mailto:${item.email}` }, () => String(item.email)),
  },
  {
    key: 'role',
    header: 'Role',
    renderCell: item => h(Badge, { variant: roleVariant[item.role] ?? 'neutral' }, () => item.role),
  },
  { key: 'age', header: 'Age', width: pixel(80) },
]
</script>

<template>
  <Table :data="users" :columns="columns" id-key="id" has-hover />
</template>
```

### Table — Row Selection

Table with row selection checkboxes and a select-all header checkbox.

```vue
<script setup lang="ts">
import type { TableColumn } from '@astryx-vue/core/Table'
import { proportional, Table, useTableSelection, useTableSelectionState } from '@astryx-vue/core/Table'
import { ref } from 'vue'

interface User extends Record<string, unknown> {
  id: string
  name: string
  email: string
  role: string
}

const users: User[] = [
  { id: '1', name: 'Alice', email: 'alice@example.com', role: 'Engineer' },
  { id: '2', name: 'Bob', email: 'bob@example.com', role: 'Designer' },
  { id: '3', name: 'Charlie', email: 'charlie@example.com', role: 'Manager' },
  { id: '4', name: 'Diana', email: 'diana@example.com', role: 'Engineer' },
  { id: '5', name: 'Eve', email: 'eve@example.com', role: 'Admin' },
]

const columns: TableColumn<User>[] = [
  { key: 'name', header: 'Name', width: proportional(1) },
  { key: 'email', header: 'Email', width: proportional(2) },
  { key: 'role', header: 'Role', width: proportional(1) },
]

const selectedKeys = ref(new Set<string>())
const { selectionConfig } = useTableSelectionState<User>({
  data: users,
  idKey: 'id',
  selectedKeys,
  setSelectedKeys(keys) {
    selectedKeys.value = keys
  },
})
const selectionPlugin = useTableSelection<User>(selectionConfig)
</script>

<template>
  <Table
    :data="users"
    :columns="columns"
    id-key="id"
    :plugins="{ selection: selectionPlugin }"
  />
</template>
```

### Table — Sortable Columns

Table with sortable columns, click headers to sort ascending or descending.

```vue
<script setup lang="ts">
import type { TableColumn } from '@astryx-vue/core/Table'
import { pixel, proportional, Table, useTableSortable, useTableSortableState } from '@astryx-vue/core/Table'

interface Employee extends Record<string, unknown> {
  id: string
  name: string
  email: string
  role: string
  age: number
}

const employees: Employee[] = [
  { id: '1', name: 'Alice', email: 'alice@example.com', role: 'Engineer', age: 32 },
  { id: '2', name: 'Bob', email: 'bob@example.com', role: 'Designer', age: 28 },
  { id: '3', name: 'Charlie', email: 'charlie@example.com', role: 'Manager', age: 45 },
  { id: '4', name: 'Diana', email: 'diana@example.com', role: 'Engineer', age: 37 },
  { id: '5', name: 'Eve', email: 'eve@example.com', role: 'Admin', age: 29 },
]

const columns: TableColumn<Employee>[] = [
  { key: 'name', header: 'Name', width: proportional(1), sortable: true },
  { key: 'email', header: 'Email', width: proportional(2), sortable: true },
  { key: 'role', header: 'Role', width: proportional(1), sortable: true },
  { key: 'age', header: 'Age', width: pixel(80), sortable: true },
]

const { sortedData, sortConfig } = useTableSortableState<Employee>({
  data: employees,
  defaultSort: [{ sortKey: 'name', direction: 'ascending' }],
})
const sortablePlugin = useTableSortable<Employee>(sortConfig)
</script>

<template>
  <Table
    :data="sortedData"
    :columns="columns"
    id-key="id"
    :plugins="{ sortable: sortablePlugin }"
  />
</template>
```

### Table — Striped Rows

Table with alternating row colors and hover highlighting for easy scanning.

```vue
<script setup lang="ts">
import type { TableColumn } from '@astryx-vue/core/Table'
import { pixel, proportional, Table } from '@astryx-vue/core/Table'

interface User extends Record<string, unknown> {
  id: string
  name: string
  email: string
  role: string
  age: number
}

const users: User[] = [
  { id: '1', name: 'Alice Johnson', email: 'alice@example.com', role: 'Engineer', age: 30 },
  { id: '2', name: 'Bob Smith', email: 'bob@example.com', role: 'Designer', age: 25 },
  { id: '3', name: 'Charlie Brown', email: 'charlie@example.com', role: 'PM', age: 35 },
  { id: '4', name: 'Diana Prince', email: 'diana@example.com', role: 'Engineer', age: 28 },
  { id: '5', name: 'Eve Davis', email: 'eve@example.com', role: 'Designer', age: 32 },
]

const columns: TableColumn<User>[] = [
  { key: 'name', header: 'Name' },
  { key: 'email', header: 'Email', width: proportional(2) },
  { key: 'role', header: 'Role' },
  { key: 'age', header: 'Age', width: pixel(80) },
]
</script>

<template>
  <Table :data="users" :columns="columns" id-key="id" is-striped has-hover />
</template>
```

### Table — Grouped rows

Rows split by role. A collapsed section keeps its header, and the row numbers skip those headers.

```vue
<script setup lang="ts">
import type { TableColumn } from '@astryx-vue/core/Table'
import { pixel, proportional, Table, useTableGroupedRows, useTableRowIndex } from '@astryx-vue/core/Table'
import { ref } from 'vue'

interface Employee extends Record<string, unknown> {
  id: string
  name: string
  role: string
}

const employees: Employee[] = [
  { id: '1', name: 'Alice', role: 'Engineer' },
  { id: '2', name: 'Bob', role: 'Designer' },
  { id: '3', name: 'Carol', role: 'Engineer' },
  { id: '4', name: 'Diego', role: 'Designer' },
]

const columns: TableColumn<Employee>[] = [
  { key: 'name', header: 'Name', width: proportional(1) },
  { key: 'role', header: 'Role', width: pixel(140) },
]

const collapsed = ref(new Set<string>())

function toggleGroup(key: string) {
  const next = new Set(collapsed.value)
  if (next.has(key))
    next.delete(key)
  else
    next.add(key)
  collapsed.value = next
}

const { data: groupedRows, plugin: groupedPlugin, idKey } = useTableGroupedRows<Employee>(() => ({
  data: employees,
  groupBy: employee => employee.role,
  collapsedGroups: collapsed.value,
  onToggleGroup: toggleGroup,
  getRowKey: employee => employee.id,
  groupOrder: ['Designer', 'Engineer'],
}))

const numbers = useTableRowIndex<Employee>(() => ({
  data: groupedRows.value,
  getRowKey: employee => employee.id,
}))
</script>

<template>
  <Table
    :data="groupedRows"
    :columns="columns"
    :id-key="idKey"
    :plugins="{ grouped: groupedPlugin, numbers }"
  />
</template>
```

### Table — Tree data

Child rows reuse the same columns. The header expands every branch, and a row click expands a node that has children.

```vue
<script setup lang="ts">
import type { TableColumn } from '@astryx-vue/core/Table'
import { proportional, Table, useTableTreeState } from '@astryx-vue/core/Table'

interface Node extends Record<string, unknown> {
  id: string
  name: string
  owner: string
  children?: Node[]
}

const nodes: Node[] = [
  {
    id: 'platform',
    name: 'Platform',
    owner: 'Ada',
    children: [
      { id: 'api', name: 'API', owner: 'Grace' },
      {
        id: 'web',
        name: 'Web',
        owner: 'Lin',
        children: [
          { id: 'docs', name: 'Docs', owner: 'Maya' },
        ],
      },
    ],
  },
  { id: 'design', name: 'Design', owner: 'Noah' },
]

const columns: TableColumn<Node>[] = [
  { key: 'name', header: 'Name', width: proportional(2) },
  { key: 'owner', header: 'Owner', width: proportional(1) },
]

const { visibleData, plugin: treePlugin } = useTableTreeState<Node>({
  data: nodes,
  idKey: 'id',
  defaultExpandedIds: ['platform'],
  hasExpandAllControl: true,
  hasRowClickExpansion: true,
})
</script>

<template>
  <Table
    :data="visibleData"
    :columns="columns"
    id-key="id"
    has-hover
    :plugins="{ tree: treePlugin }"
  />
</template>
```

### Table — Row expansion

Open the note under a row from the chevron, the row, or the context menu.

```vue
<script setup lang="ts">
import type { TableColumn } from '@astryx-vue/core/Table'
import { pixel, proportional, Table, useTableRowExpansion } from '@astryx-vue/core/Table'
import { h, ref } from 'vue'

interface Order extends Record<string, unknown> {
  id: string
  customer: string
  total: string
  note: string
}

const orders: Order[] = [
  { id: '1042', customer: 'Northwind', total: '$240', note: 'Two seats, ships Friday.' },
  { id: '1043', customer: 'Contoso', total: '$80', note: 'Gift wrap requested.' },
  { id: '1044', customer: 'Fabrikam', total: '$560', note: 'Hold for pickup.' },
]

const columns: TableColumn<Order>[] = [
  { key: 'id', header: 'Order', width: pixel(100) },
  { key: 'customer', header: 'Customer', width: proportional(1) },
  { key: 'total', header: 'Total', width: pixel(100) },
]

const expanded = ref(new Set<string>(['1042']))

function toggle(key: string) {
  const next = new Set(expanded.value)
  if (next.has(key))
    next.delete(key)
  else
    next.add(key)
  expanded.value = next
}

const expansion = useTableRowExpansion<Order>(() => ({
  expandedKeys: expanded.value,
  onToggle: toggle,
  getRowKey: order => order.id,
  panelVariant: 'muted',
  hasRowClickExpansion: true,
  renderExpanded: order => h('p', order.note),
}))
</script>

<template>
  <Table
    :data="orders"
    :columns="columns"
    id-key="id"
    :plugins="{ expansion }"
  />
</template>
```

### Table — Sticky columns

Name stays on the left and Friday stays on the right while the days in between scroll.

```vue
<script setup lang="ts">
import type { TableColumn } from '@astryx-vue/core/Table'
import { pixel, Table, useTableStickyColumns } from '@astryx-vue/core/Table'

interface Shift extends Record<string, unknown> {
  id: string
  name: string
  monday: string
  tuesday: string
  wednesday: string
  thursday: string
  friday: string
}

const shifts: Shift[] = [
  { id: '1', name: 'Alice', monday: 'Desk', tuesday: 'Desk', wednesday: 'Remote', thursday: 'Desk', friday: 'Off' },
  { id: '2', name: 'Bob', monday: 'Remote', tuesday: 'Desk', wednesday: 'Desk', thursday: 'Remote', friday: 'Desk' },
  { id: '3', name: 'Carol', monday: 'Off', tuesday: 'Remote', wednesday: 'Desk', thursday: 'Desk', friday: 'Remote' },
]

const columns: TableColumn<Shift>[] = [
  { key: 'name', header: 'Name', width: pixel(140) },
  { key: 'monday', header: 'Monday', width: pixel(140) },
  { key: 'tuesday', header: 'Tuesday', width: pixel(140) },
  { key: 'wednesday', header: 'Wednesday', width: pixel(160) },
  { key: 'thursday', header: 'Thursday', width: pixel(140) },
  { key: 'friday', header: 'Friday', width: pixel(140) },
]

const sticky = useTableStickyColumns<Shift>({
  startKeys: ['name'],
  endKeys: ['friday'],
})
</script>

<template>
  <Table
    :data="shifts"
    :columns="columns"
    id-key="id"
    :plugins="{ sticky }"
  />
</template>
```

### Table — Row status

The mark at the start of a row is success, warning, error, or a dot with no signal.

```vue
<script setup lang="ts">
import type { TableColumn, TableRowStatus } from '@astryx-vue/core/Table'
import { proportional, Table, useTableRowStatus } from '@astryx-vue/core/Table'

interface Check extends Record<string, unknown> {
  id: string
  name: string
  detail: string
  state: 'success' | 'warning' | 'error' | 'neutral'
}

const checks: Check[] = [
  { id: '1', name: 'Payments', detail: 'Settled', state: 'success' },
  { id: '2', name: 'Inventory', detail: 'Low stock', state: 'warning' },
  { id: '3', name: 'Mail', detail: 'Bounced', state: 'error' },
  { id: '4', name: 'Notes', detail: 'No signal', state: 'neutral' },
]

const columns: TableColumn<Check>[] = [
  { key: 'name', header: 'Service', width: proportional(1) },
  { key: 'detail', header: 'Detail', width: proportional(1) },
]

function statusFor(check: Check): TableRowStatus | null {
  if (check.state === 'neutral')
    return { color: 'gray', label: 'No signal' }
  if (check.state === 'success')
    return { status: 'success', label: 'Settled' }
  if (check.state === 'warning')
    return { status: 'warning', label: 'Low stock' }
  return { status: 'error', label: 'Bounced' }
}

const status = useTableRowStatus<Check>({ getStatus: statusFor })
</script>

<template>
  <Table
    :data="checks"
    :columns="columns"
    id-key="id"
    :plugins="{ status }"
  />
</template>
```

## Props

### Table

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `data` | `T[]` | - | Rows. When omitted, the default slot composes the table by hand. |
| `columns` | `TableColumn[]` | - | `key`, `header`, `width`, `align`, `verticalAlign`, `sortable`, `filter`, and `renderCell`. |
| `idKey` | `string \| (row: T) => string` | `'id'` | Row identity. Grouped rows need a function so the synthetic headers stay stable. |
| `density` | `'compact' \| 'balanced' \| 'spacious'` | `'balanced'` | Cell padding. Compact is 4×8, balanced is 8×12, spacious is 12×16. |
| `dividers` | `'rows' \| 'columns' \| 'grid' \| 'none'` | `'rows'` | The header always keeps its bottom border. The last body row does not draw a row rule. |
| `isStriped` | `boolean` | `false` | Even body rows use the muted background. |
| `hasHover` | `boolean` | `false` | Hover fill, only when the pointer can hover. |
| `verticalAlign` | `'top' \| 'middle' \| 'bottom'` | `'middle'` | Default vertical alignment. A column can override it. |
| `textOverflow` | `'wrap' \| 'truncate'` | `'wrap'` | Whether cell text wraps or clips to one line. |
| `plugins` | `Record<string, TablePlugin>` | - | Sort, selection, pagination, column settings, resize, filtering, groups, trees, expansion, sticky columns, row status, and row numbers. |
| `label` | `string` | localized “Table” | Accessible name of the table. |
| `rowIndexStart` | `number` | `1` | Where the first visible data row sits in the full result. Setting this or the total count numbers the data rows. |
| `rowCount` | `number` | - | How many rows the full result has. With only a start index, the total is unknown. |

### useTableGroupedRows

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `data` | `T[]` | required | Rows after filtering, sorting, and paging. |
| `groupBy` | `(item: T) => string` | required | Which section a row belongs to. |
| `collapsedGroups` | `Set<string>` | required | Sections that are collapsed. Replace the set when it changes. |
| `onToggleGroup` | `(groupKey: string) => void` | required | Called from the header or its chevron. |
| `renderGroupHeader` | `(key, count, collapsed) => VNodeChild` | name and count | Content to the right of the chevron. |
| `getRowKey` | `(item: T) => string` | position | Stable identity for a real row. |
| `groupOrder` | `string[]` | first seen | Keys listed here come first. |

### useTableTreeState

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `data` | `T[]` | required | Nested rows. |
| `idKey` | `string \| (item) => string` | required | Node identity. It remembers expansion and skips a cycle. |
| `childrenKey` | `string` | `children` | Field that holds child rows. |
| `expandedIds` | `Set<string>` | - | Controlled expansion. Omit it and the state stays inside the composable. |
| `defaultExpandedIds` | `Iterable<string>` | empty | Nodes open on the first render. |
| `onExpandedIdsChange` | `(ids) => void` | - | Called when the open set changes. |
| `hasExpandAllControl` | `boolean` | `false` | Puts an expand-all button on the tree column header. |
| `hasRowClickExpansion` | `boolean` | `false` | A row click toggles a node that has children. Clicks on controls or a text selection do not. |
| `indent` | `'sm' \| 'md' \| 'lg'` | `md` | How far each level steps in. |
| `treeColumnKey` | `string` | first ordinary column | Column that shows the indent and chevron. |
| `sortSiblings` | `(siblings: T[]) => T[]` | - | Order for one level. |

### useTableRowExpansion

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `expandedKeys` | `Set<string>` | required | Rows that are open. The caller owns the set. |
| `onToggle` | `(key: string) => void` | required | Called from the chevron, a row click, or the context menu. |
| `getRowKey` | `(item: T) => string` | required | Row identity. |
| `renderExpanded` | `(item: T) => VNodeChild` | required | Detail content. It lines up with the first column, past the chevron. |
| `getIsItemExpandable` | `(item: T) => boolean` | every row | Which rows can open. |
| `panelVariant` | `'muted' \| 'transparent'` | `transparent` | Panel surface. Transparent continues the table surface. Muted separates the detail from the data. |
| `hasRowClickExpansion` | `boolean` | `false` | A row click toggles the panel. Clicks on controls or a text selection do not. |

### useTableStickyColumns

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `startKeys` | `string[]` | empty | Pin from the first column through the last listed key. |
| `endKeys` | `string[]` | empty | Pin from the first listed key through the last column. |

### useTableRowStatus

`getStatus` returns null for an empty cell. A semantic status draws the success, warning, or error icon. A custom status takes a color: an icon when one is given, otherwise a dot. The label is in the tooltip and in the accessible name.

### useTableRowIndex

`data` is the rows the table is painting, after grouping. `startFrom` begins at 1. `getRowKey` recognizes the same row; without it, identity is the object itself. `label` is the column header, and the default is a hash mark.

`proportional(value)` shares leftover width. Its default minimum is 120px. A column with no width is flexible with a 60px floor. `pixel(value)` sets both width and minimum width.

### TableSelectionToolbar

`count` is the number of selected rows. The `clear` event asks the caller to drop the selection. The default slot is the bulk actions. The count, the clear label, and the region name come from the table messages.

## Theming

Header text uses the label size, semibold weight, and the secondary text color. Body text uses the body size. A selected row uses the accent muted fill. Stripes use the muted background. Hover uses the hover overlay. Inside a card, the scroll area uses the card’s container padding so the first and last cells line up with the heading.
