GrDataTable

  • core
  • data
  • API · 39 props
  • ⌨ keys

A table for large data sets with sorting and filtering.

import { GrDataTable } from '@feugene/granularity/components/GrDataTable'

Overview

When to take it

  • the rows are sorted — by a click on a heading; over server-side pagination externalSort moves the ordering into the request instead of lying about the current page;
  • the rows are selected — selectable together with a partial selection in the heading;
  • a total is needed under the set — summaryRow puts it into the <tfoot> on the same column grid as the body;
  • the columns are configured by the user — the order (columnOrder) and the width (columnWidths) are moved with the mouse and from the keyboard;
  • there are thousands of rows — virtual with stickyHeader holds both the scrolling and the heading.

When to take something else

NeedTake
You write the markup of the cells yourselfGrTable
There are no columns, the rows are uniformGrList
The rows are nestedGrTree
The order of the rows is changed by the userGrSortableList
There are more rows than fitGrPagination
A trend in a cellGrSparkline

Playground controls come from the API, not from hand

Stage

Loading…

0 changed· state is in the URL

Examples 8 each with a live demo and its code

  • Columns as data, cells as slots

    Columns are described once: sortable turns a header into a button, align puts the money right. Values stay raw — an ISO date, a number — so they sort as dates and numbers, and #cell-<key> only formats them. With sort-cycle="asc-desc-none" the third click returns the original order; @row-click opens the invoice.

    InvoicesClick a row to open the invoice
    Invoices
    Status
    Tailspin ToysINV-0419
    PaidSep 30€2,150.00
    Contoso LtdINV-0420
    OverdueOct 2€432.00
    Northwind TradersINV-0421
    SentOct 21€1,284.50
    Wide World ImportersINV-0417
    SentOct 28€3,780.00
    Fabrikam IncINV-0416
    DraftNov 4€96.00
  • Selection the toolbar acts on

    selectable adds the checkbox column with a partial state in the header, and v-model:selected gives the keys to the screen — the toolbar counts the sum and sends the reminders. selectable-row leaves a paid invoice without a checkbox and out of «select all».

    2 selected · €3,033.40
    Overdue invoices
    Customer
    Last reminder
    Amount
    Contoso LtdINV-0420
    4 daysNever€432.00
    LitwareINV-0412
    11 daysSep 28€1,290.00
    Adventure WorksINV-0409
    PaidPaid today€640.00
    ProsewareINV-0405
    23 daysSep 20€2,875.40
    Fourth CoffeeINV-0401
    31 daysSep 14€158.00
  • A second tier that loads on opening

    expandable adds a button column, and #detail draws the tier under the row. load-detail fetches it on the first opening with an AbortSignal — collapse the row early, and the request is cancelled; the skeleton stands in while it runs. accordion keeps one order open, and the state follows the row key, not its place.

    Orders this week
    Order
    Customer
    Placed
    Total
    #10482Northwind TradersOct 6€1,218.00
    #10481Contoso LtdOct 6€948.00
    #10480Tailspin ToysOct 5€292.00
    #10479Fabrikam IncOct 5€1,180.00
    #10478Wide World ImportersOct 4€1,734.00
    #10477Adventure WorksOct 4€380.00
    #10476LitwareOct 3€1,920.00
    #10475ProsewareOct 2€280.00
  • Columns the user arranges

    Three props hand the layout to the user: reorderable-columns adds a drag handle, resizable-columns an edge to pull, pinnable-columns a menu to pin left or right. Each has its own v-model — order, widths, pins — so the screen can save the layout and put it back.

    Drag a header by its handle, pull its edge, or pin it from its menu.
    Stock by product
    SKU
    Product
    Category
    In stock
    Reserved
    Price
    Supplier
    CH-201Ergonomic chairSeating4812€289Herman & Co
    DK-140Standing deskDesks97€640Flexi Works
    AR-032Monitor armAccessories13218€79Ergotron
    LP-011Desk lampLighting00€54Lumen Lab
    TB-410Meeting tableDesks31€1,180Flexi Works

API generated from the package · cannot drift

Props

PropTypedefaultDescription
rowsrequiredTRow[]—The rows of the set. The component sorts them unless `externalSort` is on; the key of a row comes from `rowKey`.
columnsrequiredGrDataColumn<TRow>[]—The columns: the field of the row, the heading, sorting, alignment, width and pinning. The order of the array is the initial order of the columns in the table.
rowKeyGrDataTableRowKey<TRow> | undefined"id" as GrDataTableRowKey<TRow>The key of a row or a resolver function. By default — the `'id'` field.
initialSortKeystring | undefinedundefinedThe column key for the initial sorting (the uncontrolled mode).
initialSortDirGrDataTableSortDir | undefined"asc"The direction of the initial sorting (the uncontrolled mode).
sortKeyv-modelstring | undefinedundefinedThe controlled sort key: `v-model:sortKey`. It switches on the controlled mode.
sortDirv-modelGrDataTableSortDir | undefinedundefinedThe controlled sort direction: `v-model:sortDir`.
sortCycleGrDataTableSortCycle | undefined"asc-desc"A click on a heading: `asc → desc` or `asc → desc → no sorting`.
externalSortboolean | undefinedfalseExternal sorting (server-side, for instance): the component does NOT sort `rows` itself and only reports the change through `update:sortKey`/`update:sortDir`/`sortChange`. The `rows` then have to arrive already sorted.
selectableboolean | undefinedfalseRow selection: it adds a leading column with checkboxes (plus "select all" in the header). The keys of the selected rows go through `v-model:selected`.
selectedv-model(string | number)[] | undefinedundefinedThe controlled list of selected row keys: `v-model:selected`.
selectableRow((row: TRow) => boolean) | undefinedundefinedA predicate "this row can be selected". Non-selectable rows do not enter "select all" either.
loadingboolean | undefinedfalseThe loading state: the body of the table is replaced with an indicator row. The `empty` state is not shown in the process.
loadingTextstring | undefinedundefinedThe text of the loading indicator. i18n: the fallback is `gr.dataTable.loading`.

Slots

SlotTypeDescription
captionanyThe caption of the table — a `<caption>`, read by a screen reader first.
loadinganyThe content while the data is on its way.
emptyanyThe empty state instead of the default text.
footer{ columns: GrDataColumn<TRow>[]; totalColumns: number; }The footer under the table: pagination, a counter, totals across the full width.

Events

EventSignatureDescription
update:sortKey(e: "update:sortKey", value: string): voidThe sort key changed through a click on a heading (`v-model:sortKey`). An empty string means the sorting was removed.
update:sortDir(e: "update:sortDir", value: GrDataTableSortDir): voidThe sort direction changed (`v-model:sortDir`).
sortChange(e: "sortChange", value: { key: string; dir: GrDataTableSortDir; }): voidThe sorting changed — the key and the direction in one event: convenient for requesting the data from the server again.
update:selected(e: "update:selected", value: (string | number)[]): voidThe row selection changed (`v-model:selected`): the keys of the selected rows, including the ones hidden from the set.
rowClick(e: "rowClick", payload: { row: TRow; index: number; event: MouseEvent; }): voidA click on a row. `index` is the position in the current, already sorted order.
update:columnOrder(e: "update:columnOrder", value: string[]): voidThe user moved a column (`v-model:columnOrder`): the keys in the new order.
columnReorder(e: "columnReorder", payload: { key: string; from: number; to: number; }): voidA single column move — from where and to where; convenient for saving the operation rather than the whole list.
update:columnWidths(e: "update:columnWidths", value: Record<string, number>): voidThe column widths changed (`v-model:columnWidths`) — also while the handle is being dragged.
columnResize(e: "columnResize", payload: { key: string; width: number; }): voidA column width is set: at the end of the gesture and on every keyboard step. `0` means the column returned to the automatic layout.
update:pinnedColumns(e: "update:pinnedColumns", value: Record<string, GrColumnPin>): voidThe user changed the pinning of the columns (`v-model:pinnedColumns`); `null` means the column is unpinned.
columnPin(e: "columnPin", payload: { key: string; pinned: GrColumnPin; }): voidA column was pinned to an edge or unpinned (`null`) through the heading menu.
update:expandedKeys(e: "update:expandedKeys", value: (string | number)[]): voidThe set of expanded rows changed (`v-model:expandedKeys`).
expand(e: "expand", payload: { row: TRow; key: string | number; }): voidA row was expanded — its second tier is shown.
collapse(e: "collapse", payload: { row: TRow; key: string | number; }): voidA row was collapsed. Rows closed by `accordion` when a neighbouring one is expanded do not emit the event.
detailLoadError
(e: "detailLoadError", payload: { row: TRow; key: string | n(e: "detailLoadError", payload: { row: TRow; key: string | number; error: unknown; }): void
`loadDetail` failed; the tier shows the error markup with a retry button. Cancelled requests do not arrive here.

Types

Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.

Import
import type { GrDataColumnKey, GrDataColumn, GrColumnPinSide } from '@feugene/granularity/components/GrDataTable'

Filedist/types/components/GrDataTable/GrDataTable.vue.d.ts

GrDataColumnKeytype
type GrDataColumnKey<TRow extends Record<string, unknown> = Record<string, unknown>> = Extract<keyof TRow, string> | (string & {});
GrDataColumntype
type GrDataColumn<TRow extends Record<string, unknown> = Record<string, unknown>> = {
    key: GrDataColumnKey<TRow>;
    label: string;
    sortable?: boolean;
    align?: 'left' | 'center' | 'right';
    width?: string | number;
    pinned?: GrColumnPinSide;
};
GrColumnPinSidetype
type GrColumnPinSide = 'left' | 'right';
GrColumnPintype
type GrColumnPin = GrColumnPinSide | null;
GrDataTableSummarytype
type GrDataTableSummary<TRow extends Record<string, unknown> = Record<string, unknown>> = Partial<Record<GrDataColumnKey<TRow>, unknown>>;
GrDataTableRowKeytype
type GrDataTableRowKey<TRow extends Record<string, unknown> = Record<string, unknown>> = string | ((row: TRow) => string | number);
export type GrDataTableSortCycle = 'asc-desc' | 'asc-desc-none';
export interface GrDataTableProps<TRow extends Record<string, unknown> = Record<string, unknown>> {
GrDataTableSortCycletype
type GrDataTableSortCycle = 'asc-desc' | 'asc-desc-none';
GrDataTablePropsinterface
interface GrDataTableProps<TRow extends Record<string, unknown> = Record<string, unknown>> {
    rows: TRow[];
    columns: GrDataColumn<TRow>[];
    rowKey?: GrDataTableRowKey<TRow>;
    initialSortKey?: string;
    initialSortDir?: GrDataTableSortDir;
    sortKey?: string;
    sortDir?: GrDataTableSortDir;
    sortCycle?: GrDataTableSortCycle;
    externalSort?: boolean;
    selectable?: boolean;
    selected?: Array<string | number>;
    selectableRow?: (row: TRow) => boolean;
GrDataTableEmitsinterface
interface GrDataTableEmits<TRow extends Record<string, unknown> = Record<string, unknown>> {
    (e: 'update:sortKey', value: string): void;
    (e: 'update:sortDir', value: GrDataTableSortDir): void;
    (e: 'sortChange', value: {
        key: string;
        dir: GrDataTableSortDir;
    }): void;
    (e: 'update:selected', value: Array<string | number>): void;
    (e: 'rowClick', payload: {
        row: TRow;
        index: number;
        event: MouseEvent;
    }): void;
    (e: 'update:columnOrder', value: string[]): void;
    (e: 'columnReorder', payload: {
        key: string;
        from: number;
        to: number;
    }): void;
    (e: 'update:columnWidths', value: Record<string, number>): void;
    (e: 'columnResize', payload: {
        key: string;
        width: number;
    }): void;
    (e: 'update:pinnedColumns', value: Record<string, GrColumnPin>): void;
    (e: 'columnPin', payload: {
        key: string;
        pinned: GrColumnPin;
    }): void;
    (e: 'update:expandedKeys', value: Array<string | number>): void;
    (e: 'expand', payload: {
        row: TRow;
        key: string | number;
    }): void;
    (e: 'collapse', payload: {
        row: TRow;
        key: string | number;
    }): void;
    (e: 'detailLoadError', payload: {
        row: TRow;
        key: string | number;
        error: unknown;
    }): void;
}

Filedist/types/components/GrDataTable/grDataTableSort.d.ts

GrDataTableSortDirtype
type GrDataTableSortDir = 'asc' | 'desc';

Filedist/types/components/GrDataTable/config.d.ts

grDataTableConfigconst
const grDataTableConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrDataTable">;

Filedist/types/components/GrDataTable/defaults.d.ts

GrDataTableConfigurablePropsinterface
interface GrDataTableConfigurableProps {
    size: GrComponentSize;
}

Filedist/types/components/GrDataTable/grDataTableStyles.d.ts

GrDataTableSizetype
type GrDataTableSize = GrComponentSize;

Filedist/types/components/GrDataTable/safelist.d.ts

grDataTableSafelistconst
const grDataTableSafelist: string[];

Filedist/types/components/GrDataTable/index.d.ts

GrDataTableInstancetype
type GrDataTableInstance = ComponentExposed<typeof GrDataTableComponent>;

Accessibility

APG pattern

table + separator, при cell-navigation — grid

Keys

Home / EndEnterHome / End

Usage from the library prose

The row key is not a formality

rowKey (the id field by default) takes part both in the :key for Vue and in the selection of rows. A row that has no value at the key gets a synthetic key tied to the identity of the object, and in dev mode the component warns about that in the console once.

A synthetic key survives sorting but does not survive a reload of the data: after a new fetch the same row will have a different one, which means a v-model:selected from the previous set will match nothing. For tables with selection, set rowKey explicitly — as a field or as a function:

vue
<GrDataTable :rows="rows" :columns="columns" :row-key="row => `${row.type}:${row.id}`" />
Sorting

The component sorts by itself until externalSort is set (then the rows arrive already sorted, and the table only reports the change through update:sortKey/update:sortDir/sortChange).

sortable: true over server-side pagination is a trap. What gets sorted is the array passed in, that is, the current page: the “largest write-off” column will show the maximum across twenty visible rows and will be read as the maximum across the log. If the data arrives paginated, the sorting moves into the request:

vue
<GrDataTable
  v-model:sort-key="sortKey"
  v-model:sort-dir="sortDir"
  :rows="page.data"
  :columns="columns"
  external-sort
  @sortChange="reload"
/>

The choice here is not between “the sorting lies” and “there is no sorting”: externalSort leaves the headings clickable and announces the direction in aria-sort, while the order of the rows is computed by the one who knows about all of the pages.

The comparison rules live in grDataTableSort.ts and are tested without mounting:

  • empty values (null, undefined, a whitespace string) are always at the end, in both directions. Coercion to a number through Number() would count them as zero, and empty cells would settle in the middle of a numeric series;
  • numbers, numeric strings, Date and boolean are compared numerically;
  • everything else uses localeCompare with sensitivity: 'base' and numeric: true, in the locale of the i18n adapter rather than in the locale of the browser.

sortCycle="asc-desc-none" adds a third click that removes the sorting; imperatively the same is done by clearSort().

The starting order is set by initialSortKey and initialSortDir (asc by default) — that is not the same as sortKey/sortDir: the former set the initial state and do not get in the user’s way afterwards, the latter make the sorting controlled, and then only the parent changes it. One of the two pairs has to be set: together they would mean the table received both an initial value and an owner of the state.

The accessibility of the heading

The sort button is named by the label of the column — the same one AT reads when moving across the cells. The direction is announced by aria-sort on the <th>, and “what will happen on a press” by hidden text inside the button (sr-only). An aria-label on the button is forbidden here: it replaces the name of the <th>, and instead of “Name” a screen reader reads “Sorted by Name ascending, press…”.

Loading and the empty state are announced by a live region that exists from the first render and is empty while there is nothing to announce: a region that appears already carrying text is not announced at all by some AT.

Row selection

selectable + v-model:selected (an array of keys). Without v-model:selected the table keeps the selection inside itself — as with sorting without sortKey.

selectableRow excludes a row from the selection: no checkbox is rendered for it, and it does not enter “select all”. “Select all” works over the visible rows and does not touch keys that are not on the screen right now (filtered out ones, for instance).

A navigational click (@row-click) is suppressed exactly on the checkbox: marking a row and leaving by a transition in one click is not possible. The service cell itself remains an ordinary cell of the row — in a row excluded from the selection, where there is no checkbox, it is clickable like any other.

The rows
  • @row-click gives { row, index, event };
  • rowClass is a string or a function of the row;
  • rowProps are arbitrary attributes (data-*, title);
  • the slots #cell-<key>, #header-<key>, #summary-<key>, #empty, #loading, #caption, #footer;
  • emptyText/loadingText — if text without a slot is enough;
  • the width of a column is a number (pixels) or a string ('30%', '12rem'); it travels into the heading cell and works in any mode.
The summary row

summaryRow holds values by the keys of the columns. The row goes into the <tfoot> on the same column grid as the body:

vue
<GrDataTable :rows="rows" :columns="columns" :summary-row="{ channel: 'Total', net: total }" />

A column that is not in the object stays an empty cell — a total is not obliged to fill the whole row. Zero is printed in the process: “the total is zero” and “there is no total” are different statements.

Why a prop rather than markup by hand. GrTable deliberately does not style the cells, and a row assembled in #footer gets padding of its own — and that is tied to size. It is enough to give the table size="lg" for a hand-written px-3 py-2 to diverge from the body by four pixels along both axes. The same goes for alignment, width and pinning: align="right", width and pinned would have to be reproduced on every cell. summaryRow takes all of that from the same functions as a body row.

The styling of a cell is the #summary-<key> slot, and the scope gives away value and column:

vue
<GrDataTable :rows="rows" :columns="columns" :summary-row="summaryRow">
  <template #summary-net="{ value }">
    <span class="text-[var(--gr-danger-text)]">{{ money(value) }}</span>
  </template>
</GrDataTable>

The row has no tone by default and none by a prop: “refunds” and “profit” are different messages, and the component cannot choose on the application’s behalf. By default a total gets only a separation and a weight.

The total is not summed by the component: what counts as the total — the filters, the page, the whole log — is known by the application, not by the table.

Free markup under the total

#footer remains: it is for what does not fit into a single typed row — several totals, a note, a colspan. It is rendered into the same <tfoot> after the summaryRow, so the content of the slot is table rows as well (<tr><td>) rather than a free block. A flex justify-between there will give a block that lies under the table but does not stand under its column.

The scope gives away the columns in their current order and their total number together with the selection column — so that the colspan is not guessed by eye:

vue
<GrDataTable :rows="rows" :columns="columns">
  <template #footer="{ totalColumns }">
    <tr>
      <td :colspan="totalColumns">
        Refunds are included in the "Total" row
      </td>
    </tr>
  </template>
</GrDataTable>

columns is needed when a row is computed across several columns: the order there is the user’s (columnOrder), and repeating it by hand means diverging from the header at the very first move of a column.

Virtualisation

virtual together with maxHeight keeps in the DOM only the window around the viewport.

vue
<GrDataTable :rows="rows" :columns="columns" virtual sticky-header :max-height="420" />

The spacers here are rows, not pseudo-elements. In list components what is cut off is held by the ::before/::after of the container, but in a table that is not possible: a <tbody> ignores padding, and a pseudo-element inside a group of rows does not form a row with a controllable height. Service <tr> elements with a single cell spanning all of the columns therefore stand at the top and at the bottom — of the same shape as the loading and empty rows — and they are hidden from a screen reader.

What is measured is the row group, not the row. The body is assembled in groups of one row each, and the measurement hangs on the group — otherwise the second tier of an expanded row would not make it into the height (see “The second tier of a row”).

The layout is fixed. The width of a column is computed from the content of all of the rows, and in the DOM there is only the window of them: without fixing, the columns would jump on every scroll. Set the width of the columns — otherwise a fixed layout divides the space equally, and a narrow # gets as much as Email.

The number of rows is declared explicitly. A screen reader counts the rows by the markup, and there there is a window, so the table gives away aria-rowcount, and the rows aria-rowindex (the heading is the first, and the set starts with the second). In the ordinary mode those attributes are absent: there the set is visible in the DOM. How the primitive works — virtual-list.md.

The imperative API
ts
const table = ref<GrDataTableInstance>()

table.value?.scrollToRow(42) // with `virtual` it reaches a row outside the window too
table.value?.scrollTo({ top: 0 })
table.value?.clearSort()
table.value?.toggleAll()
The order of the columns

reorderable-columns adds a drag handle to every header cell. A column is dragged with a pointer (with the mouse and with a finger) and moved from the keyboard — Shift+←/→ on the handle; the result is announced into a live region.

vue
<GrDataTable
  v-model:column-order="order"
  reorderable-columns
  :columns="columns"
  :rows="rows"
/>

v-model:column-order is an array of keys. Without it the table remembers the order itself, as it does the sorting without v-model:sortKey. Beside it there is the columnReorder event ({ key, from, to }) — with it a single operation is convenient to save rather than the whole list.

The set of columns is set by columns, and the order only orders. A key that is not in the set is ignored; a column that is not in the order (it has just been added) goes to the end. Otherwise an edit of columns would lose columns silently.

The handle is a button of its own rather than the heading itself: a click on the heading remains sorting, and dragging does not intercept it. In the tab order the handles hold one stop for the whole header (a roving tabindex), and bare ←/→ walk the focus between them.

The width of the columns

resizable-columns adds a handle to the right edge of every heading. The width is dragged with a pointer, and from the keyboard the handle behaves like a window splitter (role="separator", the GrSplitter pattern): ←/→ is a step of 16px, with Shift 48px, and Enter returns the column to automatic layout. A double click does the same.

v-model:column-widths holds the widths in pixels by column key; without it the table remembers them itself. The columnResize event ({ key, width }) arrives at the end of a gesture and on every step of the keyboard; a width of 0 means a return to automatic layout. A column does not become narrower than 48px — even the sort arrow stops fitting into it.

The handle names its width from the first render, before any dragging: while the column has no width of its own, the one measured from the header goes into aria-valuenow, and before the measurement the one declared in columns.

The mode switches the fixed layout of the table on. Otherwise the browser recomputes the columns by their content and the width set by the user disappears at the very first update of the data. An interrupted gesture (Esc, the loss of the pointer) returns the width that was there before the press.

Pinned columns

The pinned: 'left' | 'right' of a column presses it against the edge during horizontal scrolling. The offsets inside a group are computed from the measured widths of the neighbours, so a column does not have to have a width; the recomputation runs on a change of the data and on ResizeObserver.

Three rules worth knowing before they surprise you:

  • pinned columns always stand as a group at their edge — the order inside a group is the user’s, but a column cannot be moved from group to group, otherwise “pinned to the left” would stop meaning “on the left”;
  • the selection column is pinned together with the left group — otherwise the checkbox would travel away from under a row that stayed in place;
  • a pinned cell has an opaque background (otherwise what is moving underneath shows through it), so it repeats the highlight of a selected row itself.

Pinning is set by the user

pinnable-columns adds to the heading of every column a menu “pin left / right / unpin”, and v-model:pinned-columns holds the choice by column key. Without the prop pinned remains what it was — a declaration in the config that is changed only by code.

The menu items are menuitemradio with checked, not three commands: pinning is a choice of one of three positions, and in that form the screen reader names the current one itself. The menu stands on GrDropdownMenu, so the keyboard, the layer stack and the return of focus arrive ready-made.

null in the model means “unpinned”, not “as in the config”: without that distinction there would be nothing to remove a pinning declared on a column with — the absence of a key is precisely what means “the config decides”.

The choice also moves the column: the groups at the edges are the rule above, and a column from the middle, pinned to the left, travels to the beginning.

Keyboard by cells

cell-navigation switches on the grid pattern from WAI-ARIA: the table gets role="grid", occupies one Tab stop, and the arrows lead inside.

KeyWhat it does
arrowsby cells: vertically by rows, horizontally by columns
Home / Endto the edges of the row, not of the whole table
Enter, F2enter the content of the cell
Escapereturn the focus to the cell itself

The header is a grid row like any other: from it, going down lands in the first data row. The ring is not closed: the row below the last one is not the first one but “nowhere further”.

A separate prop, not the default behaviour. The grid takes Tab for itself, and for everyone who has already put the table in, the tab order would be rearranged.

The controls inside the cells leave the tab sequence — sorting, the reorder and width handles, the column menu, the checkbox, the expand button. Otherwise there would be as many stops as controls, that is, the pattern would not be fulfilled at all: they are reached by arrowing to the cell and Enter. For this GrCheckbox has a tabindex prop — it holds its stop as a literal, and there is no other way to remove it from outside.

It works with virtualisation. The set of cells is the rendered window, not the whole table: with ten thousand rows by six columns there would be sixty thousand keys. A step past the edge of the window scrolls the list and brings the focus to the row that appeared.

The second tier of a row

expandable adds a service column with a button; under the row the #detail slot unfolds.

vue
<GrDataTable :rows="rows" :columns="columns" expandable v-model:expanded-keys="open">
  <template #detail="{ row }">
    <OrderLines :order-id="row.id" />
  </template>
</GrDataTable>

Expanding is done by the button, not a click on the row: a click on the row is already taken by rowClick and selection, and expanding on it would take navigation away from the consumer. The button stops propagation, so expanding does not select the row.

The column stands before the selection column: expanding relates to the whole row, and selection to its data.

The state is held by row key, not by index: sorting does not confuse the expanded row. accordion leaves one expanded.

A button of your own instead of the service column

expand-column="false" removes the column with the button; expanding is then driven by the consumer’s markup. The cell slots receive expanded and toggleExpand, so the button is put into your own actions column next to the others:

vue
<GrDataTable :rows="rows" :columns="columns" expandable :expand-column="false">
  <template #cell-actions="{ expanded, toggleExpand }">
    <GrButton size="xs" :aria-expanded="expanded" @click="toggleExpand">
      {{ expanded ? 'Collapse' : 'Details' }}
    </GrButton>
  </template>
</GrDataTable>

aria-expanded in this mode lands on your button: the component has none of its own, and there is nothing for it to hang the state on. The link to the id of the details row through aria-controls also remains yours.

Loading on demand

loadDetail switches on loading at the moment of expanding:

vue
<GrDataTable :load-detail="(row, signal) => api.order(row.id, { signal })" expandable>
  <template #detail="{ data }">{{ data.total }}</template>
</GrDataTable>

The second argument is an AbortSignal, and passing it into the request is mandatory: a row is collapsed more often than the response is waited for, and a collapsed row cancels its request. A response that started earlier and arrived later loses to the latest one.

The states arrive in the slot: state — idle | loading | ready | error, data — the result. Loading and failure have default markup (a skeleton and a retry button); it is overridden by the #detail-loading and #detail-error slots.

A skeleton rather than a spinner, deliberately: expanding has already shifted the height of the table, and a second change a moment later would read as jitter.

What has been loaded is kept while the row is alive: collapse and expand again — there is no request. A change of the identity of the rows array resets the cache (a mutation in place keeps it), and pointwise it is reset by invalidateDetail(key?) from the imperative API.

Together with virtual

It works: the row and its second tier lie in one row group, and the measurement of the virtualiser hangs on the group. On the row itself it would not see the height of the details — the spacer would be computed without it, and the list would drift silently.

There is nothing else in a table to wrap two <tr> in: a row group is the only container that exists for them. That is why the body of the table here is assembled in groups of one row each (rowGroups of GrTable) rather than a single <tbody> for everything. Several groups in a table are valid and change nothing for a screen reader: the row number comes from aria-rowindex, not from the position in the markup.

A side benefit: the ResizeObserver of the virtualiser watches the same group, so expanding and loading the details refine the height by themselves — there is no need to report them separately.

Accessibility

The button carries aria-expanded and aria-controls onto the details row; expanding and the end of loading are announced into the live region. The details row is not a row of the set: it does not get aria-rowindex, and aria-rowcount is counted by rows.

What the table does not do

There is no grouping of rows and no editing of cells — that is a different class of table.