GrTable

Пакет: @feugene/granularityядроГруппа: Данные

Берут, когда ячейки оформляет потребитель.

Когда брать

  • ячейки оформляет потребитель — своя разметка <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.
  • fixedLayouttable-layout: fixed. Без него ширины колонок считаются по отрисованному окну и прыгают на каждой прокрутке.

rowCount заодно выключает браузерный якорь прокрутки у скролл-контейнера: он подправляет scrollTop, когда меняется высота содержимого выше видимого узла, — а окно меняет её на каждом кадре, и список уезжает тем дальше, чем грубее оценка строки.

Playground 15

Загружается…

Код
<GrTable />

Установка

npm i @feugene/granularity

Импорт

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

API

Props

PropTypeпо умолчаниюОписание
size"xs" | "sm" | "md" | "lg" | undefinedundefinedРазмер таблицы — базовый кегль текста. Паддинги ячеек оставлены за консьюмером (GrTable — «тонкий» контейнер).
ariaLabelstring | undefinedundefinedПрямой ARIA-label для `<table>`. Игнорируется, если задан `ariaLabelledby`.
loadingboolean | undefinedfalseИдёт загрузка: вместо строк — скелетоны, контейнер помечен `aria-busy`.
hoverableboolean | undefinedfalseПодсветка строки под курсором.
emptyboolean | undefinedundefinedТаблица пуста. По умолчанию определяется по содержимому слота.
emptyTextstring | undefinedundefinedТекст пустого состояния. Слот `#empty` сильнее.
maxHeightstring | number | undefinedundefinedМаксимальная высота скролл-контейнера (включает вертикальный скролл). Число трактуется как пиксели. Нужен для работы `stickyHeader`.
captionstring | undefinedundefinedТекст caption для screen reader'ов. Рендерится как `<caption class="sr-only">`, если не передан слот `#caption`.
ariaLabelledbystring | undefinedundefinedID элемента-заголовка, связанного с `<table>` через `aria-labelledby`.
regionLabelstring | undefinedundefinedARIA-label для скролл-контейнера. Включает `role="region"`; сам скролл достижим с клавиатуры всегда, независимо от метки.
stickyHeaderboolean | undefinedfalseПрилипающий заголовок: `<thead>` остаётся видимым при вертикальном скролле. Осмысленно вместе с `maxHeight` (иначе таблица не скроллится вертикально).
loadingRowsnumber | undefined3Сколько строк-заглушек показать при `loading`.
columnCountnumber | undefined1Сколько колонок занимает служебная строка (пустое состояние и скелетоны).
stripedboolean | undefinedfalseЧередование строк.
rowCountnumber | undefinedundefinedПолное число строк набора, включая строки заголовка (`aria-rowcount`). Нужен, когда в DOM не весь набор — например, при виртуализации: диктор считает строки по разметке и объявил бы «5 из 20» на таблице в десять тысяч. Строки при этом обязаны нести `aria-rowindex`.
fixedLayoutboolean | undefinedfalseФиксированная раскладка (`table-layout: fixed`): ширины колонок берутся из первой строки, а не из содержимого всех. Обязателен, если в DOM не весь набор: иначе ширины считаются по отрисованному окну и прыгают на каждой прокрутке. Класс — arbitrary-значение, а не `table-fixed`: такого правила в `presetMini` нет, и класс молча не превратился бы в CSS.
tableMinWidthstring | number | undefinedundefinedМинимальная ширина самой таблицы (число — пиксели). Нужна при `fixedLayout`: с фиксированной раскладкой и шириной `auto` браузер вписывает таблицу в контейнер и делит место между колонками пропорционально — то есть заданные ширины молча ужимаются, а горизонтальной прокрутки, на которой держатся закреплённые колонки, не возникает вовсе.

Slots

SlotTypeОписание
defaultanyСтроки таблицы, когда разметка пишется руками вместо `data`.
captionanyПодпись таблицы — `<caption>`, читается диктором первой.
headeranyШапка вместо построенной по `columns`.
loadinganyСодержимое, пока едут данные, — вместо строк-заглушек.
emptyanyПустое состояние вместо текста по умолчанию.
footeranyИтоговая строка под таблицей.

Methods / Expose

Methods / ExposeTypeОписание
scrollTo(options: ScrollToOptions) => voidПрокрутить скролл-контейнер таблицы — тот же контракт, что у `GrDataTable`.
scrollToRow(index: number, options?: ScrollIntoViewOptions | undefined) => booleanПрокрутить к строке содержимого по её индексу в разметке. `false` — строки нет в DOM.

Примеры 6

Базовая отрисовка строк

Для базовой страницы показываем canonical table markup: #header slot, body rows и composition с badges.

CampaignOwnerStatusReach
Spring onboardingOliviaReady18.2k
Card migrationMaksimReview9.7k
Payout reminderAnnaPaused6.3k

Basic Rows
<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, когда данные ещё не приехали.

TaskStateUpdated

Loading State
<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.

PresetOwnerValue
No preset rows

Empty State
<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%
Возвраты за квартал учтены отдельной строкой и в выручку не входят.

Footer
<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.2026INV-2026-0001Northwind62.011200 ₽300 ₽
02.03.2026INV-2026-0002Contoso62.021800 ₽600 ₽
03.03.2026INV-2026-0003Fabrikam62.033600 ₽900 ₽
04.03.2026INV-2026-0004Tailspin62.013600 ₽1200 ₽
05.03.2026INV-2026-0005Northwind62.026000 ₽1500 ₽
06.03.2026INV-2026-0006Contoso62.035400 ₽1800 ₽
07.03.2026INV-2026-0007Fabrikam62.018400 ₽2100 ₽
08.03.2026INV-2026-0008Tailspin62.027200 ₽2400 ₽
09.03.2026INV-2026-0009Northwind62.0310800 ₽2700 ₽
01.03.2026INV-2026-0010Contoso62.019000 ₽3000 ₽
02.03.2026INV-2026-0011Fabrikam62.0213200 ₽3300 ₽
03.03.2026INV-2026-0012Tailspin62.0310800 ₽3600 ₽
04.03.2026INV-2026-0013Northwind62.0115600 ₽3900 ₽
05.03.2026INV-2026-0014Contoso62.0212600 ₽4200 ₽

Scroll Region
<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 задаёт базовый кегль таблицы; паддинги ячеек остаются за потребителем.

size="xs"
Plan Seats Price
Starter3$12
Team25$79
size="sm"
Plan Seats Price
Starter3$12
Team25$79
size="md"
Plan Seats Price
Starter3$12
Team25$79
size="lg"
Plan Seats Price
Starter3$12
Team25$79

Sizes
<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" и имя: безымянную область скринридер объявляет просто «регион»

Полный клавиатурный контракт пакета

Документация компонентаВсе компоненты