GrDataTable
Берут, когда строки сортируют.
Когда брать
- строки сортируют — по клику на заголовок; над серверной пагинацией
externalSortуводит порядок в запрос, вместо того чтобы врать по текущей странице; - строки выбирают —
selectableвместе с частичным выбором в заголовке; - под набором нужен итог —
summaryRowставит его в<tfoot>по той же колоночной сетке, что и тело; - колонки настраивает пользователь — порядок (
columnOrder) и ширина (columnWidths) переносятся мышью и с клавиатуры; - строк тысячи —
virtualсоstickyHeaderдержит и прокрутку, и заголовок.
Когда взять другое
| Нужно | Берите |
|---|---|
| Разметку ячеек пишете сами | GrTable |
| Столбцов нет, строки однородные | GrList |
| Строки вложены | GrTree |
| Порядок строк меняет пользователь | GrSortableList |
| Строк больше, чем помещается | GrPagination |
| Тренд в ячейке | GrSparkline |
Ключ строки — не формальность
rowKey (по умолчанию поле id) участвует и в :key для Vue, и в выборе
строк. Строка, у которой значения по ключу нет, получает синтетический ключ,
привязанный к идентичности объекта, и в dev-режиме компонент один раз
предупреждает об этом в консоль.
Синтетический ключ переживает сортировку, но не переживает перезагрузку данных:
после нового fetch он у той же строки будет другим, а значит v-model:selected
из прошлого набора не совпадёт ни с чем. Для таблиц с выбором задавайте rowKey
явно — полем или функцией:
<GrDataTable :rows="rows" :columns="columns" :row-key="row => `${row.type}:${row.id}`" />Сортировка
Компонент сортирует сам, пока не задан externalSort (тогда rows приходят уже
отсортированными, а таблица только сообщает о смене через
update:sortKey/update:sortDir/sortChange).
sortable: true над серверной пагинацией — ловушка. Сортируется переданный
массив, то есть текущая страница: колонка «максимальное списание» покажет
максимум по двадцати видимым строкам, а прочитается как максимум по журналу.
Пришли постраничные данные — сортировка уходит в запрос:
<GrDataTable
v-model:sort-key="sortKey"
v-model:sort-dir="sortDir"
:rows="page.data"
:columns="columns"
external-sort
@sortChange="reload"
/>
Выбор здесь не между «сортировка врёт» и «сортировки нет»: externalSort
оставляет заголовки кликабельными и объявляет направление в aria-sort, а
порядок строк считает тот, кто знает про все страницы.
Правила сравнения живут в grDataTableSort.ts и тестируются без монтирования:
- пустые значения (
null,undefined, пробельная строка) — всегда в конце, в обоих направлениях. Приведение к числу черезNumber()считало бы их нулём, и пустые ячейки оседали бы в середине числового ряда; - числа, числовые строки,
Dateиbooleanсравниваются численно; - остальное —
localeCompareсsensitivity: 'base'иnumeric: true, на локали i18n-адаптера, а не на локали браузера.
sortCycle="asc-desc-none" добавляет третий клик, снимающий сортировку;
императивно то же делает clearSort().
Стартовый порядок задают initialSortKey и initialSortDir (asc по
умолчанию) — это не то же самое, что sortKey/sortDir: первые задают
начальное состояние и дальше не мешают пользователю, вторые делают сортировку
управляемой, и тогда её меняет только родитель. Задать нужно одну пару из двух:
вместе они означали бы, что таблица получила и начальное значение, и владельца
состояния.
Доступность заголовка
Кнопка сортировки называется подписью колонки — её же читает AT при переходе
по ячейкам. Направление объявляет aria-sort на <th>, а «что произойдёт по
нажатию» — скрытый текст внутри кнопки (sr-only). aria-label на кнопке
здесь запрещён: он подменяет собой имя <th>, и вместо «Название» диктор читает
«Отсортировано по Название по возрастанию, нажмите…».
Загрузка и пустое состояние объявляются живым регионом, который существует с первого рендера и пуст, пока объявлять нечего: регион, появляющийся сразу с текстом, часть AT не объявляет вовсе.
Выбор строк
selectable + v-model:selected (массив ключей). Без v-model:selected
таблица держит выбор внутри себя — как и сортировка без sortKey.
selectableRow исключает строку из выбора: у неё не рендерится чекбокс, и она
не попадает в «выбрать все». «Выбрать все» работает по видимым строкам и не
трогает ключи, которых сейчас нет на экране (например отфильтрованные).
Навигационный клик (@row-click) гасится ровно на чекбоксе: отметить строку и
уехать по переходу одним кликом нельзя. Сама служебная ячейка остаётся обычной
ячейкой строки — у исключённой из выбора строки, где чекбокса нет, она кликается
как любая другая.
Строки
@row-clickотдаёт{ row, index, event };rowClass— строка или функция от строки;rowProps— произвольные атрибуты (data-*,title);- слоты
#cell-<key>,#header-<key>,#summary-<key>,#empty,#loading,#caption,#footer; emptyText/loadingText— если хватает текста без слота;widthу колонки — число (пиксели) или строка ('30%','12rem'); уезжает в заголовочную ячейку и работает в любом режиме.
Итоговая строка
summaryRow — значения по ключам колонок. Строка встаёт в <tfoot> по той же
колоночной сетке, что и тело:
<GrDataTable :rows="rows" :columns="columns" :summary-row="{ channel: 'Итого', net: total }" />
Колонка, которой в объекте нет, остаётся пустой ячейкой — заполнять весь ряд итог не обязан. Ноль при этом печатается: «итог ноль» и «итога нет» — разные утверждения.
Почему проп, а не разметка руками. GrTable намеренно не оформляет ячейки, и
собранная в #footer строка получает свои паддинги — а они привязаны к size.
Достаточно поставить таблице size="lg", и вручную прописанный px-3 py-2
разъедется с телом на четыре пикселя по обеим осям. То же с выравниванием,
шириной и закреплением: align="right", width и pinned пришлось бы
воспроизводить на каждой ячейке. summaryRow берёт всё это из тех же функций,
что и строка тела.
Оформление ячейки — слот #summary-<key>, скоуп отдаёт value и 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>
Тона у строки нет по умолчанию и нет пропом: «возвраты» и «прибыль» — разные сообщения, и выбрать за приложение компонент не может. По умолчанию итог получает только отбивку и вес.
Итог не суммируется компонентом: что считать итогом — фильтры, страница, весь журнал — знает приложение, а не таблица.
Свободная разметка под итогом
#footer остаётся: он для того, что в одну типизированную строку не ложится —
несколько итогов, примечание, colspan. Рендерится в тот же <tfoot> после
строки summaryRow, поэтому содержимое слота — тоже строки таблицы (<tr><td>),
а не свободный блок. flex justify-between там даст блок, который лежит под
таблицей, но не стоит под своей колонкой.
Скоуп отдаёт колонки в текущем порядке и их полное число вместе с колонкой
выбора — чтобы colspan не подбирался на глаз:
<GrDataTable :rows="rows" :columns="columns">
<template #footer="{ totalColumns }">
<tr>
<td :colspan="totalColumns">
Возвраты учтены в строке «Итого»
</td>
</tr>
</template>
</GrDataTable>
columns нужен, когда строка считается по нескольким колонкам: порядок там
пользовательский (columnOrder), и повторять его руками — значит разойтись с
шапкой при первом же переносе колонки.
Виртуализация
virtual вместе с maxHeight оставляет в DOM только окно вокруг вьюпорта.
<GrDataTable :rows="rows" :columns="columns" virtual sticky-header :max-height="420" />
Распорки здесь — строки, а не псевдоэлементы. У списочных компонентов
срезанное держат ::before/::after контейнера, но в таблице так нельзя:
<tbody> игнорирует padding, а псевдоэлемент внутри группы строк не образует
строку с управляемой высотой. Поэтому сверху и снизу встают служебные <tr> с
одной ячейкой на все колонки — той же формы, что строки загрузки и пустоты, — и
они скрыты от скринридера.
Раскладка фиксируется. Ширина колонки считается по содержимому всех строк, а
в DOM их только окно: без фиксации колонки прыгали бы на каждой прокрутке. Задайте
width у колонок — иначе фиксированная раскладка делит место поровну, и узкий
# получит столько же, сколько Email.
Число строк объявляется явно. Диктор считает строки по разметке, а там окно,
поэтому таблица отдаёт aria-rowcount, а строки — aria-rowindex (заголовок
первый, набор начинается со второго). В обычном режиме этих атрибутов нет: там
набор виден по DOM. Устройство примитива — virtual-list.md.
Императивный API
const table = ref<GrDataTableInstance>()
table.value?.scrollToRow(42) // с `virtual` доводит и до строки вне окна
table.value?.scrollTo({ top: 0 })
table.value?.clearSort()
table.value?.toggleAll()Порядок колонок
reorderable-columns добавляет в каждую ячейку шапки ручку переноса. Колонка
тянется указателем (мышью и пальцем) и двигается с клавиатуры — Shift+←/→
на ручке; результат объявляется в живой регион.
<GrDataTable
v-model:column-order="order"
reorderable-columns
:columns="columns"
:rows="rows"
/>
v-model:column-order — массив ключей. Без него порядок таблица помнит сама,
как и сортировку без v-model:sortKey. Рядом идёт событие columnReorder
({ key, from, to }) — им удобно сохранять одну операцию, а не весь список.
Состав колонок задаёт columns, порядок — только порядок. Ключ, которого в
наборе нет, игнорируется; колонка, которой нет в порядке (её только что
добавили), встаёт в конец. Иначе правка columns теряла бы колонки молча.
Ручка — своя кнопка, а не сам заголовок: клик по заголовку остаётся
сортировкой, и перетаскивание её не перехватывает. В таб-порядке ручки держат
одну остановку на всю шапку (roving tabindex), голые ←/→ водят фокус
между ними.
Ширина колонок
resizable-columns добавляет к правому краю каждого заголовка ручку. Ширина
тянется указателем, а с клавиатуры ручка ведёт себя как разделитель окна
(role="separator", паттерн GrSplitter): ←/→ — шаг 16px, с Shift — 48px,
Enter возвращает колонку к авторазметке. То же делает двойной клик.
v-model:column-widths — ширины в пикселях по ключу колонки; без него таблица
помнит их сама. Событие columnResize ({ key, width }) приходит на конце
жеста и на каждом шаге клавиатуры; ширина 0 означает возврат к авторазметке.
Уже 48px колонка не становится — в неё перестаёт помещаться даже стрелка
сортировки.
Ширину ручка называет с первого рендера, до всякой протяжки: пока своей ширины
у колонки нет, в aria-valuenow идёт измеренная по шапке, а до замера —
объявленная в columns.
Режим включает фиксированную раскладку таблицы. Иначе браузер пересчитывает
колонки по содержимому и заданная пользователем ширина пропадает на первом же
обновлении данных. Оборванный жест (Esc, потеря указателя) возвращает ширину,
которая была до нажатия.
Закреплённые колонки
pinned: 'left' | 'right' у колонки прижимает её к краю при горизонтальной
прокрутке. Смещения внутри группы считаются по измеренным ширинам соседей,
поэтому колонке не обязательно иметь width; пересчёт идёт на смене данных и по
ResizeObserver.
Три правила, которые стоит знать до того, как это удивит:
- закреплённые колонки всегда стоят группой у своего края — порядок внутри группы пользовательский, но перенести колонку из группы в группу нельзя, иначе «закреплена слева» перестало бы означать «слева»;
- колонка выбора закрепляется вместе с левой группой — иначе чекбокс уезжал бы из-под строки, которая осталась на месте;
- у закреплённой ячейки непрозрачный фон (иначе сквозь неё просвечивает то, что уезжает под низ), поэтому подсветку выбранной строки она повторяет сама.
UI для закрепления таблица не рисует: это меню на заголовке, а GrMenu в пакете
пока нет. Проп остаётся декларативным.
Чего таблица не делает
Нет группировки строк, разворачиваемых подстрок и редактирования ячеек — это
другой класс таблицы. Нет и UI закрепления: pinned задаётся кодом, а не
пользователем.
Playground 16
Загружается…
<GrDataTable />Установка
npm i @feugene/granularityИмпорт
import { GrDataTable } from '@feugene/granularity/components/GrDataTable'API
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
rowsобязательный | TRow[] | — | — |
columnsобязательный | GrDataColumn<TRow>[] | — | — |
rowKey | GrDataTableRowKey<TRow> | undefined | "id" as GrDataTableRowKey<TRow> | Ключ строки или функция-резолвер. По умолчанию — поле `'id'`. |
initialSortKey | string | undefined | undefined | Ключ колонки для начальной сортировки (uncontrolled-режим). |
initialSortDir | GrDataTableSortDir | undefined | "asc" | Направление начальной сортировки (uncontrolled-режим). |
sortKey | string | undefined | undefined | Контролируемый ключ сортировки: `v-model:sortKey`. Задаёт controlled-режим. |
sortDir | GrDataTableSortDir | undefined | undefined | Контролируемое направление сортировки: `v-model:sortDir`. |
sortCycle | GrDataTableSortCycle | undefined | "asc-desc" | Клик по заголовку: `asc → desc` или `asc → desc → без сортировки`. |
externalSort | boolean | undefined | false | Внешняя сортировка (например серверная): компонент НЕ сортирует `rows` сам, а только сообщает о смене через `update:sortKey`/`update:sortDir`/ `sortChange`. `rows` при этом должны приходить уже отсортированными. |
selectable | boolean | undefined | false | Выбор строк: добавляет ведущую колонку с чекбоксами (+ «выбрать все» в шапке). Выбранные ключи строк — через `v-model:selected`. |
selected | (string | number)[] | undefined | undefined | Контролируемый список выбранных ключей строк: `v-model:selected`. |
selectableRow | ((row: TRow) => boolean) | undefined | undefined | Предикат «строку можно выбрать». Невыбираемые строки не попадают и в «выбрать все». |
loading | boolean | undefined | false | Состояние загрузки: тело таблицы заменяется строкой-индикатором. `empty`-состояние при этом не показывается. |
loadingText | string | undefined | undefined | Текст индикатора загрузки. i18n: fallback `gr.dataTable.loading`. |
emptyText | string | undefined | undefined | Текст пустого состояния. i18n: fallback `gr.dataTable.empty`; слот `#empty` сильнее. |
rowClass | string | ((row: TRow, index: number) => string | undefined) | undefined | undefined | Класс строки: общий для всех либо вычисляемый по строке. |
rowProps | ((row: TRow, index: number) => Record<string, unknown> | undefined) | undefined | undefined | Произвольные атрибуты строки (`data-*`, `title`, …). |
summaryRow | Partial<Record<GrDataColumnKey<TRow>, unknown>> | null | undefined | undefined | Итоговая строка в `<tfoot>`: значения по ключам колонок. Встаёт по той же колоночной сетке, что и тело, — с теми же паддингами, выравниванием, ширинами и закреплением. Собранная руками в `#footer`, она этих классов не получает (`GrTable` их принципиально не даёт) и разъезжается с телом на первой же смене `size`. Итог не суммируется компонентом: что считать итогом — знает приложение. Оформление ячейки — слот `#summary-<key>`. `null` равнозначен отсутствию: `totals ?? null` — обычная запись там, где итог считается не всегда. |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | Размер таблицы: кегль, паддинги ячеек, стрелки сортировки и чекбоксы. Прокидывается в `GrTable`. |
caption | string | undefined | undefined | — |
ariaLabel | string | undefined | undefined | — |
ariaLabelledby | string | undefined | undefined | — |
regionLabel | string | undefined | undefined | — |
stickyHeader | boolean | undefined | false | Прилипающий заголовок при вертикальном скролле (нужен `maxHeight`). |
virtual | boolean | undefined | false | Виртуализация строк: в DOM живёт только окно вокруг вьюпорта. Высоту окна задаёт `maxHeight`, без него окна прокрутки не существует. Включает фиксированную раскладку таблицы: ширина колонки считается по содержимому всех строк, а в DOM их только окно — без фиксации колонки прыгали бы на каждой прокрутке. Ширины стоит задать через `width` у колонок, иначе фиксированная раскладка делит место поровну. |
maxHeight | string | number | undefined | undefined | Максимальная высота таблицы (вертикальный скролл). Число — в пикселях. |
reorderableColumns | boolean | undefined | false | Пользовательский порядок колонок: в шапке появляется ручка переноса. Колонка тянется указателем и двигается `Shift`+`←`/`→` с клавиатуры. |
columnOrder | string[] | undefined | undefined | Контролируемый порядок колонок — ключи в нужном порядке (`v-model:columnOrder`). Не задан — порядок компонент помнит сам, начиная с порядка `columns`. |
resizableColumns | boolean | undefined | false | Пользовательская ширина колонок: у правого края заголовка появляется ручка. Тянется указателем, с клавиатуры — стрелками (паттерн window splitter). Включает фиксированную раскладку таблицы: без неё браузер пересчитывает ширины по содержимому и заданная пользователем пропадает. |
columnWidths | Record<string, number> | undefined | undefined | Контролируемые ширины в пикселях по ключу колонки (`v-model:columnWidths`). Не заданы — ширины компонент помнит сам. |
Slots
| Slot | Type | Описание |
|---|---|---|
caption | any | Подпись таблицы — `<caption>`, читается диктором первой. |
loading | any | Содержимое, пока едут данные. |
empty | any | Пустое состояние вместо текста по умолчанию. |
footer | { columns: GrDataColumn<TRow>[]; totalColumns: number; } | Подвал под таблицей: пагинация, счётчик, итоги во всю ширину. |
Events
| Event | Type | Описание |
|---|---|---|
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; }] | — |
Примеры 12
Управляемая и внешняя сортировка
Управляемая сортировка через v-model:sortKey / v-model:sortDir + событие @sortChange. С external-sort таблица не сортирует rows сама — данные приходят уже отсортированными (серверная сортировка, синхронизация с URL).
| 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>Контролируемый режим нужен для серверной сортировки и синхронизации состояния с URL; без пропов sortKey/sortDir таблица работает в uncontrolled-режиме как прежде.
Сортируемые колонки и стартовый порядок
Базовый сценарий для GrDataTable: передаём rows, columns, стартовую сортировку и сразу проверяем built-in sorting.
| 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>Итоговая строка в сетке таблицы
summaryRow — значения по ключам колонок; строка встаёт в <tfoot> по той же колоночной сетке, что и тело. Оформление ячейки — слот #summary-<key>, он получает value и column.
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>Переключите размер: паддинги итога едут вместе с телом. Собранная руками в #footer строка этого не умеет — GrTable ячейки <tfoot> не оформляет, и однажды прописанный px-3 py-2 разъезжается с телом на первой же смене size.
Подвал целиком: итоги, строка сравнения и примечание
Типизированный итог, обе его ячейки со своим оформлением и #footer под ним — вторая итоговая строка и примечание на всю ширину. totalColumns из скоупа считает colspan вместе с колонкой выбора, поэтому подбирать его на глаз не приходится.
Канал | Выручка | Возвраты | Чистая | К прошлому кварталу | |
|---|---|---|---|---|---|
| Прямые продажи | 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>Ручная строка платит за свободу: пустую ведущую ячейку под чекбокс, паддинги и выравнивание она переписывает сама, и они привязаны к size. Главный итог поэтому идёт пропом.
Свои ячейки статуса и действий
Ключевой composition-scenario: стандартный data pipeline остаётся у GrDataTable, а конкретные ячейки переопределяются слотами.
<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>Именно slots превращают компонент из «таблицы по данным» в реальный admin/reporting building block.
Фильтрация набора снаружи компонента
Показываем границу ответственности: фильтрация остаётся снаружи, а GrDataTable честно рендерит уже подготовленный набор строк.
| 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>Выбор строк, липкий заголовок и загрузка
selectable добавляет ведущую колонку с чекбоксами и «выбрать все» в шапке (модель — v-model:selected по ключам строк). sticky-header + max-height держат заголовок видимым при вертикальном скролле. 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>«Выбрать все» оперирует только видимыми строками и сохраняет внешние ключи; при клиентской сортировке выбор остаётся по ключам, а не по позициям.
Шкала размеров
В отличие от GrTable, здесь размер ведёт ещё и паддинги ячеек, стрелки сортировки и чекбоксы — таблицу видно плотнее целиком.
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>Запреты на строке, три состояния сортировки и клик
Строка может быть невыбираемой (selectableRow), подсвеченной (rowClass) и кликабельной (@row-click), а третий клик по заголовку снимает сортировку — sortCycle="asc-desc-none".
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>Справочник на 10 000 строк
С virtual в DOM живёт только окно вокруг вьюпорта. Распорки здесь — служебные строки, а не псевдоэлементы: <tbody> игнорирует отступы, и произвольную коробку в него не положить.
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>Виртуализация включает фиксированную раскладку: ширина колонки считается по содержимому всех строк, а в DOM их только окно — без фиксации колонки прыгали бы на каждой прокрутке. Полное число строк уходит в aria-rowcount, номер строки — в aria-rowindex.
Пользовательский порядок колонок
reorderable-columns добавляет в шапку ручку переноса, v-model: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>С клавиатуры порядок меняется Shift со стрелкой на ручке; ручки держат одну остановку Tab на всю шапку, а каждая перестановка объявляется скринридеру.
Ширина и закрепление колонок
resizable-columns даёт ручку у правого края заголовка и v-model:column-widths; pinned: 'left' | 'right' у колонки прижимает её к краю при горизонтальной прокрутке. Закреплённые колонки всегда стоят своей группой — перенос через границу групп запрещён.
Номер | Маршрут | Перевозчик | Вес | Прибытие | Статус |
|---|---|---|---|---|---|
| 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>Ручка ширины — role="separator" с паттерном window splitter: стрелки меняют ширину, Shift — крупным шагом, Enter и двойной клик возвращают колонку к авторазметке.
Доступность
- Паттерн APG
table + separator- Клавиши
- заголовок сортируемой колонки — обычная кнопка (
Enter/Space— сортировать); приreorderable-columnsручка переноса держит одну остановкуTabна шапку:←/→— фокус между ручками,Shift+←/→— переставить колонку,Home/End— к краям; приresizable-columnsручка ширины — свой таб-стоп с паттерном window splitter:←/→— шаг 16px, сShift— 48px,Enter— вернуть авторазметку