GrTable
Берут, когда ячейки оформляет потребитель.
Когда брать
- ячейки оформляет потребитель — своя разметка
<tr>/<td>, а от компонента нужны только обвязка и поведение; - таблица шире экрана — скролл-обёртка достижима с клавиатуры и объявлена как регион;
- заголовок должен оставаться видимым —
stickyHeaderвместе сmaxHeight; - таблица уже есть и её не переписывают — обёртка добавляет
caption, состояния и скролл, не трогая разметку.
Когда взять другое
| Нужно | Берите |
|---|---|
| Нужны сортировка, выбор строк, слоты ячеек | GrDataTable |
| Строки однородные и без столбцов | GrList |
| Строки вложены | GrTree |
| Порядок строк меняет пользователь | GrSortableList |
| Данные показывают графиком | GrChartBar |
caption — не украшение: без него таблица в списке ориентиров скринридера
безымянна, и на странице с тремя таблицами их не различить.
Скролл достижим с клавиатуры
Контейнер overflow-x-auto всегда стоит в таб-порядке (tabindex="0").
Раньше это зависело от regionLabel: широкую таблицу без метки нельзя было
проскроллить с клавиатуры вовсе — прямое нарушение WCAG 2.1.1.
regionLabel по-прежнему включает role="region" и даёт области имя, но к
достижимости скролла отношения не имеет.
Пустое состояние и загрузка
<GrTable :loading="pending" :column-count="4">
<template #header>…</template>
<tr v-for="row in rows" :key="row.id">…</tr>
<template #empty>Ничего не найдено</template>
</GrTable>
Пустоту таблица определяет сама — по содержимому слота. columnCount нужен
служебной строке: без него colspan не растянется на всю ширину.
loading рисует строки-скелетоны и помечает контейнер aria-busy; слот
#loading заменяет их целиком. Загрузка сильнее пустоты — иначе таблица мигала
бы текстом «пока пусто» на каждом запросе.
Императивный API
const table = ref<InstanceType<typeof GrTable>>()
table.value?.scrollToRow(42) // индекс строки содержимого; false — строки нет
table.value?.scrollTo({ top: 0 })
scrollToRow(index, options?) принимает индекс строки в разметке — строки
таблица не рендерит и ключей у них не знает, поэтому адресация только
позиционная. Считаются строки содержимого: служебные skeleton- и empty-строки
не адресуются, в состояниях loading и «пусто» метод возвращает false, как и
при индексе вне диапазона. У GrDataTable одноимённый метод работает по
ключу строки — при миграции аргумент меняет смысл.
Оформление строк
striped и hoverable вешаются на <tbody> целиком, а не на каждую ячейку:
разметку строк потребитель пишет сам, и требовать от него классов было бы
странно.
Sticky-заголовок использует локальный z-[1] внутри собственного контейнера —
к шкале --gr-z-* он отношения не имеет, это описано в
../z-index.md.
Неполный набор строк в разметке
Два пропа нужны, когда строки рендерит не всё содержимое набора — например, при
виртуализации в GrDataTable:
rowCount— полное число строк вместе с заголовочными, уходит вaria-rowcount. Без него диктор считает строки по разметке и объявит «5 из 20» на таблице в десять тысяч; строки при этом обязаны нестиaria-rowindex.fixedLayout—table-layout: fixed. Без него ширины колонок считаются по отрисованному окну и прыгают на каждой прокрутке.
rowCount заодно выключает браузерный якорь прокрутки у скролл-контейнера: он
подправляет scrollTop, когда меняется высота содержимого выше видимого узла, —
а окно меняет её на каждом кадре, и список уезжает тем дальше, чем грубее оценка
строки.
Playground 15
Загружается…
<GrTable />Установка
npm i @feugene/granularityИмпорт
import { GrTable } from '@feugene/granularity/components/GrTable'API
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | Размер таблицы — базовый кегль текста. Паддинги ячеек оставлены за консьюмером (GrTable — «тонкий» контейнер). |
ariaLabel | string | undefined | undefined | Прямой ARIA-label для `<table>`. Игнорируется, если задан `ariaLabelledby`. |
loading | boolean | undefined | false | Идёт загрузка: вместо строк — скелетоны, контейнер помечен `aria-busy`. |
hoverable | boolean | undefined | false | Подсветка строки под курсором. |
empty | boolean | undefined | undefined | Таблица пуста. По умолчанию определяется по содержимому слота. |
emptyText | string | undefined | undefined | Текст пустого состояния. Слот `#empty` сильнее. |
maxHeight | string | number | undefined | undefined | Максимальная высота скролл-контейнера (включает вертикальный скролл). Число трактуется как пиксели. Нужен для работы `stickyHeader`. |
caption | string | undefined | undefined | Текст caption для screen reader'ов. Рендерится как `<caption class="sr-only">`, если не передан слот `#caption`. |
ariaLabelledby | string | undefined | undefined | ID элемента-заголовка, связанного с `<table>` через `aria-labelledby`. |
regionLabel | string | undefined | undefined | ARIA-label для скролл-контейнера. Включает `role="region"`; сам скролл достижим с клавиатуры всегда, независимо от метки. |
stickyHeader | boolean | undefined | false | Прилипающий заголовок: `<thead>` остаётся видимым при вертикальном скролле. Осмысленно вместе с `maxHeight` (иначе таблица не скроллится вертикально). |
loadingRows | number | undefined | 3 | Сколько строк-заглушек показать при `loading`. |
columnCount | number | undefined | 1 | Сколько колонок занимает служебная строка (пустое состояние и скелетоны). |
striped | boolean | undefined | false | Чередование строк. |
rowCount | number | undefined | undefined | Полное число строк набора, включая строки заголовка (`aria-rowcount`). Нужен, когда в DOM не весь набор — например, при виртуализации: диктор считает строки по разметке и объявил бы «5 из 20» на таблице в десять тысяч. Строки при этом обязаны нести `aria-rowindex`. |
fixedLayout | boolean | undefined | false | Фиксированная раскладка (`table-layout: fixed`): ширины колонок берутся из первой строки, а не из содержимого всех. Обязателен, если в DOM не весь набор: иначе ширины считаются по отрисованному окну и прыгают на каждой прокрутке. Класс — arbitrary-значение, а не `table-fixed`: такого правила в `presetMini` нет, и класс молча не превратился бы в CSS. |
tableMinWidth | string | number | undefined | undefined | Минимальная ширина самой таблицы (число — пиксели). Нужна при `fixedLayout`: с фиксированной раскладкой и шириной `auto` браузер вписывает таблицу в контейнер и делит место между колонками пропорционально — то есть заданные ширины молча ужимаются, а горизонтальной прокрутки, на которой держатся закреплённые колонки, не возникает вовсе. |
Slots
| Slot | Type | Описание |
|---|---|---|
default | any | Строки таблицы, когда разметка пишется руками вместо `data`. |
caption | any | Подпись таблицы — `<caption>`, читается диктором первой. |
header | any | Шапка вместо построенной по `columns`. |
loading | any | Содержимое, пока едут данные, — вместо строк-заглушек. |
empty | any | Пустое состояние вместо текста по умолчанию. |
footer | any | Итоговая строка под таблицей. |
Methods / Expose
| Methods / Expose | Type | Описание |
|---|---|---|
scrollTo | (options: ScrollToOptions) => void | Прокрутить скролл-контейнер таблицы — тот же контракт, что у `GrDataTable`. |
scrollToRow | (index: number, options?: ScrollIntoViewOptions | undefined) => boolean | Прокрутить к строке содержимого по её индексу в разметке. `false` — строки нет в DOM. |
Примеры 6
Базовая отрисовка строк
Для базовой страницы показываем canonical table markup: #header slot, body rows и composition с badges.
| Campaign | Owner | Status | Reach |
|---|---|---|---|
| Spring onboarding | Olivia | Ready | 18.2k |
| Card migration | Maksim | Review | 9.7k |
| Payout reminder | Anna | Paused | 6.3k |
<script setup lang="ts">
import type { GrBadgeTone } from '@feugene/granularity'
import { GrBadge, GrTable } from '@feugene/granularity'
interface TableRow {
campaign: string
owner: string
status: string
tag: GrBadgeTone
reach: string
}
const rows: TableRow[] = [
{ campaign: 'Spring onboarding', owner: 'Olivia', status: 'Ready', tag: 'success', reach: '18.2k' },
{ campaign: 'Card migration', owner: 'Maksim', status: 'Review', tag: 'info', reach: '9.7k' },
{ campaign: 'Payout reminder', owner: 'Anna', status: 'Paused', tag: 'warning', reach: '6.3k' },
]
</script>
<template>
<GrTable>
<template #header>
<tr>
<th class="px-4 py-3 text-left font-600">Campaign</th>
<th class="px-4 py-3 text-left font-600">Owner</th>
<th class="px-4 py-3 text-left font-600">Status</th>
<th class="px-4 py-3 text-right font-600">Reach</th>
</tr>
</template>
<tr
v-for="row in rows"
:key="row.campaign"
class="border-t border-[var(--gr-brd)]"
>
<td class="px-4 py-3">{{ row.campaign }}</td>
<td class="px-4 py-3 text-[var(--gr-muted-fg)]">{{ row.owner }}</td>
<td class="px-4 py-3">
<GrBadge size="sm" :tone="row.tag">
{{ row.status }}
</GrBadge>
</td>
<td class="px-4 py-3 text-right font-600">{{ row.reach }}</td>
</tr>
</GrTable>
</template>Строки-скелетоны при загрузке
Закрываем data-display edge case: таблица должна выглядеть предсказуемо и в loading-state, когда данные ещё не приехали.
| Task | State | Updated |
|---|---|---|
<script setup lang="ts">
import { ref } from 'vue'
import { GrButton, GrTable } from '@feugene/granularity'
const loading = ref(true)
const rows = [
{ title: 'Ledger export', state: 'Completed', updated: '2 min ago' },
{ title: 'Reconciliation', state: 'Processing', updated: '5 min ago' },
{ title: 'Fraud review', state: 'Queued', updated: '12 min ago' },
]
</script>
<template>
<div class="grid gap-3">
<div>
<GrButton size="sm" variant="outline" @click="loading = !loading">
{{ loading ? 'Show resolved rows' : 'Show loading state' }}
</GrButton>
</div>
<!-- Скелетоны рисует сама таблица, контейнер при этом помечен `aria-busy`. -->
<GrTable :loading="loading" :loading-rows="3" :column-count="3">
<template #header>
<tr>
<th class="px-4 py-3 text-left font-600">Task</th>
<th class="px-4 py-3 text-left font-600">State</th>
<th class="px-4 py-3 text-left font-600">Updated</th>
</tr>
</template>
<tr v-for="row in rows" :key="row.title" class="border-t border-[var(--gr-brd)]">
<td class="px-4 py-3">{{ row.title }}</td>
<td class="px-4 py-3">{{ row.state }}</td>
<td class="px-4 py-3 text-[var(--gr-muted-fg)]">{{ row.updated }}</td>
</tr>
</GrTable>
</div>
</template>Пустое состояние внутри tbody
Показываем, как GrTable может содержать GrEmptyState внутри tbody, не теряя table semantics и visual shell.
| Preset | Owner | Value |
|---|---|---|
No preset rows | ||
<script setup lang="ts">
import { ref } from 'vue'
import { GrButton, GrTable } from '@feugene/granularity'
const empty = ref(true)
const rows = [
{ name: 'Risk alerts', owner: 'Ops team', value: 'Enabled' },
{ name: 'Approval SLA', owner: 'Finance', value: '24 hours' },
]
</script>
<template>
<div class="grid gap-3">
<div>
<GrButton size="sm" variant="outline" @click="empty = !empty">
{{ empty ? 'Show table rows' : 'Show empty state' }}
</GrButton>
</div>
<!-- Ни `v-if` вокруг строк, ни ручного `colspan`: пустоту таблица видит по слоту сама. -->
<GrTable :column-count="3" striped hoverable>
<template #header>
<tr>
<th class="px-4 py-3 text-left font-600">Preset</th>
<th class="px-4 py-3 text-left font-600">Owner</th>
<th class="px-4 py-3 text-left font-600">Value</th>
</tr>
</template>
<tr v-for="row in (empty ? [] : rows)" :key="row.name" class="border-t border-[var(--gr-brd)]">
<td class="px-4 py-3">{{ row.name }}</td>
<td class="px-4 py-3 text-[var(--gr-muted-fg)]">{{ row.owner }}</td>
<td class="px-4 py-3">{{ row.value }}</td>
</tr>
<template #empty>
<div class="grid justify-items-center gap-2">
<span>No preset rows</span>
<GrButton size="sm" @click="empty = false">
Load sample data
</GrButton>
</div>
</template>
</GrTable>
</div>
</template>Подвал, собранный руками: итоги и примечание
Слот #footer рендерится в <tfoot>, поэтому его содержимое — строки таблицы, а не свободный блок. Итог, отбивка и colspan примечания пишутся руками: GrTable ячейки не оформляет принципиально.
| Канал | Выручка | Возвраты | К прошлому кварталу |
|---|---|---|---|
| Прямые продажи | 12 400 000 ₽ | 320 000 ₽ | +8,4% |
| Партнёры | 8 600 000 ₽ | 145 000 ₽ | +2,1% |
| Маркетплейсы | 5 100 000 ₽ | 890 000 ₽ | -6,3% |
| Итого за квартал | 26 100 000 ₽ | 1 355 000 ₽ | +3,7% |
| Возвраты за квартал учтены отдельной строкой и в выручку не входят. | |||
<script setup lang="ts">
import { GrDelta, GrTable } from '@feugene/granularity'
interface ChannelRow {
channel: string
gross: number
refunds: number
change: number
}
const rows: ChannelRow[] = [
{ channel: 'Прямые продажи', gross: 12_400_000, refunds: 320_000, change: 8.4 },
{ channel: 'Партнёры', gross: 8_600_000, refunds: 145_000, change: 2.1 },
{ channel: 'Маркетплейсы', gross: 5_100_000, refunds: 890_000, change: -6.3 },
]
const money = new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
maximumFractionDigits: 0,
})
const sum = (pick: (row: ChannelRow) => number) => rows.reduce((total, row) => total + pick(row), 0)
// Колонок четыре — число нужно `colspan` примечания. `columnCount` у `GrTable`
// сюда не доезжает: он обслуживает только строки loading и empty.
const COLUMN_COUNT = 4
</script>
<template>
<GrTable>
<template #header>
<tr>
<th class="px-4 py-3 text-left font-600">
Канал
</th>
<th class="px-4 py-3 text-right font-600">
Выручка
</th>
<th class="px-4 py-3 text-right font-600">
Возвраты
</th>
<th class="px-4 py-3 text-right font-600">
К прошлому кварталу
</th>
</tr>
</template>
<tr v-for="row in rows" :key="row.channel" class="border-t border-[var(--gr-brd)]">
<td class="px-4 py-3">
{{ row.channel }}
</td>
<td class="px-4 py-3 text-right">
{{ money.format(row.gross) }}
</td>
<td class="px-4 py-3 text-right">
{{ money.format(row.refunds) }}
</td>
<td class="px-4 py-3 text-right">
<GrDelta :value="row.change" :precision="1" suffix="%" show-arrow />
</td>
</tr>
<!--
`GrTable` ячейки не оформляет принципиально, поэтому футер здесь целиком
на потребителе: отбивка, вес, паддинги, выравнивание и число колонок для
`colspan` пишутся руками и живут ровно до первой смены размера таблицы.
Нужен итог, который сам встаёт по колоночной сетке тела и едет за `size`,
шириной и закреплением, — это `summary-row` у `GrDataTable`.
-->
<template #footer>
<tr class="border-t border-[var(--gr-brd)] font-600">
<td class="px-4 py-3">
Итого за квартал
</td>
<td class="px-4 py-3 text-right">
{{ money.format(sum(row => row.gross)) }}
</td>
<td class="px-4 py-3 text-right text-[var(--gr-danger-text)]">
{{ money.format(sum(row => row.refunds)) }}
</td>
<td class="px-4 py-3 text-right">
<GrDelta :value="3.7" :precision="1" suffix="%" show-arrow />
</td>
</tr>
<tr>
<td :colspan="COLUMN_COUNT" class="px-4 py-3 text-[length:var(--gr-control-text-xs)] text-[var(--gr-muted-fg)]">
Возвраты за квартал учтены отдельной строкой и в выручку не входят.
</td>
</tr>
</template>
</GrTable>
</template>Нужен итог, который сам встаёт по колоночной сетке тела и едет за size, шириной и закреплением, — это summaryRow у GrDataTable. Здесь же за свободу платят повтором паддингов на каждой ячейке.
Прокручиваемая область с клавиатуры
maxHeight со stickyHeader превращает таблицу в прокручиваемую область, а regionLabel даёт ей role="region" и имя. Без имени такая область — безымянная ловушка для скринридера; с ним она достижима Tab и листается стрелками.
| Дата | Документ | Контрагент | Счёт | Дебет | Кредит | Сальдо |
|---|---|---|---|---|---|---|
| 01.03.2026 | INV-2026-0001 | Northwind | 62.01 | 1200 ₽ | — | 300 ₽ |
| 02.03.2026 | INV-2026-0002 | Contoso | 62.02 | — | 1800 ₽ | 600 ₽ |
| 03.03.2026 | INV-2026-0003 | Fabrikam | 62.03 | 3600 ₽ | — | 900 ₽ |
| 04.03.2026 | INV-2026-0004 | Tailspin | 62.01 | — | 3600 ₽ | 1200 ₽ |
| 05.03.2026 | INV-2026-0005 | Northwind | 62.02 | 6000 ₽ | — | 1500 ₽ |
| 06.03.2026 | INV-2026-0006 | Contoso | 62.03 | — | 5400 ₽ | 1800 ₽ |
| 07.03.2026 | INV-2026-0007 | Fabrikam | 62.01 | 8400 ₽ | — | 2100 ₽ |
| 08.03.2026 | INV-2026-0008 | Tailspin | 62.02 | — | 7200 ₽ | 2400 ₽ |
| 09.03.2026 | INV-2026-0009 | Northwind | 62.03 | 10800 ₽ | — | 2700 ₽ |
| 01.03.2026 | INV-2026-0010 | Contoso | 62.01 | — | 9000 ₽ | 3000 ₽ |
| 02.03.2026 | INV-2026-0011 | Fabrikam | 62.02 | 13200 ₽ | — | 3300 ₽ |
| 03.03.2026 | INV-2026-0012 | Tailspin | 62.03 | — | 10800 ₽ | 3600 ₽ |
| 04.03.2026 | INV-2026-0013 | Northwind | 62.01 | 15600 ₽ | — | 3900 ₽ |
| 05.03.2026 | INV-2026-0014 | Contoso | 62.02 | — | 12600 ₽ | 4200 ₽ |
<script setup lang="ts">
import { GrTable } from '@feugene/granularity'
interface LedgerRow {
date: string
document: string
counterparty: string
account: string
debit: string
credit: string
balance: string
}
const rows: LedgerRow[] = Array.from({ length: 14 }, (_, index) => ({
date: `0${(index % 9) + 1}.03.2026`,
document: `INV-2026-${String(index + 1).padStart(4, '0')}`,
counterparty: ['Northwind', 'Contoso', 'Fabrikam', 'Tailspin'][index % 4],
account: `62.0${(index % 3) + 1}`,
debit: index % 2 === 0 ? `${(index + 1) * 1200} ₽` : '—',
credit: index % 2 === 0 ? '—' : `${(index + 1) * 900} ₽`,
balance: `${(index + 1) * 300} ₽`,
}))
</script>
<template>
<GrTable
region-label="Оборотная ведомость за март"
max-height="260px"
sticky-header
>
<template #header>
<tr>
<th class="px-4 py-3 text-left font-600">Дата</th>
<th class="px-4 py-3 text-left font-600">Документ</th>
<th class="px-4 py-3 text-left font-600">Контрагент</th>
<th class="px-4 py-3 text-left font-600">Счёт</th>
<th class="px-4 py-3 text-right font-600">Дебет</th>
<th class="px-4 py-3 text-right font-600">Кредит</th>
<th class="px-4 py-3 text-right font-600">Сальдо</th>
</tr>
</template>
<tr
v-for="row in rows"
:key="row.document"
class="border-t border-[var(--gr-brd)]"
>
<td class="px-4 py-3 whitespace-nowrap">{{ row.date }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ row.document }}</td>
<td class="px-4 py-3 whitespace-nowrap text-[var(--gr-muted-fg)]">{{ row.counterparty }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ row.account }}</td>
<td class="px-4 py-3 text-right whitespace-nowrap">{{ row.debit }}</td>
<td class="px-4 py-3 text-right whitespace-nowrap">{{ row.credit }}</td>
<td class="px-4 py-3 text-right font-600 whitespace-nowrap">{{ row.balance }}</td>
</tr>
</GrTable>
</template>Шкала размеров
GrTable — тонкий контейнер, поэтому size задаёт базовый кегль таблицы; паддинги ячеек остаются за потребителем.
| Plan | Seats | Price |
|---|---|---|
| Starter | 3 | $12 |
| Team | 25 | $79 |
| Plan | Seats | Price |
|---|---|---|
| Starter | 3 | $12 |
| Team | 25 | $79 |
| Plan | Seats | Price |
|---|---|---|
| Starter | 3 | $12 |
| Team | 25 | $79 |
| Plan | Seats | Price |
|---|---|---|
| Starter | 3 | $12 |
| Team | 25 | $79 |
<script setup lang="ts">
import { GrTable } from '@feugene/granularity'
const sizes = ['xs', 'sm', 'md', 'lg'] as const
const rows = [
{ plan: 'Starter', seats: 3, price: '$12' },
{ plan: 'Team', seats: 25, price: '$79' },
]
</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>
<GrTable :size="size" aria-label="Plans">
<template #header>
<tr>
<th class="px-4 py-2 text-left">
Plan
</th>
<th class="px-4 py-2 text-right">
Seats
</th>
<th class="px-4 py-2 text-right">
Price
</th>
</tr>
</template>
<tr v-for="row in rows" :key="row.plan" class="border-t border-[var(--gr-brd)]">
<td class="px-4 py-2">
{{ row.plan }}
</td>
<td class="px-4 py-2 text-right">
{{ row.seats }}
</td>
<td class="px-4 py-2 text-right">
{{ row.price }}
</td>
</tr>
</GrTable>
</div>
</div>
</template>Доступность
- Паттерн APG
—- Клавиши
- при
maxHeightили горизонтальном переполнении область прокрутки — таб-стоп (tabindex="0"), листается стрелками иPageUp/PageDown. СregionLabelона получаетrole="region"и имя: безымянную область скринридер объявляет просто «регион»