GrDataTable
A table for large data sets with sorting and filtering.
Overview
When to take it
- the rows are sorted — by a click on a heading; over server-side pagination
externalSortmoves the ordering into the request instead of lying about the current page; - the rows are selected —
selectabletogether with a partial selection in the heading; - a total is needed under the set —
summaryRowputs 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 —
virtualwithstickyHeaderholds both the scrolling and the heading.
When to take something else
| Need | Take |
|---|---|
| You write the markup of the cells yourself | GrTable |
| There are no columns, the rows are uniform | GrList |
| The rows are nested | GrTree |
| The order of the rows is changed by the user | GrSortableList |
| There are more rows than fit | GrPagination |
| A trend in a cell | GrSparkline |
Playground controls come from the API, not from hand
Loading…
0 changed· state is in the URL
<GrDataTable
:columns="columns"
:rows="rows"
rowKey="id"
initialSortKey="due"
/>Examples 8 each with a live demo and its code
Columns as data, cells as slots
Columns are described once:
sortableturns a header into a button,alignputs the money right. Values stay raw — an ISO date, a number — so they sort as dates and numbers, and#cell-<key>only formats them. Withsort-cycle="asc-desc-none"the third click returns the original order;@row-clickopens the invoice.InvoicesClick a row to open the invoiceInvoices StatusTailspin ToysINV-0419Paid Sep 30 €2,150.00 Contoso LtdINV-0420Overdue Oct 2 €432.00 Northwind TradersINV-0421Sent Oct 21 €1,284.50 Wide World ImportersINV-0417Sent Oct 28 €3,780.00 Fabrikam IncINV-0416Draft Nov 4 €96.00 Selection the toolbar acts on
selectableadds the checkbox column with a partial state in the header, andv-model:selectedgives the keys to the screen — the toolbar counts the sum and sends the reminders.selectable-rowleaves a paid invoice without a checkbox and out of «select all».2 selected · €3,033.40Overdue invoices CustomerLast reminderAmountContoso LtdINV-04204 days Never €432.00 LitwareINV-041211 days Sep 28 €1,290.00 Adventure WorksINV-0409Paid Paid today €640.00 ProsewareINV-040523 days Sep 20 €2,875.40 Fourth CoffeeINV-040131 days Sep 14 €158.00 A second tier that loads on opening
expandableadds a button column, and#detaildraws the tier under the row.load-detailfetches it on the first opening with anAbortSignal— collapse the row early, and the request is cancelled; the skeleton stands in while it runs.accordionkeeps one order open, and the state follows the row key, not its place.Orders this week OrderCustomerPlacedTotal#10482 Northwind Traders Oct 6 €1,218.00 #10481 Contoso Ltd Oct 6 €948.00 #10480 Tailspin Toys Oct 5 €292.00 #10479 Fabrikam Inc Oct 5 €1,180.00 #10478 Wide World Importers Oct 4 €1,734.00 #10477 Adventure Works Oct 4 €380.00 #10476 Litware Oct 3 €1,920.00 #10475 Proseware Oct 2 €280.00 Columns the user arranges
Three props hand the layout to the user:
reorderable-columnsadds a drag handle,resizable-columnsan edge to pull,pinnable-columnsa menu to pin left or right. Each has its ownv-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 SKUProductCategoryIn stockReservedPriceSupplierCH-201 Ergonomic chair Seating 48 12 €289 Herman & Co DK-140 Standing desk Desks 9 7 €640 Flexi Works AR-032 Monitor arm Accessories 132 18 €79 Ergotron LP-011 Desk lamp Lighting 0 0 €54 Lumen Lab TB-410 Meeting table Desks 3 1 €1,180 Flexi Works
API generated from the package · cannot drift
Props
| Prop | Type | default | Description |
|---|---|---|---|
rowsrequired | TRow[] | — | The rows of the set. The component sorts them unless `externalSort` is on; the key of a row comes from `rowKey`. |
columnsrequired | GrDataColumn<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. |
rowKey | GrDataTableRowKey<TRow> | undefined | "id" as GrDataTableRowKey<TRow> | The key of a row or a resolver function. By default — the `'id'` field. |
initialSortKey | string | undefined | undefined | The column key for the initial sorting (the uncontrolled mode). |
initialSortDir | GrDataTableSortDir | undefined | "asc" | The direction of the initial sorting (the uncontrolled mode). |
sortKeyv-model | string | undefined | undefined | The controlled sort key: `v-model:sortKey`. It switches on the controlled mode. |
sortDirv-model | GrDataTableSortDir | undefined | undefined | The controlled sort direction: `v-model:sortDir`. |
sortCycle | GrDataTableSortCycle | undefined | "asc-desc" | A click on a heading: `asc → desc` or `asc → desc → no sorting`. |
externalSort | boolean | undefined | false | External 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. |
selectable | boolean | undefined | false | Row 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)[] | undefined | undefined | The controlled list of selected row keys: `v-model:selected`. |
selectableRow | ((row: TRow) => boolean) | undefined | undefined | A predicate "this row can be selected". Non-selectable rows do not enter "select all" either. |
loading | boolean | undefined | false | The loading state: the body of the table is replaced with an indicator row. The `empty` state is not shown in the process. |
loadingText | string | undefined | undefined | The text of the loading indicator. i18n: the fallback is `gr.dataTable.loading`. |
emptyText | string | undefined | undefined | The text of the empty state. i18n: the fallback is `gr.dataTable.empty`; the `#empty` slot is stronger. |
rowClass | string | ((row: TRow, index: number) => string | undefined) | undefined | undefined | The class of a row: one for all of them or computed from the row. |
rowProps | ((row: TRow, index: number) => Record<string, unknown> | undefined) | undefined | undefined | Arbitrary attributes of a row (`data-*`, `title`, …). |
summaryRow | Partial<Record<GrDataColumnKey<TRow>, unknown>> | null | undefined | undefined | The summary row in the `<tfoot>`: values by the keys of the columns. It stands on the same column grid as the body — with the same padding, alignment, widths and pinning. Assembled by hand in `#footer`, it gets none of those classes (`GrTable` deliberately does not give them) and diverges from the body at the very first change of `size`. The total is not summed by the component: what counts as the total is known by the application. The styling of a cell is the `#summary-<key>` slot. `null` is equivalent to absence: `totals ?? null` is the ordinary way to write it where the total is not always computed. |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | The size of the table: the type size, the padding of the cells, the sort arrows and the checkboxes. It is passed into `GrTable`. |
caption | string | undefined | undefined | The caption of the table in a hidden `<caption>` — a screen reader reads it first. The `#caption` slot is stronger. |
ariaLabel | string | undefined | undefined | The accessible name of the `<table>` when there is no caption. Ignored if `ariaLabelledby` is set. |
ariaLabelledby | string | undefined | undefined | The `id` of a visible heading that names the table through `aria-labelledby`. |
regionLabel | string | undefined | undefined | The name of the scrolling wrapper: with it the wrapper becomes a `role="region"` area. |
stickyHeader | boolean | undefined | false | A heading that sticks during vertical scrolling (requires `maxHeight`). |
virtual | boolean | undefined | false | Virtualisation of the rows: only the window around the viewport lives in the DOM. The height of the window is set by `maxHeight`; without it there is no scrolling window at all. It switches on the fixed layout of the table: 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. The widths are worth setting with the `width` of the columns, otherwise a fixed layout divides the space equally. |
maxHeight | string | number | undefined | undefined | The maximum height of the table (vertical scrolling). A number is in pixels. |
reorderableColumns | boolean | undefined | false | A user order of the columns: a drag handle appears in the header. A column is dragged with a pointer and moved with `Shift`+`←`/`→` from the keyboard. |
columnOrderv-model | string[] | undefined | undefined | The controlled order of the columns — the keys in the required order (`v-model:columnOrder`). Unset — the component remembers the order itself, starting from the order of `columns`. |
resizableColumns | boolean | undefined | false | A user width of the columns: a handle appears at the right edge of a heading. It is dragged with a pointer, and from the keyboard with the arrows (the window-splitter pattern). It switches on the fixed layout of the table: without it the browser recomputes the widths by the content and the one set by the user disappears. |
columnWidthsv-model | Record<string, number> | undefined | undefined | The controlled widths in pixels by column key (`v-model:columnWidths`). Unset — the component remembers the widths itself. |
pinnableColumns | boolean | undefined | false | Pinning of columns is set by the user: a menu appears in the header with "pin left / pin right / unpin". Without the prop `pinned` on a column stays what it was — a declaration in the config that changes only through code. |
pinnedColumnsv-model | Record<string, GrColumnPin> | undefined | undefined | Controlled pinning by column key (`v-model:pinnedColumns`). Not set — the component remembers it itself, starting from `pinned` on the columns. |
cellNavigation | boolean | undefined | false | The keyboard across cells: the `grid` pattern from the WAI-ARIA APG. The whole table becomes **one** `Tab` stop, the arrows lead inside, and `Enter` or `F2` lead into the content of a cell. That is why it is a separate prop rather than the default behaviour: it rearranges the tab order for everyone who has already put the table in place. |
expandable | boolean | undefined | false | A second tier of a row: a column with an expand button and a block of details under the row. The content is drawn by the `#detail` slot. |
expandedKeysv-model | (string | number)[] | undefined | undefined | The controlled list of expanded keys (`v-model:expandedKeys`). |
expandableRow | ((row: TRow) => boolean) | undefined | undefined | Which row has a second tier. The rest get no button. |
loadDetail | ((row: TRow, signal: AbortSignal) => Promise<unknown>) | undefined | undefined | Loading of the details at the moment of expanding. Without it the content is taken from the row itself. `signal` is mandatory: a row is collapsed more often than waited for. |
accordion | boolean | undefined | false | No more than one row is expanded. |
expandColumn | boolean | undefined | true | Whether to draw the service column with its own expand button. `false` — there is no column, and expanding is driven by the consumer’s markup: the cell slots receive `expanded` and `toggleExpand` so that the button can be put into your own actions column next to the others. Then `aria-expanded` and the accessible name go onto that button too — the component has nothing to hang them on. |
Slots
| Slot | Type | Description |
|---|---|---|
caption | any | The caption of the table — a `<caption>`, read by a screen reader first. |
loading | any | The content while the data is on its way. |
empty | any | The 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
| Event | Signature | Description |
|---|---|---|
update:sortKey | (e: "update:sortKey", value: string): void | The 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): void | The sort direction changed (`v-model:sortDir`). |
sortChange | (e: "sortChange", value: { key: string; dir: GrDataTableSortDir; }): void | The 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)[]): void | The 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; }): void | A click on a row. `index` is the position in the current, already sorted order. |
update:columnOrder | (e: "update:columnOrder", value: string[]): void | The user moved a column (`v-model:columnOrder`): the keys in the new order. |
columnReorder | (e: "columnReorder", payload: { key: string; from: number; to: number; }): void | A 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>): void | The column widths changed (`v-model:columnWidths`) — also while the handle is being dragged. |
columnResize | (e: "columnResize", payload: { key: string; width: number; }): void | A 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>): void | The user changed the pinning of the columns (`v-model:pinnedColumns`); `null` means the column is unpinned. |
columnPin | (e: "columnPin", payload: { key: string; pinned: GrColumnPin; }): void | A column was pinned to an edge or unpinned (`null`) through the heading menu. |
update:expandedKeys | (e: "update:expandedKeys", value: (string | number)[]): void | The set of expanded rows changed (`v-model:expandedKeys`). |
expand | (e: "expand", payload: { row: TRow; key: string | number; }): void | A row was expanded — its second tier is shown. |
collapse | (e: "collapse", payload: { row: TRow; key: string | number; }): void | A row was collapsed. Rows closed by `accordion` when a neighbouring one is expanded do not emit the event. |
detailLoadError |
| `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 type { GrDataColumnKey, GrDataColumn, GrColumnPinSide } from '@feugene/granularity/components/GrDataTable'Filedist/types/components/GrDataTable/GrDataTable.vue.d.ts
type GrDataColumnKey<TRow extends Record<string, unknown> = Record<string, unknown>> = Extract<keyof TRow, string> | (string & {});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;
};type GrColumnPinSide = 'left' | 'right';type GrColumnPin = GrColumnPinSide | null;type GrDataTableSummary<TRow extends Record<string, unknown> = Record<string, unknown>> = Partial<Record<GrDataColumnKey<TRow>, unknown>>;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>> {type GrDataTableSortCycle = 'asc-desc' | 'asc-desc-none';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;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
type GrDataTableSortDir = 'asc' | 'desc';Filedist/types/components/GrDataTable/config.d.ts
const grDataTableConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrDataTable">;Filedist/types/components/GrDataTable/defaults.d.ts
interface GrDataTableConfigurableProps {
size: GrComponentSize;
}Filedist/types/components/GrDataTable/grDataTableStyles.d.ts
type GrDataTableSize = GrComponentSize;Filedist/types/components/GrDataTable/safelist.d.ts
const grDataTableSafelist: string[];Filedist/types/components/GrDataTable/index.d.ts
type GrDataTableInstance = ComponentExposed<typeof GrDataTableComponent>;Accessibility
APG pattern
table + separator, при cell-navigation — grid
Keys
Home / EndEnterHome / End
Full contract of the package: docs/keyboard.md ↗
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:
<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:
<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 throughNumber()would count them as zero, and empty cells would settle in the middle of a numeric series; - numbers, numeric strings,
Dateandbooleanare compared numerically; - everything else uses
localeComparewithsensitivity: 'base'andnumeric: 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-clickgives{ row, index, event };rowClassis a string or a function of the row;rowPropsare 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
widthof 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:
<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:
<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:
<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.
<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
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.
<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.
| Key | What it does |
|---|---|
| arrows | by cells: vertically by rows, horizontally by columns |
Home / End | to the edges of the row, not of the whole table |
Enter, F2 | enter the content of the cell |
Escape | return 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.
<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:
<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:
<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.
Props in this example