GrDataTable

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

Берут, когда строки сортируют.

Когда брать

  • строки сортируют — по клику на заголовок; над серверной пагинацией 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

PropTypeпо умолчаниюОписание
rowsобязательныйTRow[]
columnsобязательныйGrDataColumn<TRow>[]
rowKeyGrDataTableRowKey<TRow> | undefined"id" as GrDataTableRowKey<TRow>Ключ строки или функция-резолвер. По умолчанию — поле `'id'`.
initialSortKeystring | undefinedundefinedКлюч колонки для начальной сортировки (uncontrolled-режим).
initialSortDirGrDataTableSortDir | undefined"asc"Направление начальной сортировки (uncontrolled-режим).
sortKeystring | undefinedundefinedКонтролируемый ключ сортировки: `v-model:sortKey`. Задаёт controlled-режим.
sortDirGrDataTableSortDir | undefinedundefinedКонтролируемое направление сортировки: `v-model:sortDir`.
sortCycleGrDataTableSortCycle | undefined"asc-desc"Клик по заголовку: `asc → desc` или `asc → desc → без сортировки`.
externalSortboolean | undefinedfalseВнешняя сортировка (например серверная): компонент НЕ сортирует `rows` сам, а только сообщает о смене через `update:sortKey`/`update:sortDir`/ `sortChange`. `rows` при этом должны приходить уже отсортированными.
selectableboolean | undefinedfalseВыбор строк: добавляет ведущую колонку с чекбоксами (+ «выбрать все» в шапке). Выбранные ключи строк — через `v-model:selected`.
selected(string | number)[] | undefinedundefinedКонтролируемый список выбранных ключей строк: `v-model:selected`.
selectableRow((row: TRow) => boolean) | undefinedundefinedПредикат «строку можно выбрать». Невыбираемые строки не попадают и в «выбрать все».
loadingboolean | undefinedfalseСостояние загрузки: тело таблицы заменяется строкой-индикатором. `empty`-состояние при этом не показывается.
loadingTextstring | undefinedundefinedТекст индикатора загрузки. i18n: fallback `gr.dataTable.loading`.
emptyTextstring | undefinedundefinedТекст пустого состояния. i18n: fallback `gr.dataTable.empty`; слот `#empty` сильнее.
rowClassstring | ((row: TRow, index: number) => string | undefined) | undefinedundefinedКласс строки: общий для всех либо вычисляемый по строке.
rowProps((row: TRow, index: number) => Record<string, unknown> | undefined) | undefinedundefinedПроизвольные атрибуты строки (`data-*`, `title`, …).
summaryRowPartial<Record<GrDataColumnKey<TRow>, unknown>> | null | undefinedundefinedИтоговая строка в `<tfoot>`: значения по ключам колонок. Встаёт по той же колоночной сетке, что и тело, — с теми же паддингами, выравниванием, ширинами и закреплением. Собранная руками в `#footer`, она этих классов не получает (`GrTable` их принципиально не даёт) и разъезжается с телом на первой же смене `size`. Итог не суммируется компонентом: что считать итогом — знает приложение. Оформление ячейки — слот `#summary-<key>`. `null` равнозначен отсутствию: `totals ?? null` — обычная запись там, где итог считается не всегда.
size"xs" | "sm" | "md" | "lg" | undefinedundefinedРазмер таблицы: кегль, паддинги ячеек, стрелки сортировки и чекбоксы. Прокидывается в `GrTable`.
captionstring | undefinedundefined
ariaLabelstring | undefinedundefined
ariaLabelledbystring | undefinedundefined
regionLabelstring | undefinedundefined
stickyHeaderboolean | undefinedfalseПрилипающий заголовок при вертикальном скролле (нужен `maxHeight`).
virtualboolean | undefinedfalseВиртуализация строк: в DOM живёт только окно вокруг вьюпорта. Высоту окна задаёт `maxHeight`, без него окна прокрутки не существует. Включает фиксированную раскладку таблицы: ширина колонки считается по содержимому всех строк, а в DOM их только окно — без фиксации колонки прыгали бы на каждой прокрутке. Ширины стоит задать через `width` у колонок, иначе фиксированная раскладка делит место поровну.
maxHeightstring | number | undefinedundefinedМаксимальная высота таблицы (вертикальный скролл). Число — в пикселях.
reorderableColumnsboolean | undefinedfalseПользовательский порядок колонок: в шапке появляется ручка переноса. Колонка тянется указателем и двигается `Shift`+`←`/`→` с клавиатуры.
columnOrderstring[] | undefinedundefinedКонтролируемый порядок колонок — ключи в нужном порядке (`v-model:columnOrder`). Не задан — порядок компонент помнит сам, начиная с порядка `columns`.
resizableColumnsboolean | undefinedfalseПользовательская ширина колонок: у правого края заголовка появляется ручка. Тянется указателем, с клавиатуры — стрелками (паттерн window splitter). Включает фиксированную раскладку таблицы: без неё браузер пересчитывает ширины по содержимому и заданная пользователем пропадает.
columnWidthsRecord<string, number> | undefinedundefinedКонтролируемые ширины в пикселях по ключу колонки (`v-model:columnWidths`). Не заданы — ширины компонент помнит сам.

Slots

SlotTypeОписание
captionanyПодпись таблицы — `<caption>`, читается диктором первой.
loadinganyСодержимое, пока едут данные.
emptyanyПустое состояние вместо текста по умолчанию.
footer{ columns: GrDataColumn<TRow>[]; totalColumns: number; }Подвал под таблицей: пагинация, счётчик, итоги во всю ширину.

Events

EventTypeОписание
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).

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>

Контролируемый режим нужен для серверной сортировки и синхронизации состояния с URL; без пропов sortKey/sortDir таблица работает в uncontrolled-режиме как прежде.

Сортируемые колонки и стартовый порядок

Базовый сценарий для GrDataTable: передаём rows, columns, стартовую сортировку и сразу проверяем built-in sorting.

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>

Итоговая строка в сетке таблицы

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

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>

Переключите размер: паддинги итога едут вместе с телом. Собранная руками в #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.

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>

Ручная строка платит за свободу: пустую ведущую ячейку под чекбокс, паддинги и выравнивание она переписывает сама, и они привязаны к size. Главный итог поэтому идёт пропом.

Свои ячейки статуса и действий

Ключевой composition-scenario: стандартный data pipeline остаётся у GrDataTable, а конкретные ячейки переопределяются слотами.

Custom Cellsзависит от окружения витрины
<script setup lang="ts">
import { ref } from 'vue'

import type { GrDataColumn } from '@feugene/granularity'
import { GrBadge, GrButton, GrDataTable } from '@feugene/granularity'
import IconTrash from '~icons/lucide/trash2'

const lastAction = ref('No actions yet')

const rows = [
  { id: 1, service: 'Gateway', status: 'ok', owner: 'Core' },
  { id: 2, service: 'Importer', status: 'warning', owner: 'Ops' },
  { id: 3, service: 'Notifier', status: 'danger', owner: 'Growth' },
]

const columns: GrDataColumn[] = [
  { key: 'service', label: 'Service', sortable: true },
  { key: 'owner', label: 'Owner' },
  { key: 'status', label: 'Status' },
  { key: 'actions', label: 'Actions', align: 'right' },
]

function statusVariant(status: unknown): 'success' | 'warning' | 'danger' {
  if (status === 'ok')
    return 'success'

  if (status === 'warning')
    return 'warning'

  return 'danger'
}
</script>

<template>
  <div class="grid gap-3">
    <GrDataTable :rows="rows" :columns="columns" row-key="id">
      <template #cell-status="{ row }">
        <GrBadge size="lg" :tone="statusVariant(row.status)">
          {{ row.status }}
        </GrBadge>
      </template>

      <template #cell-actions="{ row }">
        <div class="flex justify-end gap-2">
          <GrButton size="sm" variant="ghost" @click="lastAction = `Viewed ${row.service}`">
            View
          </GrButton>
          <!-- Icon-only: иконка декоративна, имя кнопки задаётся явно. -->
          <GrButton
            size="sm"
            square
            variant="outline"
            tone="danger"
            :aria-label="`Escalate ${row.service}`"
            @click="lastAction = `Escalated ${row.service}`"
          >
            <IconTrash />
          </GrButton>
        </div>
      </template>
    </GrDataTable>

    <div class="text-sm text-[var(--gr-muted-fg)]">
      {{ lastAction }}
    </div>
  </div>
</template>

Именно slots превращают компонент из «таблицы по данным» в реальный admin/reporting building block.

Фильтрация набора снаружи компонента

Показываем границу ответственности: фильтрация остаётся снаружи, а GrDataTable честно рендерит уже подготовленный набор строк.

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>

Выбор строк, липкий заголовок и загрузка

selectable добавляет ведущую колонку с чекбоксами и «выбрать все» в шапке (модель — v-model:selected по ключам строк). sticky-header + max-height держат заголовок видимым при вертикальном скролле. 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>

«Выбрать все» оперирует только видимыми строками и сохраняет внешние ключи; при клиентской сортировке выбор остаётся по ключам, а не по позициям.

Шкала размеров

В отличие от GrTable, здесь размер ведёт ещё и паддинги ячеек, стрелки сортировки и чекбоксы — таблицу видно плотнее целиком.

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>

Запреты на строке, три состояния сортировки и клик

Строка может быть невыбираемой (selectableRow), подсвеченной (rowClass) и кликабельной (@row-click), а третий клик по заголовку снимает сортировку — sortCycle="asc-desc-none".

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>

Справочник на 10 000 строк

С virtual в DOM живёт только окно вокруг вьюпорта. Распорки здесь — служебные строки, а не псевдоэлементы: <tbody> игнорирует отступы, и произвольную коробку в него не положить.

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>

Виртуализация включает фиксированную раскладку: ширина колонки считается по содержимому всех строк, а в 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 со стрелкой. Клик по заголовку остаётся сортировкой.

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>

С клавиатуры порядок меняется 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 возвращает колонку к авторазметке.

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>

Ручка ширины — 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 — вернуть авторазметку

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

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