DataTable
A typed table with sorting, selection, row and bulk actions, inline editing, export and a card layout on phones.
<script setup lang="ts">
import type { ColumnDef, PaginationConfig, SortConfig } from '@shared/components/common/DataTable/types'
import DataTable from '@shared/components/common/DataTable/DataTable.vue'
import { computed, ref } from 'vue'
interface Project extends Record<string, unknown> {
id: number
name: string
client: string
budget: number
due: string
}
const PROJECTS: Project[] = [
{ id: 1, name: 'Warehouse rollout', client: 'Northwind', budget: 48000, due: '2026-04-10' },
{ id: 2, name: 'Checkout redesign', client: 'Lumen Retail', budget: 26500, due: '2026-03-28' },
{ id: 3, name: 'Driver app', client: 'Vellum Logistics', budget: 72000, due: '2026-06-01' },
{ id: 4, name: 'Billing migration', client: 'Acme Health', budget: 19000, due: '2026-02-14' },
{ id: 5, name: 'Partner portal', client: 'Northwind', budget: 54000, due: '2026-05-05' },
{ id: 6, name: 'Warehouse analytics', client: 'Vellum Logistics', budget: 31000, due: '2026-04-22' },
{ id: 7, name: 'Loyalty program', client: 'Lumen Retail', budget: 15500, due: '2026-07-09' },
{ id: 8, name: 'Claims intake', client: 'Acme Health', budget: 63000, due: '2026-05-30' },
]
const columns: ColumnDef<Project>[] = [
{ key: 'name', label: 'Project', sortable: true },
{ key: 'client', label: 'Client', sortable: true },
{ key: 'budget', label: 'Budget', sortable: true, align: 'right', render: value => `$${Number(value).toLocaleString('en-US')}` },
{ key: 'due', label: 'Due', sortable: true, hideOn: 'sm' },
]
const sort = ref<SortConfig<Project> | null>(null)
const pagination = ref<PaginationConfig>({ page: 1, perPage: 5, total: PROJECTS.length })
const sorted = computed(() => {
if (!sort.value)
return PROJECTS
const { key, direction } = sort.value
return [...PROJECTS].sort((a, b) => {
const left = a[key] as string | number
const right = b[key] as string | number
const order = left < right ? -1 : left > right ? 1 : 0
return direction === 'asc' ? order : -order
})
})
const rows = computed(() => {
const start = (pagination.value.page - 1) * pagination.value.perPage
return sorted.value.slice(start, start + pagination.value.perPage)
})
</script>
<template>
<DataTable
:columns="columns"
:data="rows"
:pagination="pagination"
row-key="id"
filename="projects"
@update:sort="sort = $event"
@update:pagination="pagination = $event"
/>
</template>Usage
Two props are required: columns and data. Everything else turns a piece on.
Pagination and sorting are controlled. The component never slices data: it shows exactly the rows you hand it and tells you when the reader asks for another page or another order. That is the same contract for a client-side list and a server-paginated one, so moving between them changes your fetch and nothing else.
rowKey names the property that identifies a row. It defaults to id, resolved at runtime.
For how the pieces fit together, and for SchemaTable, see Systems → DataTable.
Examples
Custom cells
render returns a string or a VNode and owns the whole cell, with no wrapper element around it. width, align and hideOn are per column.
<script setup lang="ts">
import type { ColumnDef } from '@shared/components/common/DataTable/types'
import DataTable from '@shared/components/common/DataTable/DataTable.vue'
import { h } from 'vue'
interface Project extends Record<string, unknown> {
id: number
name: string
stage: 'discovery' | 'build' | 'review' | 'shipped'
budget: number
}
const PROJECTS: Project[] = [
{ id: 1, name: 'Warehouse rollout', stage: 'build', budget: 48000 },
{ id: 2, name: 'Checkout redesign', stage: 'review', budget: 26500 },
{ id: 3, name: 'Driver app', stage: 'discovery', budget: 72000 },
{ id: 4, name: 'Billing migration', stage: 'shipped', budget: 19000 },
]
const STAGE_TONE: Record<Project['stage'], string> = {
discovery: 'bg-sky-50 text-sky-700 dark:bg-sky-500/15 dark:text-sky-400',
build: 'bg-amber-50 text-amber-700 dark:bg-amber-500/15 dark:text-amber-400',
review: 'bg-violet-50 text-violet-700 dark:bg-violet-500/15 dark:text-violet-400',
shipped: 'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-400',
}
const STAGE_LABEL: Record<Project['stage'], string> = {
discovery: 'Discovery',
build: 'Build',
review: 'Review',
shipped: 'Shipped',
}
const columns: ColumnDef<Project>[] = [
{ key: 'id', label: '#', width: '64px', align: 'center' },
{ key: 'name', label: 'Project' },
{
key: 'stage',
label: 'Stage',
render: (value) => {
const stage = value as Project['stage']
return h(
'span',
{ class: `inline-block rounded-full px-2 py-0.5 sa-text-xs font-medium ${STAGE_TONE[stage]}` },
STAGE_LABEL[stage],
)
},
},
{
key: 'budget',
label: 'Budget',
align: 'right',
render: value => `$${Number(value).toLocaleString('en-US')}`,
},
]
</script>
<template>
<DataTable :columns="columns" :data="PROJECTS" row-key="id" :toolbar="false" />
</template>Selection and bulk actions
selectable adds the checkbox column and the select-all header. update:selection carries row keys, not rows, which is what a bulk action needs and what stays correct on a server-paginated table where a row picked on page one is no longer in data by page three. BulkActionDef.handler receives the same keys.
<script setup lang="ts">
import type { BulkActionDef, ColumnDef } from '@shared/components/common/DataTable/types'
import DataTable from '@shared/components/common/DataTable/DataTable.vue'
import { Archive, Trash2 } from 'lucide-vue-next'
import { ref } from 'vue'
interface Project extends Record<string, unknown> {
id: number
name: string
client: string
}
const PROJECTS: Project[] = [
{ id: 1, name: 'Warehouse rollout', client: 'Northwind' },
{ id: 2, name: 'Checkout redesign', client: 'Lumen Retail' },
{ id: 3, name: 'Driver app', client: 'Vellum Logistics' },
{ id: 4, name: 'Billing migration', client: 'Acme Health' },
]
const columns: ColumnDef<Project>[] = [
{ key: 'name', label: 'Project' },
{ key: 'client', label: 'Client' },
]
const lastAction = ref('')
const bulkActions: BulkActionDef[] = [
{ label: 'Archive', icon: Archive, handler: keys => (lastAction.value = `Archived ${keys.join(', ')}`) },
{ label: 'Delete', icon: Trash2, variant: 'destructive', handler: keys => (lastAction.value = `Deleted ${keys.join(', ')}`) },
]
const selection = ref<unknown[]>([])
</script>
<template>
<div class="space-y-3">
<DataTable
:columns="columns"
:data="PROJECTS"
:bulk-actions="bulkActions"
selectable
row-key="id"
:toolbar="false"
@update:selection="selection = $event"
/>
<p class="text-sm text-muted-foreground">
{{ lastAction || `${selection.length} selected` }}
</p>
</div>
</template>Row actions
Two or fewer visible actions render as inline buttons; three or more collapse into an overflow menu. visibleIf is evaluated per row, so a table with four actions can still show inline buttons on a row where only one applies. actionsMenu forces the menu either way.
<script setup lang="ts">
import type { ActionDef, ColumnDef } from '@shared/components/common/DataTable/types'
import DataTable from '@shared/components/common/DataTable/DataTable.vue'
import { Eye, Pencil, Trash2, Undo2 } from 'lucide-vue-next'
import { ref } from 'vue'
interface Project extends Record<string, unknown> {
id: number
name: string
stage: 'build' | 'shipped'
}
const PROJECTS: Project[] = [
{ id: 1, name: 'Warehouse rollout', stage: 'build' },
{ id: 2, name: 'Billing migration', stage: 'shipped' },
{ id: 3, name: 'Partner portal', stage: 'build' },
]
const columns: ColumnDef<Project>[] = [
{ key: 'name', label: 'Project' },
{ key: 'stage', label: 'Stage' },
]
const log = ref('')
const actions: ActionDef<Project>[] = [
{ label: 'View', icon: Eye, handler: row => (log.value = `View ${row.name}`) },
{ label: 'Edit', icon: Pencil, handler: row => (log.value = `Edit ${row.name}`) },
{ label: 'Reopen', icon: Undo2, handler: row => (log.value = `Reopen ${row.name}`), visibleIf: row => row.stage === 'shipped' },
{ label: 'Delete', icon: Trash2, variant: 'destructive', handler: row => (log.value = `Delete ${row.name}`) },
]
</script>
<template>
<div class="space-y-3">
<DataTable :columns="columns" :data="PROJECTS" :actions="actions" row-key="id" :toolbar="false" />
<p class="text-sm text-muted-foreground">
{{ log || 'No action yet' }}
</p>
</div>
</template>Expandable rows
expandable prepends an arrow column and renders the #expanded-row slot beneath the row it belongs to.
<script setup lang="ts">
import type { ColumnDef } from '@shared/components/common/DataTable/types'
import DataTable from '@shared/components/common/DataTable/DataTable.vue'
interface Project extends Record<string, unknown> {
id: number
name: string
client: string
summary: string
}
const PROJECTS: Project[] = [
{ id: 1, name: 'Warehouse rollout', client: 'Northwind', summary: 'Three sites live, two in fit-out. Hardware arrives in week 12.' },
{ id: 2, name: 'Checkout redesign', client: 'Lumen Retail', summary: 'Design signed off. Payment provider swap is the open risk.' },
{ id: 3, name: 'Driver app', client: 'Vellum Logistics', summary: 'Discovery interviews done. Scope lands at the end of the month.' },
]
const columns: ColumnDef<Project>[] = [
{ key: 'name', label: 'Project' },
{ key: 'client', label: 'Client' },
]
</script>
<template>
<DataTable :columns="columns" :data="PROJECTS" row-key="id" expandable :toolbar="false">
<template #expanded-row="{ row }">
<p class="text-sm text-muted-foreground">
{{ row.summary }}
</p>
</template>
</DataTable>
</template>Inline editing
A column with editable swaps to an input on double-click. Enter and blur commit through cell-edit, Escape reverts. It is table mode only, and a column with a render is never editable: the cell's author owns the cell.
The table does not write to your data. It hands you the row, the key and the value, and you decide.
<script setup lang="ts">
import type { CellEditPayload, ColumnDef } from '@shared/components/common/DataTable/types'
import DataTable from '@shared/components/common/DataTable/DataTable.vue'
import { ref } from 'vue'
interface Project extends Record<string, unknown> {
id: number
name: string
owner: string
}
const projects = ref<Project[]>([
{ id: 1, name: 'Warehouse rollout', owner: 'Amina Haddad' },
{ id: 2, name: 'Checkout redesign', owner: 'Luca Rossi' },
{ id: 3, name: 'Driver app', owner: 'Mei Tanaka' },
])
const columns: ColumnDef<Project>[] = [
{ key: 'name', label: 'Project', editable: true },
{ key: 'owner', label: 'Owner', editable: true },
]
function onCellEdit(payload: CellEditPayload<Project>): void {
const target = projects.value.find(p => p.id === payload.row.id)
if (target)
target[payload.key] = payload.value as string
}
</script>
<template>
<div class="space-y-3">
<DataTable
:columns="columns"
:data="projects"
row-key="id"
:toolbar="false"
@cell-edit="onCellEdit"
/>
<p class="text-sm text-muted-foreground">
Double-click a cell to edit it. Enter commits, Escape reverts.
</p>
</div>
</template>Loading and empty
loading renders a skeleton of pagination.perPage rows, five when there is no pagination, or five cards in card mode. With no rows and no loading, the table shows the shared empty state, which the #empty slot replaces.
features.skeletonLoaders off removes the skeleton rows and cards. See Global config.
<script setup lang="ts">
import type { ColumnDef } from '@shared/components/common/DataTable/types'
import DataTable from '@shared/components/common/DataTable/DataTable.vue'
import { Button } from '@shared/components/ui/button'
import { computed, ref } from 'vue'
interface Project extends Record<string, unknown> {
id: number
name: string
client: string
}
const ALL: Project[] = [
{ id: 1, name: 'Warehouse rollout', client: 'Northwind' },
{ id: 2, name: 'Checkout redesign', client: 'Lumen Retail' },
]
const columns: ColumnDef<Project>[] = [
{ key: 'name', label: 'Project' },
{ key: 'client', label: 'Client' },
]
const state = ref<'data' | 'loading' | 'empty'>('loading')
const rows = computed(() => (state.value === 'data' ? ALL : []))
</script>
<template>
<div class="space-y-3">
<div class="flex flex-wrap gap-2">
<Button size="sm" :variant="state === 'loading' ? 'default' : 'outline'" @click="state = 'loading'">
Loading
</Button>
<Button size="sm" :variant="state === 'empty' ? 'default' : 'outline'" @click="state = 'empty'">
Empty
</Button>
<Button size="sm" :variant="state === 'data' ? 'default' : 'outline'" @click="state = 'data'">
With rows
</Button>
</div>
<DataTable
:columns="columns"
:data="rows"
:loading="state === 'loading'"
row-key="id"
:toolbar="false"
/>
</div>
</template>Column visibility
DataTableColumnToggle is deliberately not rendered by the table, so a page can put it where it belongs. It remembers the reader's choice in localStorage, keyed by the route name unless you pass storageKey.
visibleColumns is a read-only input on the table. You own the state.
<script setup lang="ts">
import type { ColumnDef } from '@shared/components/common/DataTable/types'
import DataTable from '@shared/components/common/DataTable/DataTable.vue'
import DataTableColumnToggle from '@shared/components/common/DataTable/DataTableColumnToggle.vue'
import { ref } from 'vue'
interface Project extends Record<string, unknown> {
id: number
name: string
client: string
budget: number
due: string
}
const PROJECTS: Project[] = [
{ id: 1, name: 'Warehouse rollout', client: 'Northwind', budget: 48000, due: '2026-04-10' },
{ id: 2, name: 'Checkout redesign', client: 'Lumen Retail', budget: 26500, due: '2026-03-28' },
{ id: 3, name: 'Driver app', client: 'Vellum Logistics', budget: 72000, due: '2026-06-01' },
]
const columns: ColumnDef<Project>[] = [
{ key: 'name', label: 'Project' },
{ key: 'client', label: 'Client' },
{ key: 'budget', label: 'Budget', align: 'right' },
{ key: 'due', label: 'Due' },
]
const visibleColumns = ref(columns.map(c => c.key))
</script>
<template>
<div class="space-y-3">
<div class="flex justify-end">
<DataTableColumnToggle
v-model="visibleColumns"
:columns="columns"
storage-key="sa-coltoggle:projects"
/>
</div>
<DataTable
:columns="columns"
:data="PROJECTS"
:visible-columns="visibleColumns"
row-key="id"
:toolbar="false"
/>
</div>
</template>Filter bar
DataTableFilter is its own component and takes the same FieldDefinition[] the form engine uses. mode is inline, collapsible, drawer or modal. Inline filters as the reader types, debounced by 300ms; the other three wait for Apply. Applying the values to the rows is the page's job: the bar collects them and says when.
<script setup lang="ts">
import type { FieldDefinition } from '@core/schema/types'
import type { ColumnDef } from '@shared/components/common/DataTable/types'
import DataTable from '@shared/components/common/DataTable/DataTable.vue'
import DataTableFilter from '@shared/components/common/DataTable/DataTableFilter.vue'
import { computed, ref } from 'vue'
interface Project extends Record<string, unknown> {
id: number
name: string
client: string
stage: string
}
const PROJECTS: Project[] = [
{ id: 1, name: 'Warehouse rollout', client: 'Northwind', stage: 'build' },
{ id: 2, name: 'Checkout redesign', client: 'Lumen Retail', stage: 'review' },
{ id: 3, name: 'Driver app', client: 'Vellum Logistics', stage: 'discovery' },
{ id: 4, name: 'Billing migration', client: 'Acme Health', stage: 'shipped' },
{ id: 5, name: 'Partner portal', client: 'Northwind', stage: 'build' },
]
const columns: ColumnDef<Project>[] = [
{ key: 'name', label: 'Project' },
{ key: 'client', label: 'Client' },
{ key: 'stage', label: 'Stage' },
]
const fields: FieldDefinition[] = [
{ key: 'name', type: 'text', label: 'Project', placeholder: 'Search by name' },
{
key: 'stage',
type: 'select',
label: 'Stage',
options: [
{ label: 'Discovery', value: 'discovery' },
{ label: 'Build', value: 'build' },
{ label: 'Review', value: 'review' },
{ label: 'Shipped', value: 'shipped' },
],
},
]
const filters = ref<Record<string, unknown>>({})
const applied = ref<Record<string, unknown>>({})
const rows = computed(() => PROJECTS.filter((project) => {
const name = String(applied.value.name ?? '').toLowerCase()
const stage = applied.value.stage
if (name && !project.name.toLowerCase().includes(name))
return false
if (stage && project.stage !== stage)
return false
return true
}))
function apply(): void {
applied.value = { ...filters.value }
}
function reset(): void {
applied.value = {}
}
</script>
<template>
<div class="space-y-4">
<DataTableFilter
v-model="filters"
mode="collapsible"
:fields="fields"
@apply="apply"
@reset="reset"
/>
<DataTable :columns="columns" :data="rows" row-key="id" :toolbar="false" />
</div>
</template>The toolbar
Export and print are on by default, import is opt-in. Export writes CSV, Excel or PDF of the current data and columns, each cell as the table shows it (see useExport), and the Excel and PDF libraries are imported on demand, so a page that never exports never downloads them. Import needs importFields: it offers a template built from those fields, parses the uploaded file, coerces each column to its declared type, and emits import with the rows. Nothing is saved until you save it.
<script setup lang="ts">
import type { FieldDefinition } from '@core/schema/types'
import type { ColumnDef } from '@shared/components/common/DataTable/types'
import DataTable from '@shared/components/common/DataTable/DataTable.vue'
import { ref } from 'vue'
interface Project extends Record<string, unknown> {
id: number
name: string
client: string
budget: number
}
const projects = ref<Project[]>([
{ id: 1, name: 'Warehouse rollout', client: 'Northwind', budget: 48000 },
{ id: 2, name: 'Checkout redesign', client: 'Lumen Retail', budget: 26500 },
])
const columns: ColumnDef<Project>[] = [
{ key: 'name', label: 'Project' },
{ key: 'client', label: 'Client' },
{ key: 'budget', label: 'Budget', align: 'right' },
]
const importFields: FieldDefinition[] = [
{ key: 'name', type: 'text', label: 'Project', required: true },
{ key: 'client', type: 'text', label: 'Client', required: true },
{ key: 'budget', type: 'number', label: 'Budget' },
]
const imported = ref(0)
function onImport(rows: Record<string, unknown>[]): void {
imported.value = rows.length
}
</script>
<template>
<div class="space-y-3">
<DataTable
:columns="columns"
:data="projects"
:import-fields="importFields"
row-key="id"
importable
filename="projects"
@import="onImport"
/>
<p class="text-sm text-muted-foreground">
{{ imported ? `${imported} row(s) imported` : 'Export, print and import sit in the toolbar' }}
</p>
</div>
</template>Cards
Below 768px each row becomes a card, which is how a wide table stays readable without a horizontal scroll. mobileCards: false opts out. displayMode="card" forces cards at every width.
<script setup lang="ts">
import type { ColumnDef } from '@shared/components/common/DataTable/types'
import DataTable from '@shared/components/common/DataTable/DataTable.vue'
interface Project extends Record<string, unknown> {
id: number
name: string
client: string
budget: number
}
const PROJECTS: Project[] = [
{ id: 1, name: 'Warehouse rollout', client: 'Northwind', budget: 48000 },
{ id: 2, name: 'Checkout redesign', client: 'Lumen Retail', budget: 26500 },
{ id: 3, name: 'Driver app', client: 'Vellum Logistics', budget: 72000 },
]
const columns: ColumnDef<Project>[] = [
{ key: 'name', label: 'Project' },
{ key: 'client', label: 'Client' },
{ key: 'budget', label: 'Budget', align: 'right', render: value => `$${Number(value).toLocaleString('en-US')}` },
]
</script>
<template>
<DataTable
:columns="columns"
:data="PROJECTS"
display-mode="card"
row-key="id"
:toolbar="false"
/>
</template>API
Source: src/shared/components/common/DataTable/
Props
| Prop | Type | Default | Description |
|---|---|---|---|
columns | ColumnDef<T>[] | required | The columns, in render order. |
data | T[] | required | The rows to show, already paged. |
loading | boolean | false | Renders a skeleton of pagination.perPage rows, or five when there is no pagination. |
pagination | PaginationConfig | — | { page, perPage, total }. Omit it and no pager renders. |
selectable | boolean | false | Adds the checkbox column and the select-all header. |
displayMode | 'table' | 'card' | 'table' | Force one layout at every width. |
serverSort | boolean | false | Stop sorting locally and only emit update:sort. |
rowKey | keyof T & string | 'id' | The property that identifies a row. |
actions | ActionDef<T>[] | — | Row actions. |
bulkActions | BulkActionDef[] | — | Actions over the current selection. |
actionsMenu | boolean | false | Force row actions into the overflow menu. |
visibleColumns | string[] | — | Keys to show. Unset means all. Read-only: you own the state. |
expandable | boolean | false | Adds the expand arrow and renders #expanded-row. |
mobileCards | boolean | true | Cards below 768px. |
toolbar | boolean | true | The top bar with import, export and print. |
exportable | boolean | true | The export menu. |
printable | boolean | true | The print button. |
importable | boolean | false | The import dialog. Needs importFields. |
importFields | FieldDefinition[] | [] | Drives the downloadable template and the coercion on import. |
filename | string | 'export' | Filename stem for exports and the template. |
card | boolean | true | The card the toolbar, table and pager sit in. |
Emits
| Event | Payload | Description |
|---|---|---|
update:pagination | PaginationConfig | The reader changed page or page size. |
update:sort | SortConfig<T> | { key, direction }. |
update:selection | T[keyof T][] | Row keys, not rows. |
row-click | T | Fires on a row press. |
cell-edit | CellEditPayload<T> | { row, key, value }, after Enter or blur on an editable cell. |
import | Record<string, unknown>[] | Parsed and coerced rows from the import dialog. |
Slots
| Slot | Props | Description |
|---|---|---|
toolbar-start | — | Content at the start of the toolbar, before the built-in buttons. |
toolbar | — | Content after toolbar-start, inside the toolbar. |
empty | — | Replaces the empty state. |
expanded-row | row: T | The panel under an expanded row. Needs expandable. |
ColumnDef<T>
| Key | Type | Description |
|---|---|---|
key | keyof T & string | The property to read. |
label | string | Header text. A translation key or a literal. |
sortable | boolean | Adds the sort control to this header. |
filterable | boolean | Marks the column as filterable for a filter bar. |
width | string | Any CSS width, such as '120px' or '20%'. |
align | 'left' | 'center' | 'right' | Cell alignment. |
render | (value, row) => string | VNode | Custom cell output, with no wrapper element. |
hideOn | 'sm' | 'md' | 'lg' | Hide this column below that width. |
editable | boolean | Double-click swaps the cell for an input. Table mode only, and a render always wins. |
ActionDef<T> and BulkActionDef
| Key | Type | Description |
|---|---|---|
label | string | A translation key or a literal. |
icon | Component | Optional. |
handler | (row: T) => void / (keys: unknown[]) => void | A row action takes the row; a bulk action takes the selected keys. |
variant | 'default' | 'destructive' | Destructive draws in the destructive color. |
visibleIf | (row: T) => boolean | Row actions only. Evaluated per row. |
The section components
Each piece is usable on its own. The table renders the toolbar, bulk-action bar, row actions and pager itself; the filter and column toggle are placed by the page.
| Component | Props | Emits |
|---|---|---|
DataTableToolbar | data, columns, filename, exportable, printable, importable, importFields | import |
DataTableFilter | mode, fields, modelValue | update:modelValue, apply, reset |
DataTableColumnToggle | columns, modelValue, storageKey | update:modelValue |
DataTableExport | data, columns, filename | — |
DataTableImportDialog | open, fields, filename | update:open, confirm |
DataTableBulkActions | selected, actions | clear |
DataTableActions | actions, row, menu | — |
DataTablePagination | pagination | update:pagination |
DataTableToolbar also has a start slot, which is what the table's toolbar-start fills.
Page sizes offered by the pager are 10, 25, 50 and 100, plus the current perPage when it is not one of them, so a table set to 5 shows its own size rather than an empty box.
Accessibility
- A sortable header cell shows the current direction with an icon, so the state does not rest on color, and the cycle is ascending, descending, off. It is a clickable header cell rather than a button, so sorting is a pointer gesture.
- The select-all checkbox is named "Select all rows on this page" and reports the indeterminate state when only some rows on the page are selected. Each row checkbox is named "Select row".
- The expand arrow is a button carrying
aria-expanded, named "Expand row" or "Collapse row", and its icon rotates, so the state is not carried by position alone. - The actions column's header carries a visually hidden "Actions", so the column is not an unnamed blank.
- An inline edit commits on
Enteror blur and reverts onEscape, and its two buttons are named "Apply this change" and "Discard this change". They hold the input's focus on mouse-down, or blur would commit before Discard ran. - In a right-to-left layout the columns reverse with the page, the actions column stays at the inline end, and the expand arrow flips, because the component uses logical alignment throughout.