GrDataTable
A table for large data sets with sorting and filtering.
Machine-translated from the Russian original, not yet reviewed. Read the original
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 |
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.
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.
The table does not draw a UI for pinning: that is a menu on the heading, and there is no
GrMenu in the package yet. The prop remains declarative.
What the table does not do
There is no grouping of rows, no expandable sub-rows and no editing of cells — that is a
different class of table. There is no pinning UI either: pinned is set by code rather than by
the user.
Playground 16
Loading…
<GrDataTable />Install
npm i @feugene/granularityImport
import { GrDataTable } from '@feugene/granularity/components/GrDataTable'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
rowsrequired | TRow[] | — | — |
columnsrequired | GrDataColumn<TRow>[] | — | — |
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). |
sortKey | string | undefined | undefined | The controlled sort key: `v-model:sortKey`. It switches on the controlled mode. |
sortDir | 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`. |
selected | (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 | — |
ariaLabel | string | undefined | undefined | — |
ariaLabelledby | string | undefined | undefined | — |
regionLabel | string | undefined | undefined | — |
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. |
columnOrder | 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. |
columnWidths | Record<string, number> | undefined | undefined | The controlled widths in pixels by column key (`v-model:columnWidths`). Unset — the component remembers the widths itself. |
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 | Type | Description |
|---|---|---|
update:sortKey | [string] | — |
update:sortDir | [GrDataTableSortDir] | — |
sortChange | [{ key: string; dir: GrDataTableSortDir; }] | — |
update:selected | [(string | number)[]] | — |
rowClick | [{ row: TRow; index: number; event: MouseEvent; }] | — |
update:columnOrder | [string[]] | — |
columnReorder | [{ key: string; from: number; to: number; }] | — |
update:columnWidths | [Record<string, number>] | — |
columnResize | [{ key: string; width: number; }] | — |
Examples 12
Controlled / external sort
| Search | 7 | 2 |
| Auth | 2 | 3 |
| Billing | 0 | 1 |
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { GrDataColumn } from '@feugene/granularity'
import { GrBadge, GrDataTable } from '@feugene/granularity'
const rows = [
{ id: 1, service: 'Auth', incidents: 2, updatedAt: 3 },
{ id: 2, service: 'Billing', incidents: 0, updatedAt: 1 },
{ id: 3, service: 'Search', incidents: 7, updatedAt: 2 },
]
const columns: GrDataColumn[] = [
{ key: 'service', label: 'Service', sortable: true },
{ key: 'incidents', label: 'Incidents', sortable: true, align: 'right' },
{ key: 'updatedAt', label: 'Updated', sortable: true, align: 'right' },
]
// Контролируемое состояние сортировки (v-model:sortKey / v-model:sortDir).
const sortKey = ref('incidents')
const sortDir = ref<'asc' | 'desc'>('desc')
const lastChange = ref('')
// `external-sort`: таблица сама не сортирует — сортируем «снаружи» (как это делал бы
// сервер). Здесь имитируем это локально, но `rows` приходят уже отсортированными.
const sortedRows = computed(() => {
const key = sortKey.value
if (!key)
return rows
const dir = sortDir.value
return [...rows].sort((a, b) => {
const av = (a as Record<string, unknown>)[key]
const bv = (b as Record<string, unknown>)[key]
const res = typeof av === 'number' && typeof bv === 'number'
? av - bv
: String(av ?? '').localeCompare(String(bv ?? ''))
return dir === 'asc' ? res : -res
})
})
function onSortChange(event: { key: string, dir: 'asc' | 'desc' }) {
lastChange.value = `${event.key} · ${event.dir}`
}
</script>
<template>
<div class="grid gap-3">
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="showcase-demo-text opacity-70">Controlled sort:</span>
<GrBadge tone="primary">{{ sortKey }} · {{ sortDir }}</GrBadge>
<span v-if="lastChange" class="showcase-demo-text opacity-70">@sortChange: {{ lastChange }}</span>
</div>
<GrDataTable
v-model:sort-key="sortKey"
v-model:sort-dir="sortDir"
:rows="sortedRows"
:columns="columns"
row-key="id"
external-sort
@sort-change="onSortChange"
/>
</div>
</template>Sortable rows with initial state
| Alpha | Platform | 2 |
| Beta | Billing | 0 |
| Gamma | Support | 7 |
<script setup lang="ts">
import type { GrDataColumn } from '@feugene/granularity'
import { GrDataTable } from '@feugene/granularity'
const rows = [
{ id: 1, name: 'Alpha', incidents: 2, owner: 'Platform' },
{ id: 2, name: 'Beta', incidents: 0, owner: 'Billing' },
{ id: 3, name: 'Gamma', incidents: 7, owner: 'Support' },
]
const columns: GrDataColumn[] = [
{ key: 'name', label: 'Workspace', sortable: true },
{ key: 'owner', label: 'Owner', sortable: true },
{ key: 'incidents', label: 'Incidents', sortable: true, align: 'right' },
]
</script>
<template>
<GrDataTable
:rows="rows"
:columns="columns"
row-key="id"
initial-sort-key="name"
/>
</template>Summary row in the table grid
Channel | Gross | Refunds |
|---|---|---|
| Direct | $12,400 | $320 |
| Partners | $8,600 | $145 |
| Marketplace | $5,100 | $890 |
| Total | $26,100 | $1,355 |
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { GrDataColumn, GrDataTableSize } from '@feugene/granularity'
import { GrDataTable, GrSegmented } from '@feugene/granularity'
const rows = [
{ id: 1, channel: 'Direct', gross: 12400, refunds: 320 },
{ id: 2, channel: 'Partners', gross: 8600, refunds: 145 },
{ id: 3, channel: 'Marketplace', gross: 5100, refunds: 890 },
]
const columns: GrDataColumn[] = [
{ key: 'channel', label: 'Channel' },
{ key: 'gross', label: 'Gross', align: 'right' },
{ key: 'refunds', label: 'Refunds', align: 'right' },
]
const money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })
// Считает приложение: что именно итожить — страницу, фильтр или весь журнал —
// таблица знать не может.
const summaryRow = computed(() => ({
channel: 'Total',
gross: money.format(rows.reduce((sum, row) => sum + row.gross, 0)),
refunds: rows.reduce((sum, row) => sum + row.refunds, 0),
}))
// Переключатель здесь не для красоты: он и есть предмет примера — паддинги
// итога едут вместе с телом, а не остаются от той ступени, на которой их
// однажды прописали руками.
const size = ref<GrDataTableSize>('md')
</script>
<template>
<div class="grid gap-3">
<GrSegmented
v-model="size"
:options="[
{ value: 'xs', label: 'xs' },
{ value: 'sm', label: 'sm' },
{ value: 'md', label: 'md' },
{ value: 'lg', label: 'lg' },
]"
size="sm"
/>
<GrDataTable :rows="rows" :columns="columns" row-key="id" :size="size" :summary-row="summaryRow">
<template #cell-gross="{ row }">
{{ money.format(row.gross as number) }}
</template>
<template #cell-refunds="{ row }">
{{ money.format(row.refunds as number) }}
</template>
<!-- Тона у итога нет по умолчанию: «возвраты» и «прибыль» — разные
сообщения, и выбрать за приложение компонент не может. -->
<template #summary-refunds="{ value }">
<span class="text-[var(--gr-danger-text)]">{{ money.format(value as number) }}</span>
</template>
</GrDataTable>
</div>
</template>The whole footer: totals, a comparison row and a note
Канал | Выручка | Возвраты | Чистая | К прошлому кварталу | |
|---|---|---|---|---|---|
| Прямые продажи | 12 400 000 ₽ | 320 000 ₽ | 12 080 000 ₽ | +8.4% | |
| Партнёры | 8 600 000 ₽ | 145 000 ₽ | 8 455 000 ₽ | +2.1% | |
| Маркетплейсы | 5 100 000 ₽ | 890 000 ₽ | 4 210 000 ₽ | -6.3% | |
| Розница | 3 250 000 ₽ | 61 000 ₽ | 3 189 000 ₽ | 0.0% | |
| Итого за квартал | 29 350 000 ₽ | 1 416 000 ₽ | 27 934 000 ₽ | +4.5% | |
| Прошлый квартал | 27 900 000 ₽ | 1 180 000 ₽ | 26 720 000 ₽ | ||
| Возвраты учтены в «Чистой». Выбрано строк: 1 из 4. | |||||
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { GrDataColumn } from '@feugene/granularity'
import { GrDataTable, GrDelta } from '@feugene/granularity'
type Channel = {
id: number
channel: string
gross: number
refunds: number
net: number
change: number
}
const rows: Channel[] = [
{ id: 1, channel: 'Прямые продажи', gross: 12_400_000, refunds: 320_000, net: 12_080_000, change: 8.4 },
{ id: 2, channel: 'Партнёры', gross: 8_600_000, refunds: 145_000, net: 8_455_000, change: 2.1 },
{ id: 3, channel: 'Маркетплейсы', gross: 5_100_000, refunds: 890_000, net: 4_210_000, change: -6.3 },
{ id: 4, channel: 'Розница', gross: 3_250_000, refunds: 61_000, net: 3_189_000, change: 0 },
]
const columns: GrDataColumn<Channel>[] = [
{ key: 'channel', label: 'Канал' },
{ key: 'gross', label: 'Выручка', align: 'right' },
{ key: 'refunds', label: 'Возвраты', align: 'right' },
{ key: 'net', label: 'Чистая', align: 'right' },
{ key: 'change', label: 'К прошлому кварталу', align: 'right' },
]
const selected = ref<Array<string | number>>([2])
const money = new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
maximumFractionDigits: 0,
})
const sum = (pick: (row: Channel) => number) => rows.reduce((total, row) => total + pick(row), 0)
const previous = { gross: 27_900_000, refunds: 1_180_000, net: 26_720_000 }
// Считает приложение: что именно итожить — страницу, фильтр или весь журнал —
// таблица знать не может.
//
// `#cell-<key>` до итога не доходит: тело и итог — разные строки. Колонка либо
// кладёт в `summaryRow` уже готовую строку, либо получает слот `#summary-<key>`;
// здесь показаны оба пути.
const summaryRow = computed(() => ({
channel: 'Итого за квартал',
gross: money.format(sum(row => row.gross)),
refunds: sum(row => row.refunds),
net: money.format(sum(row => row.net)),
change: (sum(row => row.net) / previous.net - 1) * 100,
}))
</script>
<template>
<GrDataTable
v-model:selected="selected"
:rows="rows"
:columns="columns"
row-key="id"
selectable
:summary-row="summaryRow"
aria-label="Выручка по каналам"
>
<template #cell-gross="{ row }">
{{ money.format(row.gross as number) }}
</template>
<template #cell-refunds="{ row }">
{{ money.format(row.refunds as number) }}
</template>
<template #cell-net="{ row }">
{{ money.format(row.net as number) }}
</template>
<template #cell-change="{ row }">
<GrDelta :value="row.change as number" :precision="1" suffix="%" show-arrow />
</template>
<!--
Тона у итога нет по умолчанию: «возвраты» и «прибыль» — разные сообщения,
и выбрать за приложение компонент не может.
-->
<template #summary-refunds="{ value }">
<span class="text-[var(--gr-danger-text)]">{{ money.format(value as number) }}</span>
</template>
<template #summary-change="{ value }">
<GrDelta :value="value as number" :precision="1" suffix="%" show-arrow />
</template>
<!--
Всё, что в одну типизированную строку не ложится, живёт в `#footer`:
вторая итоговая строка и примечание под таблицей. Содержимое слота — тоже
строки таблицы, а не свободный блок: `div.flex` лёг бы под таблицу, но не
встал бы под свои колонки.
Цена ручной строки видна прямо здесь: паддинги, выравнивание и пустая
ведущая ячейка под чекбокс переписываются руками и привязаны к `size` —
поставьте таблице `size="lg"`, и `px-4 py-3` разъедется с телом. Ровно
поэтому главный итог берётся пропом `summaryRow`, а не собирается тут же.
-->
<template #footer="{ totalColumns }">
<tr class="text-[var(--gr-muted-fg)]">
<td />
<td class="px-4 py-3">
Прошлый квартал
</td>
<td class="px-4 py-3 text-right">
{{ money.format(previous.gross) }}
</td>
<td class="px-4 py-3 text-right">
{{ money.format(previous.refunds) }}
</td>
<td class="px-4 py-3 text-right">
{{ money.format(previous.net) }}
</td>
<td />
</tr>
<tr>
<td :colspan="totalColumns" class="px-4 py-3 text-[length:var(--gr-control-text-xs)] text-[var(--gr-muted-fg)]">
Возвраты учтены в «Чистой». Выбрано строк: {{ selected.length }} из {{ rows.length }}.
</td>
</tr>
</template>
</GrDataTable>
</template>Custom status and actions cells
<script setup lang="ts">
import { ref } from 'vue'
import type { GrDataColumn } from '@feugene/granularity'
import { GrBadge, GrButton, GrDataTable } from '@feugene/granularity'
import IconTrash from '~icons/lucide/trash2'
const lastAction = ref('No actions yet')
const rows = [
{ id: 1, service: 'Gateway', status: 'ok', owner: 'Core' },
{ id: 2, service: 'Importer', status: 'warning', owner: 'Ops' },
{ id: 3, service: 'Notifier', status: 'danger', owner: 'Growth' },
]
const columns: GrDataColumn[] = [
{ key: 'service', label: 'Service', sortable: true },
{ key: 'owner', label: 'Owner' },
{ key: 'status', label: 'Status' },
{ key: 'actions', label: 'Actions', align: 'right' },
]
function statusVariant(status: unknown): 'success' | 'warning' | 'danger' {
if (status === 'ok')
return 'success'
if (status === 'warning')
return 'warning'
return 'danger'
}
</script>
<template>
<div class="grid gap-3">
<GrDataTable :rows="rows" :columns="columns" row-key="id">
<template #cell-status="{ row }">
<GrBadge size="lg" :tone="statusVariant(row.status)">
{{ row.status }}
</GrBadge>
</template>
<template #cell-actions="{ row }">
<div class="flex justify-end gap-2">
<GrButton size="sm" variant="ghost" @click="lastAction = `Viewed ${row.service}`">
View
</GrButton>
<!-- Icon-only: иконка декоративна, имя кнопки задаётся явно. -->
<GrButton
size="sm"
square
variant="outline"
tone="danger"
:aria-label="`Escalate ${row.service}`"
@click="lastAction = `Escalated ${row.service}`"
>
<IconTrash />
</GrButton>
</div>
</template>
</GrDataTable>
<div class="text-sm text-[var(--gr-muted-fg)]">
{{ lastAction }}
</div>
</div>
</template>Filtered datasets outside the component
| Checkout latency | critical | 10:24 |
| Profile sync | normal | 10:18 |
| Webhook retries | critical | 09:57 |
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { GrDataColumn } from '@feugene/granularity'
import { GrButton, GrDataTable } from '@feugene/granularity'
const activeFilter = ref<'all' | 'critical'>('all')
const rows = [
{ id: 1, name: 'Checkout latency', severity: 'critical', updatedAt: '10:24' },
{ id: 2, name: 'Profile sync', severity: 'normal', updatedAt: '10:18' },
{ id: 3, name: 'Webhook retries', severity: 'critical', updatedAt: '09:57' },
]
const columns: GrDataColumn[] = [
{ key: 'name', label: 'Signal', sortable: true },
{ key: 'severity', label: 'Severity', sortable: true },
{ key: 'updatedAt', label: 'Updated', align: 'right', sortable: true },
]
const visibleRows = computed(() => {
return activeFilter.value === 'critical'
? rows.filter(row => row.severity === 'critical')
: rows
})
</script>
<template>
<div class="grid gap-3">
<div class="flex flex-wrap gap-2">
<GrButton size="sm" :variant="activeFilter === 'all' ? 'primary' : 'outline'" @click="activeFilter = 'all'">
All rows
</GrButton>
<GrButton size="sm" :variant="activeFilter === 'critical' ? 'primary' : 'outline'" @click="activeFilter = 'critical'">
Critical only
</GrButton>
</div>
<GrDataTable
:rows="visibleRows"
:columns="columns"
row-key="id"
initial-sort-key="updatedAt"
initial-sort-dir="desc"
/>
</div>
</template>Row selection, sticky header and loading
City | |||
|---|---|---|---|
| Person 1 | Engineer | Berlin | |
| Person 2 | Designer | Lisbon | |
| Person 3 | PM | Warsaw | |
| Person 4 | Analyst | Madrid | |
| Person 5 | Support | Milan | |
| Person 6 | Engineer | Amsterdam | |
| Person 7 | Designer | Berlin | |
| Person 8 | PM | Lisbon | |
| Person 9 | Analyst | Warsaw | |
| Person 10 | Support | Madrid | |
| Person 11 | Engineer | Milan | |
| Person 12 | Designer | Amsterdam | |
| Person 13 | PM | Berlin | |
| Person 14 | Analyst | Lisbon | |
| Person 15 | Support | Warsaw | |
| Person 16 | Engineer | Madrid | |
| Person 17 | Designer | Milan | |
| Person 18 | PM | Amsterdam | |
| Person 19 | Analyst | Berlin | |
| Person 20 | Support | Lisbon | |
| Person 21 | Engineer | Warsaw | |
| Person 22 | Designer | Madrid | |
| Person 23 | PM | Milan | |
| Person 24 | Analyst | Amsterdam |
<script setup lang="ts">
import { ref } from 'vue'
import type { GrDataColumn } from '@feugene/granularity'
import { GrBadge, GrButton, GrDataTable } from '@feugene/granularity'
const columns: GrDataColumn[] = [
{ key: 'name', label: 'Name', sortable: true },
{ key: 'role', label: 'Role', sortable: true },
{ key: 'city', label: 'City' },
]
const roles = ['Engineer', 'Designer', 'PM', 'Analyst', 'Support']
const cities = ['Berlin', 'Lisbon', 'Warsaw', 'Madrid', 'Milan', 'Amsterdam']
const rows = Array.from({ length: 24 }, (_, i) => ({
id: i + 1,
name: `Person ${i + 1}`,
role: roles[i % roles.length],
city: cities[i % cities.length],
}))
// Row selection via v-model:selected
const selected = ref<Array<string | number>>([2, 5])
// Loading toggle
const loading = ref(false)
function simulateReload() {
loading.value = true
window.setTimeout(() => {
loading.value = false
}, 1400)
}
</script>
<template>
<div class="grid gap-3">
<div class="flex flex-wrap items-center gap-2">
<GrButton size="sm" variant="outline" @click="simulateReload">
Simulate reload (loading)
</GrButton>
<GrButton size="sm" variant="ghost" @click="selected = []">
Clear selection
</GrButton>
<GrBadge>{{ selected.length }} selected</GrBadge>
</div>
<GrDataTable
v-model:selected="selected"
:rows="rows"
:columns="columns"
row-key="id"
selectable
sticky-header
:max-height="280"
:loading="loading"
>
<template #cell-role="{ row }">
<GrBadge tone="slate">
{{ row.role }}
</GrBadge>
</template>
</GrDataTable>
<div class="text-sm text-[var(--gr-muted-fg)]">
Selected ids: {{ selected.length ? selected.join(', ') : 'none' }}
</div>
</div>
</template>Sizes
Role | |||
|---|---|---|---|
| Ada Lovelace | Owner | 12 | |
| Grace Hopper | Admin | 4 |
Role | |||
|---|---|---|---|
| Ada Lovelace | Owner | 12 | |
| Grace Hopper | Admin | 4 |
Role | |||
|---|---|---|---|
| Ada Lovelace | Owner | 12 | |
| Grace Hopper | Admin | 4 |
Role | |||
|---|---|---|---|
| Ada Lovelace | Owner | 12 | |
| Grace Hopper | Admin | 4 |
<script setup lang="ts">
import { GrDataTable } from '@feugene/granularity'
const sizes = ['xs', 'sm', 'md', 'lg'] as const
const columns = [
{ key: 'name', label: 'Name', sortable: true },
{ key: 'role', label: 'Role' },
{ key: 'seats', label: 'Seats', align: 'right' as const, sortable: true },
]
const rows = [
{ id: 1, name: 'Ada Lovelace', role: 'Owner', seats: 12 },
{ id: 2, name: 'Grace Hopper', role: 'Admin', seats: 4 },
]
</script>
<template>
<div class="grid gap-4">
<div v-for="size in sizes" :key="size" class="grid gap-2">
<div class="text-xs font-semibold text-[var(--gr-muted-fg)]">
size="{{ size }}"
</div>
<GrDataTable
:rows="rows"
:columns="columns"
:size="size"
selectable
aria-label="Members"
/>
</div>
</div>
</template>Row guards, tri-state sorting and row click
Status | ||||
|---|---|---|---|---|
| INV-1043 | Northwind | €1,280.00 | sent | |
| INV-1044 | Contoso | €640.00 | paid | |
| INV-1045 | Fabrikam | €2,190.00 | draft | |
| INV-1046 | Adventure Works | €310.00 | sent |
<script setup lang="ts">
import { ref } from 'vue'
import type { GrDataColumn } from '@feugene/granularity'
import { GrBadge, GrDataTable } from '@feugene/granularity'
type Invoice = {
id: number
number: string
client: string
total: number
status: 'draft' | 'sent' | 'paid'
}
const columns: GrDataColumn<Invoice>[] = [
{ key: 'number', label: 'Invoice', sortable: true },
{ key: 'client', label: 'Client', sortable: true },
{ key: 'total', label: 'Total', sortable: true, align: 'right' },
{ key: 'status', label: 'Status' },
]
const rows: Invoice[] = [
{ id: 1, number: 'INV-1043', client: 'Northwind', total: 1280, status: 'sent' },
{ id: 2, number: 'INV-1044', client: 'Contoso', total: 640, status: 'paid' },
{ id: 3, number: 'INV-1045', client: 'Fabrikam', total: 2190, status: 'draft' },
{ id: 4, number: 'INV-1046', client: 'Adventure Works', total: 310, status: 'sent' },
]
const selected = ref<Array<string | number>>([])
const lastClicked = ref('—')
// Оплаченный счёт нельзя ни выбрать, ни отправить в массовое действие.
function canSelect(row: Invoice): boolean {
return row.status !== 'paid'
}
function rowClass(row: Invoice): string | undefined {
return row.status === 'paid' ? 'text-[var(--gr-muted-fg)]' : undefined
}
</script>
<template>
<div class="grid gap-3">
<GrDataTable
v-model:selected="selected"
:rows="rows"
:columns="columns"
row-key="id"
selectable
sort-cycle="asc-desc-none"
:selectable-row="canSelect"
:row-class="rowClass"
empty-text="No invoices for this period"
@row-click="lastClicked = $event.row.number"
>
<template #cell-total="{ row }">
{{ row.total.toLocaleString('en-US', { style: 'currency', currency: 'EUR' }) }}
</template>
<template #cell-status="{ row }">
<GrBadge :tone="row.status === 'paid' ? 'success' : row.status === 'draft' ? 'slate' : 'primary'" size="sm">
{{ row.status }}
</GrBadge>
</template>
</GrDataTable>
<div class="rounded-2xl border border-dashed border-[var(--gr-brd)] p-3 text-sm text-[var(--gr-muted-fg)]">
Third click on a header clears sorting · last clicked row:
<span class="font-semibold text-[var(--gr-fg)]">{{ lastClicked }}</span> ·
selected: <span class="font-semibold text-[var(--gr-fg)]">{{ selected.length ? selected.join(', ') : 'none' }}</span>
</div>
</div>
</template>Virtual
Email | Team | ||
|---|---|---|---|
| 1 | Person 1 | [email protected] | Platform |
| 2 | Person 2 | [email protected] | Design |
| 3 | Person 3 | [email protected] | Growth |
| 4 | Person 4 | [email protected] | Support |
| 5 | Person 5 | [email protected] | Platform |
| 6 | Person 6 | [email protected] | Design |
| 7 | Person 7 | [email protected] | Growth |
| 8 | Person 8 | [email protected] | Support |
| 9 | Person 9 | [email protected] | Platform |
| 10 | Person 10 | [email protected] | Design |
| 11 | Person 11 | [email protected] | Growth |
| 12 | Person 12 | [email protected] | Support |
| 13 | Person 13 | [email protected] | Platform |
<script setup lang="ts">
import { GrDataTable, type GrDataColumn } from '@feugene/granularity'
type Person = { id: number, name: string, email: string, team: string }
// Ширины заданы намеренно: с виртуализацией раскладка таблицы фиксируется, и
// без подсказок колонки поделили бы место поровну.
const columns: GrDataColumn<Person>[] = [
{ key: 'id', label: '#', width: 80, sortable: true },
{ key: 'name', label: 'Name', width: '30%', sortable: true },
{ key: 'email', label: 'Email' },
{ key: 'team', label: 'Team', width: 140 },
]
const teams = ['Platform', 'Design', 'Growth', 'Support']
const rows: Person[] = Array.from({ length: 10000 }, (_, index) => ({
id: index + 1,
name: `Person ${index + 1}`,
email: `person${index + 1}@example.com`,
team: teams[index % teams.length]!,
}))
</script>
<template>
<GrDataTable
:rows="rows"
:columns="columns"
virtual
sticky-header
:max-height="420"
aria-label="People directory"
/>
</template>Column Order
Этап | Ответственный | ||
|---|---|---|---|
| Северный мост | Переговоры | Иванова | 1 240 000 ₽ |
| Гидропроект | Счёт выставлен | Петров | 860 000 ₽ |
| Литейный двор | Подписание | Соколова | 2 105 000 ₽ |
Порядок: client, stage, owner, amount. Наведите курсор на заголовок и тяните за ручку — или дойдите до неё клавишей Tab и нажмите Shift со стрелкой. Клик по заголовку остаётся сортировкой.
<script setup lang="ts">
import { ref } from 'vue'
import { GrDataTable } from '@feugene/granularity'
type Deal = { id: number, client: string, stage: string, owner: string, amount: number }
const columns = [
{ key: 'client', label: 'Клиент', sortable: true },
{ key: 'stage', label: 'Этап' },
{ key: 'owner', label: 'Ответственный' },
{ key: 'amount', label: 'Сумма', sortable: true, align: 'right' as const },
]
const rows: Deal[] = [
{ id: 1, client: 'Северный мост', stage: 'Переговоры', owner: 'Иванова', amount: 1_240_000 },
{ id: 2, client: 'Гидропроект', stage: 'Счёт выставлен', owner: 'Петров', amount: 860_000 },
{ id: 3, client: 'Литейный двор', stage: 'Подписание', owner: 'Соколова', amount: 2_105_000 },
]
const order = ref(columns.map(column => column.key))
</script>
<template>
<div class="grid gap-4">
<GrDataTable
v-model:column-order="order"
reorderable-columns
:columns="columns"
:rows="rows"
row-key="id"
aria-label="Сделки"
>
<template #cell-amount="{ row }">
{{ row.amount.toLocaleString('ru-RU') }} ₽
</template>
</GrDataTable>
<p class="text-sm text-[var(--gr-muted-fg)]">
Порядок: <code>{{ order.join(', ') }}</code>. Наведите курсор на заголовок и тяните за ручку —
или дойдите до неё клавишей Tab и нажмите Shift со стрелкой. Клик по заголовку остаётся
сортировкой.
</p>
</div>
</template>Column Layout
Номер | Маршрут | Перевозчик | Вес | Прибытие | Статус |
|---|---|---|---|---|---|
| SH-1043 | Санкт-Петербург — Казань | Северный экспресс | 12,4 т | 14 августа | В пути |
| SH-1044 | Новороссийск — Пермь | ЮгТранс | 8,1 т | 16 августа | Погрузка |
| SH-1045 | Владивосток — Иркутск | Дальлогистика | 21,7 т | 19 августа | Задержка |
Номер закреплён слева, статус справа — при горизонтальной прокрутке они остаются на месте. Ширина тянется за правый край заголовка; с клавиатуры — стрелками на ручке, Enter возвращает колонку к авторазметке.
<script setup lang="ts">
import { ref } from 'vue'
import { GrDataTable } from '@feugene/granularity'
type Shipment = {
id: number
code: string
route: string
carrier: string
weight: string
eta: string
status: string
}
const columns = [
{ key: 'code', label: 'Номер', pinned: 'left' as const, width: 140 },
{ key: 'route', label: 'Маршрут', width: 260 },
{ key: 'carrier', label: 'Перевозчик', width: 220 },
{ key: 'weight', label: 'Вес', width: 160, align: 'right' as const },
{ key: 'eta', label: 'Прибытие', width: 200 },
{ key: 'status', label: 'Статус', pinned: 'right' as const, width: 160 },
]
const rows: Shipment[] = [
{ id: 1, code: 'SH-1043', route: 'Санкт-Петербург — Казань', carrier: 'Северный экспресс', weight: '12,4 т', eta: '14 августа', status: 'В пути' },
{ id: 2, code: 'SH-1044', route: 'Новороссийск — Пермь', carrier: 'ЮгТранс', weight: '8,1 т', eta: '16 августа', status: 'Погрузка' },
{ id: 3, code: 'SH-1045', route: 'Владивосток — Иркутск', carrier: 'Дальлогистика', weight: '21,7 т', eta: '19 августа', status: 'Задержка' },
]
const widths = ref<Record<string, number>>({})
</script>
<template>
<div class="grid gap-4">
<GrDataTable
v-model:column-widths="widths"
resizable-columns
:columns="columns"
:rows="rows"
row-key="id"
aria-label="Отгрузки"
/>
<p class="text-sm text-[var(--gr-muted-fg)]">
Номер закреплён слева, статус справа — при горизонтальной прокрутке они остаются на месте.
Ширина тянется за правый край заголовка; с клавиатуры — стрелками на ручке, Enter возвращает
колонку к авторазметке.
<template v-if="Object.keys(widths).length">
Заданные ширины: <code>{{ widths }}</code>.
</template>
</p>
</div>
</template>Accessibility
- APG pattern
table + separator