GrDataTable

Package: @feugene/granularitycoreGroup: data

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 externalSort moves the ordering into the request instead of lying about the current page;
  • the rows are selectedselectable together with a partial selection in the heading;
  • a total is needed under the setsummaryRow 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 rowsvirtual 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

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 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:

<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…

Code
<GrDataTable />

Install

npm i @feugene/granularity

Import

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

API

Props

PropTypedefaultDescription
rowsrequiredTRow[]
columnsrequiredGrDataColumn<TRow>[]
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).
sortKeystring | undefinedundefinedThe controlled sort key: `v-model:sortKey`. It switches on the controlled mode.
sortDirGrDataTableSortDir | 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`.
selected(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`.
emptyTextstring | undefinedundefinedThe text of the empty state. i18n: the fallback is `gr.dataTable.empty`; the `#empty` slot is stronger.
rowClassstring | ((row: TRow, index: number) => string | undefined) | undefinedundefinedThe class of a row: one for all of them or computed from the row.
rowProps((row: TRow, index: number) => Record<string, unknown> | undefined) | undefinedundefinedArbitrary attributes of a row (`data-*`, `title`, …).
summaryRowPartial<Record<GrDataColumnKey<TRow>, unknown>> | null | undefinedundefinedThe 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" | undefinedundefinedThe size of the table: the type size, the padding of the cells, the sort arrows and the checkboxes. It is passed into `GrTable`.
captionstring | undefinedundefined
ariaLabelstring | undefinedundefined
ariaLabelledbystring | undefinedundefined
regionLabelstring | undefinedundefined
stickyHeaderboolean | undefinedfalseA heading that sticks during vertical scrolling (requires `maxHeight`).
virtualboolean | undefinedfalseVirtualisation 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.
maxHeightstring | number | undefinedundefinedThe maximum height of the table (vertical scrolling). A number is in pixels.
reorderableColumnsboolean | undefinedfalseA 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.
columnOrderstring[] | undefinedundefinedThe 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`.
resizableColumnsboolean | undefinedfalseA 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.
columnWidthsRecord<string, number> | undefinedundefinedThe controlled widths in pixels by column key (`v-model:columnWidths`). Unset — the component remembers the widths itself.

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

EventTypeDescription
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

Controlled sort:incidents · desc
Search72
Auth23
Billing01

Controlled Sort
<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

AlphaPlatform2
BetaBilling0
GammaSupport7

Sortable Columns
<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

Summary Row
<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.

Footer
<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

Custom Cellsdepends on the showcase environment
<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 latencycritical10:24
Profile syncnormal10:18
Webhook retriescritical09:57

Filtered View
<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

2 selected
City
Person 1EngineerBerlin
Person 2DesignerLisbon
Person 3PMWarsaw
Person 4AnalystMadrid
Person 5SupportMilan
Person 6EngineerAmsterdam
Person 7DesignerBerlin
Person 8PMLisbon
Person 9AnalystWarsaw
Person 10SupportMadrid
Person 11EngineerMilan
Person 12DesignerAmsterdam
Person 13PMBerlin
Person 14AnalystLisbon
Person 15SupportWarsaw
Person 16EngineerMadrid
Person 17DesignerMilan
Person 18PMAmsterdam
Person 19AnalystBerlin
Person 20SupportLisbon
Person 21EngineerWarsaw
Person 22DesignerMadrid
Person 23PMMilan
Person 24AnalystAmsterdam
Selected ids: 2, 5

Selection Sticky
<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

size="xs"
Role
Ada LovelaceOwner12
Grace HopperAdmin4
size="sm"
Role
Ada LovelaceOwner12
Grace HopperAdmin4
size="md"
Role
Ada LovelaceOwner12
Grace HopperAdmin4
size="lg"
Role
Ada LovelaceOwner12
Grace HopperAdmin4

Sizes
<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-1043Northwind€1,280.00sent
INV-1044Contoso€640.00paid
INV-1045Fabrikam€2,190.00draft
INV-1046Adventure Works€310.00sent
Third click on a header clears sorting · last clicked row: · selected: none

Row Guards
<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
1Person 1[email protected]Platform
2Person 2[email protected]Design
3Person 3[email protected]Growth
4Person 4[email protected]Support
5Person 5[email protected]Platform
6Person 6[email protected]Design
7Person 7[email protected]Growth
8Person 8[email protected]Support
9Person 9[email protected]Platform
10Person 10[email protected]Design
11Person 11[email protected]Growth
12Person 12[email protected]Support
13Person 13[email protected]Platform

Virtual
<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 со стрелкой. Клик по заголовку остаётся сортировкой.

Column Order
<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 возвращает колонку к авторазметке.

Column Layout
<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

Full keyboard contract of the package

Component documentationAll components