GrDataTable

  • ядро
  • Данные
  • API · 39 props
  • ⌨ клавиши

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

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

Обзор

Когда брать

  • строки сортируют — по клику на заголовок; над серверной пагинацией externalSort уводит порядок в запрос, вместо того чтобы врать по текущей странице;
  • строки выбирают — selectable вместе с частичным выбором в заголовке;
  • под набором нужен итог — summaryRow ставит его в <tfoot> по той же колоночной сетке, что и тело;
  • колонки настраивает пользователь — порядок (columnOrder) и ширина (columnWidths) переносятся мышью и с клавиатуры;
  • строк тысячи — virtual со stickyHeader держит и прокрутку, и заголовок.

Когда взять другое

НужноБерите
Разметку ячеек пишете самиGrTable
Столбцов нет, строки однородныеGrList
Строки вложеныGrTree
Порядок строк меняет пользовательGrSortableList
Строк больше, чем помещаетсяGrPagination
Тренд в ячейкеGrSparkline

Playground контролы выводятся из API, а не пишутся руками

Сцена

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

0 изменено· состояние — в адресе

Примеры 8 у каждого — живое демо и его код

  • Колонки данными, ячейки слотами

    Колонки описаны один раз: sortable делает заголовок кнопкой, align уводит деньги вправо. Значения остаются сырыми — ISO-дата, число, — поэтому сортируются как даты и числа, а #cell-<key> их только оформляет. С sort-cycle="asc-desc-none" третий клик возвращает исходный порядок; @row-click открывает счёт.

    InvoicesClick a row to open the invoice
    Invoices
    Status
    Tailspin ToysINV-0419
    PaidSep 30€2,150.00
    Contoso LtdINV-0420
    OverdueOct 2€432.00
    Northwind TradersINV-0421
    SentOct 21€1,284.50
    Wide World ImportersINV-0417
    SentOct 28€3,780.00
    Fabrikam IncINV-0416
    DraftNov 4€96.00
  • Выбор, с которым работает панель

    selectable добавляет колонку флажков с частичным состоянием в шапке, а v-model:selected отдаёт ключи экрану — панель считает сумму и рассылает напоминания. selectable-row оставляет оплаченный счёт без флажка и вне «выбрать все».

    2 selected · €3,033.40
    Overdue invoices
    Customer
    Last reminder
    Amount
    Contoso LtdINV-0420
    4 daysNever€432.00
    LitwareINV-0412
    11 daysSep 28€1,290.00
    Adventure WorksINV-0409
    PaidPaid today€640.00
    ProsewareINV-0405
    23 daysSep 20€2,875.40
    Fourth CoffeeINV-0401
    31 daysSep 14€158.00
  • Второй ярус, который грузится при раскрытии

    expandable добавляет колонку с кнопкой, а #detail рисует ярус под строкой. load-detail загружает его при первом раскрытии с AbortSignal — сверните строку раньше, и запрос отменится; пока он идёт, стоит скелет. accordion держит открытым один заказ, а состояние привязано к ключу строки, а не к её месту.

    Orders this week
    Order
    Customer
    Placed
    Total
    #10482Northwind TradersOct 6€1,218.00
    #10481Contoso LtdOct 6€948.00
    #10480Tailspin ToysOct 5€292.00
    #10479Fabrikam IncOct 5€1,180.00
    #10478Wide World ImportersOct 4€1,734.00
    #10477Adventure WorksOct 4€380.00
    #10476LitwareOct 3€1,920.00
    #10475ProsewareOct 2€280.00
  • Колонки, которые расставляет пользователь

    Три пропа отдают раскладку пользователю: reorderable-columns добавляет ручку переноса, resizable-columns — край, за который тянуть, pinnable-columns — меню закрепления слева или справа. У каждого свой v-model — порядок, ширины, закрепления, — и экран может сохранить раскладку и вернуть её.

    Drag a header by its handle, pull its edge, or pin it from its menu.
    Stock by product
    SKU
    Product
    Category
    In stock
    Reserved
    Price
    Supplier
    CH-201Ergonomic chairSeating4812€289Herman & Co
    DK-140Standing deskDesks97€640Flexi Works
    AR-032Monitor armAccessories13218€79Ergotron
    LP-011Desk lampLighting00€54Lumen Lab
    TB-410Meeting tableDesks31€1,180Flexi Works

API порождается из пакета · разойтись не может

Props

PropTypeпо умолчаниюОписание
rowsобязательныйTRow[]—Строки набора. Сортирует их компонент, пока не включён `externalSort`; ключ строки — из `rowKey`.
columnsобязательныйGrDataColumn<TRow>[]—Колонки: поле строки, заголовок, сортировка, выравнивание, ширина и закрепление. Порядок массива — исходный порядок колонок в таблице.
rowKeyGrDataTableRowKey<TRow> | undefined"id" as GrDataTableRowKey<TRow>Ключ строки или функция-резолвер. По умолчанию — поле `'id'`.
initialSortKeystring | undefinedundefinedКлюч колонки для начальной сортировки (uncontrolled-режим).
initialSortDirGrDataTableSortDir | undefined"asc"Направление начальной сортировки (uncontrolled-режим).
sortKeyv-modelstring | undefinedundefinedКонтролируемый ключ сортировки: `v-model:sortKey`. Задаёт controlled-режим.
sortDirv-modelGrDataTableSortDir | 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`.
selectedv-model(string | number)[] | undefinedundefinedКонтролируемый список выбранных ключей строк: `v-model:selected`.
selectableRow((row: TRow) => boolean) | undefinedundefinedПредикат «строку можно выбрать». Невыбираемые строки не попадают и в «выбрать все».
loadingboolean | undefinedfalseСостояние загрузки: тело таблицы заменяется строкой-индикатором. `empty`-состояние при этом не показывается.
loadingTextstring | undefinedundefinedТекст индикатора загрузки. i18n: fallback `gr.dataTable.loading`.

Слоты

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

События

EventСигнатураОписание
update:sortKey(e: "update:sortKey", value: string): voidКлюч сортировки сменился кликом по заголовку (`v-model:sortKey`). Пустая строка — сортировка снята.
update:sortDir(e: "update:sortDir", value: GrDataTableSortDir): voidНаправление сортировки сменилось (`v-model:sortDir`).
sortChange(e: "sortChange", value: { key: string; dir: GrDataTableSortDir; }): voidСортировка сменилась — ключ и направление одним событием: удобно перезапросить данные с сервера.
update:selected(e: "update:selected", value: (string | number)[]): voidВыбор строк изменился (`v-model:selected`): ключи выбранных строк, включая скрытые из набора.
rowClick(e: "rowClick", payload: { row: TRow; index: number; event: MouseEvent; }): voidКлик по строке. `index` — позиция в текущем, уже отсортированном порядке.
update:columnOrder(e: "update:columnOrder", value: string[]): voidПользователь переставил колонку (`v-model:columnOrder`): ключи в новом порядке.
columnReorder(e: "columnReorder", payload: { key: string; from: number; to: number; }): voidОдна перестановка колонки — откуда и куда; удобно сохранять операцию, а не весь список.
update:columnWidths(e: "update:columnWidths", value: Record<string, number>): voidШирины колонок изменились (`v-model:columnWidths`) — и по ходу протяжки ручки.
columnResize(e: "columnResize", payload: { key: string; width: number; }): voidШирина колонки задана: на конце жеста и на каждом шаге клавиатуры. `0` — колонка вернулась к авторазметке.
update:pinnedColumns(e: "update:pinnedColumns", value: Record<string, GrColumnPin>): voidПользователь сменил закрепление колонок (`v-model:pinnedColumns`); `null` — колонка откреплена.
columnPin(e: "columnPin", payload: { key: string; pinned: GrColumnPin; }): voidКолонку закрепили у края или открепили (`null`) через меню заголовка.
update:expandedKeys(e: "update:expandedKeys", value: (string | number)[]): voidНабор раскрытых строк изменился (`v-model:expandedKeys`).
expand(e: "expand", payload: { row: TRow; key: string | number; }): voidСтроку раскрыли — её второй ярус показан.
collapse(e: "collapse", payload: { row: TRow; key: string | number; }): voidСтроку свернули. Строки, закрытые `accordion` при раскрытии соседней, события не дают.
detailLoadError
(e: "detailLoadError", payload: { row: TRow; key: string | n(e: "detailLoadError", payload: { row: TRow; key: string | number; error: unknown; }): void
`loadDetail` отказал; в ярусе — разметка ошибки с кнопкой повтора. Отменённые запросы сюда не приходят.

Типы

Все экспортируемые типы подпути, по файлам, где они объявлены. Имена, дженерики и тела читаются из файлов деклараций пакета и разойтись не могут.

Импорт
import type { GrDataColumnKey, GrDataColumn, GrColumnPinSide } from '@feugene/granularity/components/GrDataTable'

Файлdist/types/components/GrDataTable/GrDataTable.vue.d.ts

GrDataColumnKeytype

Ключ колонки. Собственные поля строки подсказываются автодополнением, но произвольная строка тоже допустима: колонка может быть вычисляемой и жить только в слоте #cell-<key>.

type GrDataColumnKey<TRow extends Record<string, unknown> = Record<string, unknown>> = Extract<keyof TRow, string> | (string & {});
GrDataColumntype
type GrDataColumn<TRow extends Record<string, unknown> = Record<string, unknown>> = {
    key: GrDataColumnKey<TRow>;
    label: string;
    /**
     * Заголовок колонки сортирует.
     *
     * Сортируется **переданный массив `rows`**, и только он. Над серверной
     * пагинацией это врёт: «максимальное списание» покажет максимум по текущей
     * странице, а прочитается как максимум по журналу. Данные приходят
     * постранично — включайте `externalSort` и сортируйте запросом.
     */
    sortable?: boolean;
    align?: 'left' | 'center' | 'right';
    /**
     * Ширина колонки: число — пиксели, строка — как есть (`'40%'`, `'12rem'`).
     *
     * Уезжает в заголовочную ячейку: при фиксированной раскладке ширины колонок
     * задаёт первая строка таблицы, а это и есть `<thead>`.
     */
    width?: string | number;
    /**
     * Колонка липнет к краю при горизонтальной прокрутке.
     *
     * Закреплённые колонки всегда стоят своей группой у своего края — перенос
     * колонки из группы в группу запрещён, иначе «закреплена слева» перестало бы
     * означать «слева».
     */
    pinned?: GrColumnPinSide;
};
GrColumnPinSidetype

Край, к которому липнет колонка.

type GrColumnPinSide = 'left' | 'right';
GrColumnPintype

Закрепление, выбранное пользователем. null — не «как в конфиге», а «откреплена»: без него снять закрепление, объявленное у колонки, было бы нечем — отсутствие ключа означает «решает конфиг».

type GrColumnPin = GrColumnPinSide | null;
GrDataTableSummarytype

Значения итоговой строки по ключам колонок. Не строка набора: у итога нет ключа, он не сортируется и не выбирается. Колонка, для которой значения нет, остаётся пустой ячейкой — заполнять все колонки итог не обязан.

type GrDataTableSummary<TRow extends Record<string, unknown> = Record<string, unknown>> = Partial<Record<GrDataColumnKey<TRow>, unknown>>;
GrDataTableRowKeytype
type GrDataTableRowKey<TRow extends Record<string, unknown> = Record<string, unknown>> = string | ((row: TRow) => string | number);
/** Порядок обхода состояний сортировки по клику на заголовок. */
export type GrDataTableSortCycle = 'asc-desc' | 'asc-desc-none';
export interface GrDataTableProps<TRow extends Record<string, unknown> = Record<string, unknown>> {
GrDataTableSortCycletype

Порядок обхода состояний сортировки по клику на заголовок.

type GrDataTableSortCycle = 'asc-desc' | 'asc-desc-none';
GrDataTablePropsinterface
interface GrDataTableProps<TRow extends Record<string, unknown> = Record<string, unknown>> {
    /** Строки набора. Сортирует их компонент, пока не включён `externalSort`; ключ строки — из `rowKey`. */
    rows: TRow[];
    /**
     * Колонки: поле строки, заголовок, сортировка, выравнивание, ширина и закрепление.
     * Порядок массива — исходный порядок колонок в таблице.
     */
    columns: GrDataColumn<TRow>[];
    /** Ключ строки или функция-резолвер. По умолчанию — поле `'id'`. */
    rowKey?: GrDataTableRowKey<TRow>;
    /** Ключ колонки для начальной сортировки (uncontrolled-режим). */
    initialSortKey?: string;
    /** Направление начальной сортировки (uncontrolled-режим). */
    initialSortDir?: GrDataTableSortDir;
    /** Контролируемый ключ сортировки: `v-model:sortKey`. Задаёт controlled-режим. */
    sortKey?: string;
    /** Контролируемое направление сортировки: `v-model:sortDir`. */
    sortDir?: GrDataTableSortDir;
    /** Клик по заголовку: `asc → desc` или `asc → desc → без сортировки`. */
    sortCycle?: GrDataTableSortCycle;
    /**
     * Внешняя сортировка (например серверная): компонент НЕ сортирует `rows`
     * сам, а только сообщает о смене через `update:sortKey`/`update:sortDir`/
     * `sortChange`. `rows` при этом должны приходить уже отсортированными.
     */
    externalSort?: boolean;
    /**
     * Выбор строк: добавляет ведущую колонку с чекбоксами (+ «выбрать все» в шапке).
     * Выбранные ключи строк — через `v-model:selected`.
     */
    selectable?: boolean;
    /** Контролируемый список выбранных ключей строк: `v-model:selected`. */
    selected?: Array<string | number>;
    /** Предикат «строку можно выбрать». Невыбираемые строки не попадают и в «выбрать все». */
    selectableRow?: (row: TRow) => boolean;
GrDataTableEmitsinterface
interface GrDataTableEmits<TRow extends Record<string, unknown> = Record<string, unknown>> {
    /**
     * Ключ сортировки сменился кликом по заголовку (`v-model:sortKey`).
     * Пустая строка — сортировка снята.
     */
    (e: 'update:sortKey', value: string): void;
    /** Направление сортировки сменилось (`v-model:sortDir`). */
    (e: 'update:sortDir', value: GrDataTableSortDir): void;
    /** Сортировка сменилась — ключ и направление одним событием: удобно перезапросить данные с сервера. */
    (e: 'sortChange', value: {
        key: string;
        dir: GrDataTableSortDir;
    }): void;
    /** Выбор строк изменился (`v-model:selected`): ключи выбранных строк, включая скрытые из набора. */
    (e: 'update:selected', value: Array<string | number>): void;
    /** Клик по строке. `index` — позиция в текущем, уже отсортированном порядке. */
    (e: 'rowClick', payload: {
        row: TRow;
        index: number;
        event: MouseEvent;
    }): void;
    /** Пользователь переставил колонку (`v-model:columnOrder`): ключи в новом порядке. */
    (e: 'update:columnOrder', value: string[]): void;
    /** Одна перестановка колонки — откуда и куда; удобно сохранять операцию, а не весь список. */
    (e: 'columnReorder', payload: {
        key: string;
        from: number;
        to: number;
    }): void;
    /** Ширины колонок изменились (`v-model:columnWidths`) — и по ходу протяжки ручки. */
    (e: 'update:columnWidths', value: Record<string, number>): void;
    /**
     * Ширина колонки задана: на конце жеста и на каждом шаге клавиатуры.
     * `0` — колонка вернулась к авторазметке.
     */
    (e: 'columnResize', payload: {
        key: string;
        width: number;
    }): void;
    /** Пользователь сменил закрепление колонок (`v-model:pinnedColumns`); `null` — колонка откреплена. */
    (e: 'update:pinnedColumns', value: Record<string, GrColumnPin>): void;
    /** Колонку закрепили у края или открепили (`null`) через меню заголовка. */
    (e: 'columnPin', payload: {
        key: string;
        pinned: GrColumnPin;
    }): void;
    /** Набор раскрытых строк изменился (`v-model:expandedKeys`). */
    (e: 'update:expandedKeys', value: Array<string | number>): void;
    /** Строку раскрыли — её второй ярус показан. */
    (e: 'expand', payload: {
        row: TRow;
        key: string | number;
    }): void;
    /** Строку свернули. Строки, закрытые `accordion` при раскрытии соседней, события не дают. */
    (e: 'collapse', payload: {
        row: TRow;
        key: string | number;
    }): void;
    /**
     * `loadDetail` отказал; в ярусе — разметка ошибки с кнопкой повтора.
     * Отменённые запросы сюда не приходят.
     */
    (e: 'detailLoadError', payload: {
        row: TRow;
        key: string | number;
        error: unknown;
    }): void;
}

Файлdist/types/components/GrDataTable/grDataTableSort.d.ts

GrDataTableSortDirtype

Сравнение значений ячеек для сортировки GrDataTable. Логика вынесена из SFC: у неё есть крайние случаи, которые дешевле проверить прямым вызовом, чем через монтирование таблицы. Прежняя реализация приводила значение к числу через Number(value), а он считает нулём и null, и пустую строку: пустые ячейки оседали в середине числового ряда, между отрицательными и положительными. Здесь пустое значение — отдельная категория, и она всегда в конце, независимо от направления.

type GrDataTableSortDir = 'asc' | 'desc';

Файлdist/types/components/GrDataTable/config.d.ts

grDataTableConfigconst
const grDataTableConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrDataTable">;

Файлdist/types/components/GrDataTable/defaults.d.ts

GrDataTableConfigurablePropsinterface

Пропы GrDataTable, настраиваемые глобально через componentDefaults.

interface GrDataTableConfigurableProps {
    size: GrComponentSize;
}

Файлdist/types/components/GrDataTable/grDataTableStyles.d.ts

GrDataTableSizetype
type GrDataTableSize = GrComponentSize;

Файлdist/types/components/GrDataTable/safelist.d.ts

grDataTableSafelistconst

Пуст: составных классов у таблицы нет. Шаблон, мапы размеров и классы grDataTableStyles.ts, которые раздают и композаблы, лежат целыми литералами, и granum извлекает их из чанков компонента сам, включая общие. Полоса и подсветка строк собираются в GrTable и приходят с его safelist.

const grDataTableSafelist: string[];

Файлdist/types/components/GrDataTable/index.d.ts

GrDataTableInstancetype
type GrDataTableInstance = ComponentExposed<typeof GrDataTableComponent>;

Доступность

Паттерн APG

table + separator, при cell-navigation — grid

Клавиши

Home / EndEnterHome / End

КлавишаДействие
—заголовок сортируемой колонки — обычная кнопка (Enter/Space — сортировать); при reorderable-columns ручка переноса держит одну остановку Tab на шапку: ←/→ — фокус между ручками
—Shift+←/→ — переставить колонку
Home / Endк краям; при resizable-columns ручка ширины — свой таб-стоп с паттерном window splitter: ←/→ — шаг 16px, с Shift — 48px
Enterвернуть авторазметку; при pinnable-columns в заголовке стоит меню закрепления на GrDropdownMenu со своей клавиатурой. При cell-navigation включается паттерн grid: таблица — **одна** остановка Tab, стрелки ходят по ячейкам
Home / Endк краям строки
—Enter/F2 входят в содержимое ячейки
—Escape возвращает фокус ячейке; контролы внутри ячеек из обхода выходят

Использование из прозы библиотеки

Ключ строки — не формальность

rowKey (по умолчанию поле id) участвует и в :key для Vue, и в выборе строк. Строка, у которой значения по ключу нет, получает синтетический ключ, привязанный к идентичности объекта, и в dev-режиме компонент один раз предупреждает об этом в консоль.

Синтетический ключ переживает сортировку, но не переживает перезагрузку данных: после нового fetch он у той же строки будет другим, а значит v-model:selected из прошлого набора не совпадёт ни с чем. Для таблиц с выбором задавайте rowKey явно — полем или функцией:

vue
<GrDataTable :rows="rows" :columns="columns" :row-key="row => `${row.type}:${row.id}`" />
Сортировка

Компонент сортирует сам, пока не задан externalSort (тогда rows приходят уже отсортированными, а таблица только сообщает о смене через update:sortKey/update:sortDir/sortChange).

sortable: true над серверной пагинацией — ловушка. Сортируется переданный массив, то есть текущая страница: колонка «максимальное списание» покажет максимум по двадцати видимым строкам, а прочитается как максимум по журналу. Пришли постраничные данные — сортировка уходит в запрос:

vue
<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> по той же колоночной сетке, что и тело:

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

vue
<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 не подбирался на глаз:

vue
<GrDataTable :rows="rows" :columns="columns">
  <template #footer="{ totalColumns }">
    <tr>
      <td :colspan="totalColumns">
        Возвраты учтены в строке «Итого»
      </td>
    </tr>
  </template>
</GrDataTable>

columns нужен, когда строка считается по нескольким колонкам: порядок там пользовательский (columnOrder), и повторять его руками — значит разойтись с шапкой при первом же переносе колонки.

Виртуализация

virtual вместе с maxHeight оставляет в DOM только окно вокруг вьюпорта.

vue
<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
ts
const table = ref<GrDataTableInstance>()

table.value?.scrollToRow(42) // с `virtual` доводит и до строки вне окна
table.value?.scrollTo({ top: 0 })
table.value?.clearSort()
table.value?.toggleAll()
Порядок колонок

reorderable-columns добавляет в каждую ячейку шапки ручку переноса. Колонка тянется указателем (мышью и пальцем) и двигается с клавиатуры — Shift+←/→ на ручке; результат объявляется в живой регион.

vue
<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.

Три правила, которые стоит знать до того, как это удивит:

  • закреплённые колонки всегда стоят группой у своего края — порядок внутри группы пользовательский, но перенести колонку из группы в группу нельзя, иначе «закреплена слева» перестало бы означать «слева»;
  • колонка выбора закрепляется вместе с левой группой — иначе чекбокс уезжал бы из-под строки, которая осталась на месте;
  • у закреплённой ячейки непрозрачный фон (иначе сквозь неё просвечивает то, что уезжает под низ), поэтому подсветку выбранной строки она повторяет сама.

Закрепление задаёт пользователь

pinnable-columns добавляет в заголовок каждой колонки меню «закрепить слева / справа / открепить», а v-model:pinned-columns хранит выбор по ключу колонки. Без пропа pinned остаётся тем, чем был, — объявлением в конфиге, которое меняется только кодом.

Пункты меню — menuitemradio с checked, а не три команды: закрепление это выбор одного из трёх положений, и в такой форме диктор называет текущее сам. Меню стоит на GrDropdownMenu, поэтому клавиатура, стек слоёв и возврат фокуса приезжают готовыми.

null в модели означает «откреплена», а не «как в конфиге»: без этого различия снять закрепление, объявленное у колонки, было бы нечем — отсутствие ключа как раз и значит «решает конфиг».

Выбор заодно переставляет колонку: группы у краёв — правило выше, и колонка из середины, закреплённая слева, уезжает в начало.

Клавиатура по ячейкам

cell-navigation включает паттерн grid из WAI-ARIA: таблица получает role="grid", занимает одну остановку Tab, и внутрь ведут стрелки.

КлавишаЧто делает
стрелкипо ячейкам: вертикаль по строкам, горизонталь по колонкам
Home / Endк краям строки, а не всей таблицы
Enter, F2войти в содержимое ячейки
Escapeвернуть фокус самой ячейке

Шапка — такая же строка сетки: из неё вниз попадаешь в первую строку данных. Кольцо не замкнуто: строка ниже последней — это не первая, а «дальше некуда».

Проп отдельный, а не поведение по умолчанию. Сетка забирает Tab себе, и у всех, кто таблицу уже поставил, таб-порядок бы переставился.

Контролы внутри ячеек из обхода выходят — сортировка, ручки переноса и ширины, меню колонки, чекбокс, кнопка раскрытия. Иначе остановок было бы столько же, сколько контролов, то есть паттерн не выполнялся бы вовсе: до них добираются стрелкой до ячейки и Enter. Ради этого у GrCheckbox есть проп tabindex — свою остановку он держит литералом, и снаружи её иначе не снять.

С виртуализацией работает. Набор ячеек — отрисованное окно, а не вся таблица: у десяти тысяч строк на шесть колонок ключей было бы шестьдесят тысяч. Шаг за край окна прокручивает список и доводит фокус до появившейся строки.

Второй ярус строки

expandable добавляет служебную колонку с кнопкой; под строкой раскрывается слот #detail.

vue
<GrDataTable :rows="rows" :columns="columns" expandable v-model:expanded-keys="open">
  <template #detail="{ row }">
    <OrderLines :order-id="row.id" />
  </template>
</GrDataTable>

Раскрывает кнопка, а не клик по строке: клик по строке уже занят rowClick и выбором, и раскрытие на нём отняло бы у потребителя навигацию. Кнопка гасит всплытие, поэтому раскрытие не выделяет строку.

Колонка стоит перед колонкой выбора: раскрытие относится ко всей строке, а выбор — к её данным.

Состояние хранится по ключу строки, а не по индексу: сортировка раскрытую строку не путает. accordion оставляет раскрытой одну.

Своя кнопка вместо служебной колонки

expand-column="false" убирает столбец с кнопкой; раскрытием тогда управляет разметка потребителя. Слоты ячеек получают expanded и toggleExpand, поэтому кнопка ставится в свой столбец действий рядом с остальными:

vue
<GrDataTable :rows="rows" :columns="columns" expandable :expand-column="false">
  <template #cell-actions="{ expanded, toggleExpand }">
    <GrButton size="xs" :aria-expanded="expanded" @click="toggleExpand">
      {{ expanded ? 'Свернуть' : 'Подробнее' }}
    </GrButton>
  </template>
</GrDataTable>

aria-expanded в этом режиме ложится на вашу кнопку: своей у компонента нет, и повесить состояние ему не на что. Связь с id строки подробностей через aria-controls тоже остаётся за вами.

Подгрузка

loadDetail включает загрузку в момент раскрытия:

vue
<GrDataTable :load-detail="(row, signal) => api.order(row.id, { signal })" expandable>
  <template #detail="{ data }">{{ data.total }}</template>
</GrDataTable>

Второй аргумент — AbortSignal, и прокинуть его в запрос обязательно: строку сворачивают чаще, чем дожидаются ответа, и свёрнутая строка свой запрос отменяет. Ответ, стартовавший раньше и пришедший позже, проигрывает последнему.

Состояния приходят в слот: state — idle | loading | ready | error, data — результат. У загрузки и отказа есть дефолтная разметка (скелет и кнопка повтора), она переопределяется слотами #detail-loading и #detail-error.

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

Загруженное держится, пока строка жива: свернуть и раскрыть снова — запроса нет. Смена идентичности массива rows кэш сбрасывает (мутация на месте — сохраняет), а точечно его сбрасывает invalidateDetail(key?) из императивного API.

Вместе с virtual

Работает: строка и её второй ярус лежат в одной группе строк, и замер виртуализатора висит на группе. На самой строке он не видел бы высоту подробностей — распорка считалась бы без неё, и список поехал бы молча.

Обернуть две <tr> в таблице больше не во что: группа строк — единственный контейнер, который для них существует. Поэтому тело таблицы здесь собрано группами по одной строке в каждой (rowGroups у GrTable), а не одной <tbody> на всё. Несколько групп в таблице валидны и для скринридера ничего не меняют: номер строки приходит из aria-rowindex, а не из позиции в разметке.

Побочная выгода: ResizeObserver виртуализатора следит за той же группой, так что раскрытие и подгрузка подробностей уточняют высоту сами — сообщать о них отдельно не нужно.

Доступность

Кнопка несёт aria-expanded и aria-controls на строку подробностей; раскрытие и окончание загрузки объявляются в живой регион. Строка подробностей — не строка набора: aria-rowindex она не получает, и aria-rowcount считается по rows.

Чего таблица не делает

Нет группировки строк и редактирования ячеек — это другой класс таблицы.