GrDataTable
Берут, когда строки сортируют.
Обзор
Когда брать
- строки сортируют — по клику на заголовок; над серверной пагинацией
externalSortуводит порядок в запрос, вместо того чтобы врать по текущей странице; - строки выбирают —
selectableвместе с частичным выбором в заголовке; - под набором нужен итог —
summaryRowставит его в<tfoot>по той же колоночной сетке, что и тело; - колонки настраивает пользователь — порядок (
columnOrder) и ширина (columnWidths) переносятся мышью и с клавиатуры; - строк тысячи —
virtualсоstickyHeaderдержит и прокрутку, и заголовок.
Когда взять другое
| Нужно | Берите |
|---|---|
| Разметку ячеек пишете сами | GrTable |
| Столбцов нет, строки однородные | GrList |
| Строки вложены | GrTree |
| Порядок строк меняет пользователь | GrSortableList |
| Строк больше, чем помещается | GrPagination |
| Тренд в ячейке | GrSparkline |
Playground контролы выводятся из API, а не пишутся руками
Загружается…
0 изменено· состояние — в адресе
<GrDataTable
:columns="columns"
:rows="rows"
rowKey="id"
initialSortKey="due"
/>Примеры 8 у каждого — живое демо и его код
Колонки данными, ячейки слотами
Колонки описаны один раз:
sortableделает заголовок кнопкой,alignуводит деньги вправо. Значения остаются сырыми — ISO-дата, число, — поэтому сортируются как даты и числа, а#cell-<key>их только оформляет. Сsort-cycle="asc-desc-none"третий клик возвращает исходный порядок;@row-clickоткрывает счёт.InvoicesClick a row to open the invoiceInvoices StatusTailspin ToysINV-0419Paid Sep 30 €2,150.00 Contoso LtdINV-0420Overdue Oct 2 €432.00 Northwind TradersINV-0421Sent Oct 21 €1,284.50 Wide World ImportersINV-0417Sent Oct 28 €3,780.00 Fabrikam IncINV-0416Draft Nov 4 €96.00 Выбор, с которым работает панель
selectableдобавляет колонку флажков с частичным состоянием в шапке, аv-model:selectedотдаёт ключи экрану — панель считает сумму и рассылает напоминания.selectable-rowоставляет оплаченный счёт без флажка и вне «выбрать все».2 selected · €3,033.40Overdue invoices CustomerLast reminderAmountContoso LtdINV-04204 days Never €432.00 LitwareINV-041211 days Sep 28 €1,290.00 Adventure WorksINV-0409Paid Paid today €640.00 ProsewareINV-040523 days Sep 20 €2,875.40 Fourth CoffeeINV-040131 days Sep 14 €158.00 Второй ярус, который грузится при раскрытии
expandableдобавляет колонку с кнопкой, а#detailрисует ярус под строкой.load-detailзагружает его при первом раскрытии сAbortSignal— сверните строку раньше, и запрос отменится; пока он идёт, стоит скелет.accordionдержит открытым один заказ, а состояние привязано к ключу строки, а не к её месту.Orders this week OrderCustomerPlacedTotal#10482 Northwind Traders Oct 6 €1,218.00 #10481 Contoso Ltd Oct 6 €948.00 #10480 Tailspin Toys Oct 5 €292.00 #10479 Fabrikam Inc Oct 5 €1,180.00 #10478 Wide World Importers Oct 4 €1,734.00 #10477 Adventure Works Oct 4 €380.00 #10476 Litware Oct 3 €1,920.00 #10475 Proseware Oct 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 SKUProductCategoryIn stockReservedPriceSupplierCH-201 Ergonomic chair Seating 48 12 €289 Herman & Co DK-140 Standing desk Desks 9 7 €640 Flexi Works AR-032 Monitor arm Accessories 132 18 €79 Ergotron LP-011 Desk lamp Lighting 0 0 €54 Lumen Lab TB-410 Meeting table Desks 3 1 €1,180 Flexi Works
API порождается из пакета · разойтись не может
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
rowsобязательный | TRow[] | — | Строки набора. Сортирует их компонент, пока не включён `externalSort`; ключ строки — из `rowKey`. |
columnsобязательный | GrDataColumn<TRow>[] | — | Колонки: поле строки, заголовок, сортировка, выравнивание, ширина и закрепление. Порядок массива — исходный порядок колонок в таблице. |
rowKey | GrDataTableRowKey<TRow> | undefined | "id" as GrDataTableRowKey<TRow> | Ключ строки или функция-резолвер. По умолчанию — поле `'id'`. |
initialSortKey | string | undefined | undefined | Ключ колонки для начальной сортировки (uncontrolled-режим). |
initialSortDir | GrDataTableSortDir | undefined | "asc" | Направление начальной сортировки (uncontrolled-режим). |
sortKeyv-model | string | undefined | undefined | Контролируемый ключ сортировки: `v-model:sortKey`. Задаёт controlled-режим. |
sortDirv-model | GrDataTableSortDir | undefined | undefined | Контролируемое направление сортировки: `v-model:sortDir`. |
sortCycle | GrDataTableSortCycle | undefined | "asc-desc" | Клик по заголовку: `asc → desc` или `asc → desc → без сортировки`. |
externalSort | boolean | undefined | false | Внешняя сортировка (например серверная): компонент НЕ сортирует `rows` сам, а только сообщает о смене через `update:sortKey`/`update:sortDir`/ `sortChange`. `rows` при этом должны приходить уже отсортированными. |
selectable | boolean | undefined | false | Выбор строк: добавляет ведущую колонку с чекбоксами (+ «выбрать все» в шапке). Выбранные ключи строк — через `v-model:selected`. |
selectedv-model | (string | number)[] | undefined | undefined | Контролируемый список выбранных ключей строк: `v-model:selected`. |
selectableRow | ((row: TRow) => boolean) | undefined | undefined | Предикат «строку можно выбрать». Невыбираемые строки не попадают и в «выбрать все». |
loading | boolean | undefined | false | Состояние загрузки: тело таблицы заменяется строкой-индикатором. `empty`-состояние при этом не показывается. |
loadingText | string | undefined | undefined | Текст индикатора загрузки. i18n: fallback `gr.dataTable.loading`. |
emptyText | string | undefined | undefined | Текст пустого состояния. i18n: fallback `gr.dataTable.empty`; слот `#empty` сильнее. |
rowClass | string | ((row: TRow, index: number) => string | undefined) | undefined | undefined | Класс строки: общий для всех либо вычисляемый по строке. |
rowProps | ((row: TRow, index: number) => Record<string, unknown> | undefined) | undefined | undefined | Произвольные атрибуты строки (`data-*`, `title`, …). |
summaryRow | Partial<Record<GrDataColumnKey<TRow>, unknown>> | null | undefined | undefined | Итоговая строка в `<tfoot>`: значения по ключам колонок. Встаёт по той же колоночной сетке, что и тело, — с теми же паддингами, выравниванием, ширинами и закреплением. Собранная руками в `#footer`, она этих классов не получает (`GrTable` их принципиально не даёт) и разъезжается с телом на первой же смене `size`. Итог не суммируется компонентом: что считать итогом — знает приложение. Оформление ячейки — слот `#summary-<key>`. `null` равнозначен отсутствию: `totals ?? null` — обычная запись там, где итог считается не всегда. |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | Размер таблицы: кегль, паддинги ячеек, стрелки сортировки и чекбоксы. Прокидывается в `GrTable`. |
caption | string | undefined | undefined | Подпись таблицы в скрытом `<caption>` — диктор читает её первой. Слот `#caption` сильнее. |
ariaLabel | string | undefined | undefined | Доступное имя `<table>`, когда подписи нет. Игнорируется, если задан `ariaLabelledby`. |
ariaLabelledby | string | undefined | undefined | `id` видимого заголовка, который называет таблицу через `aria-labelledby`. |
regionLabel | string | undefined | undefined | Имя прокручиваемой обёртки: с ним она становится областью `role="region"`. |
stickyHeader | boolean | undefined | false | Прилипающий заголовок при вертикальном скролле (нужен `maxHeight`). |
virtual | boolean | undefined | false | Виртуализация строк: в DOM живёт только окно вокруг вьюпорта. Высоту окна задаёт `maxHeight`, без него окна прокрутки не существует. Включает фиксированную раскладку таблицы: ширина колонки считается по содержимому всех строк, а в DOM их только окно — без фиксации колонки прыгали бы на каждой прокрутке. Ширины стоит задать через `width` у колонок, иначе фиксированная раскладка делит место поровну. |
maxHeight | string | number | undefined | undefined | Максимальная высота таблицы (вертикальный скролл). Число — в пикселях. |
reorderableColumns | boolean | undefined | false | Пользовательский порядок колонок: в шапке появляется ручка переноса. Колонка тянется указателем и двигается `Shift`+`←`/`→` с клавиатуры. |
columnOrderv-model | string[] | undefined | undefined | Контролируемый порядок колонок — ключи в нужном порядке (`v-model:columnOrder`). Не задан — порядок компонент помнит сам, начиная с порядка `columns`. |
resizableColumns | boolean | undefined | false | Пользовательская ширина колонок: у правого края заголовка появляется ручка. Тянется указателем, с клавиатуры — стрелками (паттерн window splitter). Включает фиксированную раскладку таблицы: без неё браузер пересчитывает ширины по содержимому и заданная пользователем пропадает. |
columnWidthsv-model | Record<string, number> | undefined | undefined | Контролируемые ширины в пикселях по ключу колонки (`v-model:columnWidths`). Не заданы — ширины компонент помнит сам. |
pinnableColumns | boolean | undefined | false | Закрепление колонок задаёт пользователь: в шапке появляется меню с «закрепить слева / справа / открепить». Без пропа `pinned` у колонки остаётся тем, чем был, — объявлением в конфиге, которое меняется только кодом. |
pinnedColumnsv-model | Record<string, GrColumnPin> | undefined | undefined | Контролируемое закрепление по ключу колонки (`v-model:pinnedColumns`). Не задано — компонент помнит его сам, начиная с `pinned` у колонок. |
cellNavigation | boolean | undefined | false | Клавиатура по ячейкам: паттерн `grid` из WAI-ARIA APG. Вся таблица становится **одной** остановкой `Tab`, внутрь ведут стрелки, а в содержимое ячейки — `Enter` или `F2`. Поэтому проп отдельный, а не поведение по умолчанию: он переставляет таб-порядок у всех, кто таблицу уже поставил. |
expandable | boolean | undefined | false | Второй ярус строки: колонка с кнопкой раскрытия и блок подробностей под строкой. Содержимое рисует слот `#detail`. |
expandedKeysv-model | (string | number)[] | undefined | undefined | Контролируемый список раскрытых ключей (`v-model:expandedKeys`). |
expandableRow | ((row: TRow) => boolean) | undefined | undefined | У какой строки есть второй ярус. Остальные кнопки не получают. |
loadDetail | ((row: TRow, signal: AbortSignal) => Promise<unknown>) | undefined | undefined | Подгрузка подробностей в момент раскрытия. Без неё содержимое берётся из самой строки. `signal` обязателен: строку сворачивают чаще, чем ждут. |
accordion | boolean | undefined | false | Раскрыта не больше одной строки. |
expandColumn | boolean | undefined | true | Рисовать ли служебную колонку с собственной кнопкой раскрытия. `false` — колонки нет, и раскрытием управляет разметка потребителя: слоты ячеек получают `expanded` и `toggleExpand`, чтобы кнопку можно было поставить в свой столбец действий рядом с остальными. Тогда `aria-expanded` и доступное имя ложатся на неё же — компоненту их вешать не на что. |
Слоты
| Slot | Type | Описание |
|---|---|---|
caption | any | Подпись таблицы — `<caption>`, читается диктором первой. |
loading | any | Содержимое, пока едут данные. |
empty | any | Пустое состояние вместо текста по умолчанию. |
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 |
| `loadDetail` отказал; в ярусе — разметка ошибки с кнопкой повтора. Отменённые запросы сюда не приходят. |
Типы
Все экспортируемые типы подпути, по файлам, где они объявлены. Имена, дженерики и тела читаются из файлов деклараций пакета и разойтись не могут.
import type { GrDataColumnKey, GrDataColumn, GrColumnPinSide } from '@feugene/granularity/components/GrDataTable'Файлdist/types/components/GrDataTable/GrDataTable.vue.d.ts
Ключ колонки. Собственные поля строки подсказываются автодополнением, но
произвольная строка тоже допустима: колонка может быть вычисляемой и жить
только в слоте #cell-<key>.
type GrDataColumnKey<TRow extends Record<string, unknown> = Record<string, unknown>> = Extract<keyof TRow, string> | (string & {});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;
};Край, к которому липнет колонка.
type GrColumnPinSide = 'left' | 'right';Закрепление, выбранное пользователем. null — не «как в конфиге», а
«откреплена»: без него снять закрепление, объявленное у колонки, было бы
нечем — отсутствие ключа означает «решает конфиг».
type GrColumnPin = GrColumnPinSide | null;Значения итоговой строки по ключам колонок. Не строка набора: у итога нет ключа, он не сортируется и не выбирается. Колонка, для которой значения нет, остаётся пустой ячейкой — заполнять все колонки итог не обязан.
type GrDataTableSummary<TRow extends Record<string, unknown> = Record<string, unknown>> = Partial<Record<GrDataColumnKey<TRow>, unknown>>;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>> {Порядок обхода состояний сортировки по клику на заголовок.
type GrDataTableSortCycle = 'asc-desc' | 'asc-desc-none';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;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
Сравнение значений ячеек для сортировки GrDataTable.
Логика вынесена из SFC: у неё есть крайние случаи, которые дешевле проверить
прямым вызовом, чем через монтирование таблицы.
Прежняя реализация приводила значение к числу через Number(value), а он
считает нулём и null, и пустую строку: пустые ячейки оседали в середине
числового ряда, между отрицательными и положительными. Здесь пустое значение —
отдельная категория, и она всегда в конце, независимо от направления.
type GrDataTableSortDir = 'asc' | 'desc';Файлdist/types/components/GrDataTable/config.d.ts
const grDataTableConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrDataTable">;Файлdist/types/components/GrDataTable/defaults.d.ts
Пропы GrDataTable, настраиваемые глобально через componentDefaults.
interface GrDataTableConfigurableProps {
size: GrComponentSize;
}Файлdist/types/components/GrDataTable/grDataTableStyles.d.ts
type GrDataTableSize = GrComponentSize;Файлdist/types/components/GrDataTable/safelist.d.ts
Пуст: составных классов у таблицы нет. Шаблон, мапы размеров и классы
grDataTableStyles.ts, которые раздают и композаблы, лежат целыми
литералами, и granum извлекает их из чанков компонента сам, включая общие.
Полоса и подсветка строк собираются в GrTable и приходят с его safelist.
const grDataTableSafelist: string[];Файлdist/types/components/GrDataTable/index.d.ts
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 возвращает фокус ячейке; контролы внутри ячеек из обхода выходят |
Полный контракт пакета: docs/keyboard.md ↗
Использование из прозы библиотеки
Ключ строки — не формальность
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.
Три правила, которые стоит знать до того, как это удивит:
- закреплённые колонки всегда стоят группой у своего края — порядок внутри группы пользовательский, но перенести колонку из группы в группу нельзя, иначе «закреплена слева» перестало бы означать «слева»;
- колонка выбора закрепляется вместе с левой группой — иначе чекбокс уезжал бы из-под строки, которая осталась на месте;
- у закреплённой ячейки непрозрачный фон (иначе сквозь неё просвечивает то, что уезжает под низ), поэтому подсветку выбранной строки она повторяет сама.
Закрепление задаёт пользователь
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.
<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, поэтому
кнопка ставится в свой столбец действий рядом с остальными:
<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 включает загрузку в момент раскрытия:
<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.
Чего таблица не делает
Нет группировки строк и редактирования ячеек — это другой класс таблицы.
Пропы этого примера