Skip to content

STable

Обзор

Таблица: виртуализация 100к строк, деревья и группы, sticky-слои, выбор, сортировка, ресайз и DnD.

Usage

Плоская таблица с виртуализацией и сортировкой.

API

Краткий справочник props, slots и events — см. API Reference.

Guide

STable — полнофункциональная таблица на нативных <table>-тегах: виртуализация на миллионы строк (со сжатием прокрутки выше лимита браузера), деревья и группы-статусы, sticky-шапка/колонки/строки, каскадный выбор, сортировка, ресайз и drag-and-drop колонок и строк.

Установка peer-зависимостей

Виртуализация и DnD опираются на обычные peer-зависимости — установите их вместе с китом:

bash
pnpm add @tanstack/vue-virtual @atlaskit/pragmatic-drag-and-drop @atlaskit/pragmatic-drag-and-drop-hitbox @atlaskit/pragmatic-drag-and-drop-auto-scroll

Контракт иммутабельности данных

Флаттенинг дерева и кэши индексов работают по identity массива items: любые изменения данных (добавление, правка, перенос строк) выполняйте новым массивом (items = [...items, next], обновлённые узлы — новыми объектами). Мутация массива на месте не пересчитает таблицу. Тот же контракт действует для v-model:selected/v-model:expanded после rowsDrop — кит не переставляет данные сам, а только эмитит намерение.

Контент ячеек в слотах #cell:* должен зависеть от item и параметров скоупа: строки рендерятся через v-memo, и внешняя реактивность, не выраженная в данных, может не перерисовать ячейку.

Сортировка и меню колонки

Кит не сортирует данные: v-model:sort-state хранит индикаторы, sortChange сообщает о намерении. Клик по заголовку — цикл asc → desc → сброс; Shift/Ctrl-клик добавляет колонку в мультисортировку (порядковые бейджи). Кнопка и индикатор сортировки стоят сразу за заголовком, у правого края колонки остаётся только kebab — так в макете при выравнивании заголовка влево. Kebab-меню колонки встроено: «По возрастанию»/«По убыванию»/«Сбросить сортировку»/«Скрыть колонку»; слот header-menu:[key] замещает содержимое меню.

Управление колонками

Проп columnsManageable добавляет крайнюю правую служебную колонку 32px (Figma Header Cells type=buttonIcon): в шапке — кнопка tablePlus, в строках ячейки пустые. Колонка sticky-right, поэтому кнопка не уезжает при горизонтальной прокрутке и не накрывает шапку. Кнопка открывает меню со всеми скрываемыми data-колонками и возвращает колонку, скрытую через kebab заголовка: без неё hiddenColumns можно было только пополнять. Главная колонка в список не входит — она держит дерево и всегда видима.

Строка меню — тоггл на всю ширину (Figma Field): слева иконка типа колонки из icon в конфиге колонки и заголовок, справа переключатель; клик по любому месту строки переключает видимость. Видимые колонки идут первыми в порядке columns, скрытые — после разделителя; разделитель появляется только когда есть обе группы.

Кнопка — часть грида: её ячейка шапки достижима стрелками (End в шапке ведёт в неё), F2 или Enter входят к кнопке, Space открывает меню; таблица по-прежнему один табстоп. В теле и агрегатном футере эта колонка в навигации не участвует. Меню телепортировано в body и не режется скроллером. Содержимое меню заменяется слотом columns-menu (скоуп: columns, isColumnHidden, toggleColumn).

vue
<STable
  v-model:hidden-columns="hiddenColumns"
  :items="tasks"
  :columns="columns"
  columns-manageable
/>

Контент ячеек

Кит рисует структуру таблицы: колонки, sticky-слои, выбор, сортировку, ресайз и DnD. Контент ячейки — зона приложения: рендерит его слот #cell:[key] со скоупом TSTableCellSlotScope (item, column, value, index, depth, selected, expanded, toggleExpanded, toggleSelected). Так люди с аватарами, статусы, теги, прогресс, деньги, даты, ссылки и любые интерактивные колонки (трекер времени, кнопки действий, счётчики с поповером) собираются из компонентов кита и приложения без промежуточного слоя типов.

vue
<STable :items="tasks" :columns="columns">
  <template #cell:assignees="{ item }">
    <SAvatar v-for="person in item.assignees" :key="person.id" :name="person.name" :size="ESAvatarSize.SIZE_24" />
  </template>
  <template #cell:status="{ value }">
    <STag :model-value="String(value)" :size="ESTagSize.SMALL" :color="ESTagColor.BLUE" />
  </template>
</STable>

Что кит рисует сам, а что отдаёт приложению

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

  • Индикатор 2px — колонка появляется от наличия слота #indicator, цвет задаёт приложение. Класс s-ui-table__indicator заливает ячейку целиком.
  • Иконка статуса, светофор, аватары, теги, прогресс — это обычные колонки приложения: задайте width/sticky в columns и нарисуйте содержимое в #cell:[key]. Отдельных пропов для них в ките нет.
  • Служебные строки (групповая, футер поддерева, «Показать ещё», строка создания) кит рисует и прилепляет сам, а их кнопки и подписи приходят из слотов #group-actions, #depth-footer, #creation-row.
  • Кнопки ячейки по ховеру — слот #cell-actions: кит держит зону, раскрытие и попадание в клавиатурную модель, приложение — сами кнопки. Раскрытие идёт по ховеру своей ячейки, а не всей строки (Figma: это состояние Data Cells, а не строки); в покое зона нулевой ширины, поэтому контент занимает ячейку целиком и ужимается только на ховере. В readonly кнопки скрыты. Скоуп слота отдаёт cellState: по макету в режиме правки остаётся только кнопка удаления, но какая из кнопок какая — знает приложение, поэтому решение за ним.
  • Состояние строки — проп rowState: признак «выполнено» вычисляет приложение, кит гасит текст первичной колонки до text/surface/disabled. Зачёркивания нет: в макете его нет ни на одном узле, и вторичные колонки в этом состоянии не меняются.
  • Подсветка строки — проп rowColor: насыщенный цвет у левого края строки, растворяющийся к первой трети главной колонки; остальные колонки остаются на обычном фоне. Значение — оттенок акцентной палитры (ESTableRowColor) или кастомный #hex / CSS-переменная; оттенки палитры адаптируются к тёмной теме токенами, кастомный цвет — ответственность приложения. На тёмных цветах текст главной колонки автоматически светлеет: тёмные оттенки палитры размечены в ките, для #hex контраст вычисляется по яркости, var(--…) кит не разбирает — там контраст на совести приложения. Заголовок, выходящий за фейд, останется светлым уже на обычном фоне — тёмные цвета лучше сочетать с короткими заголовками. Ховер, выделение и перетаскивание продолжают подкрашивать фон под градиентом. Слой плавно появляется и гаснет, но таймеров в ките нет: когда снять цвет (например, после «вспышки» только что созданной строки) — решает приложение. Без колонки чекбоксов градиент якорится к краю главной колонки. Важно про виртуализацию: строки кэшируются по identity колбэка — чтобы перекрасить строки без смены самих элементов, передайте новую стрелку (например, из computed, зависящего от карты цветов).
vue
<STable
  :items="tasks"
  :columns="columns"
  :row-state="(item) => (item.done ? ESTableRowState.COMPLETED : undefined)"
  :row-color="(item) => (item.isNew ? ESTableRowColor.GREEN : undefined)"
>
  <template #indicator="{ item }">
    <div class="s-ui-table__indicator" :style="{ backgroundColor: item.priorityColor }" />
  </template>
  <template #cell-actions="{ item, column }">
    <SButton v-if="column.key === 'title'" icon="edit" :appearance="EButtonAppearance.BARE" :size="EButtonSize.SMALL" tabindex="-1" @click.stop="edit(item)" />
  </template>
</STable>

Без слота колонка показывает обычный текст: значение берётся из column.value (поле элемента или резолвер), а без него — из поля с именем колонки; пустое значение показывает column.placeholder («Добавить» в макете). Типографика фолбэка задана макетом: значение — 12 Regular вторичным цветом, заголовок в главной колонке — 14 Medium основным.

Служебные строки: контент живёт в колонке

Групповая строка, строка-футер поддерева, строка «Показать ещё» и строка создания — обычные строки таблицы: колоночная структура сохраняется, высота от 40px, а контент кладётся в конкретную колонку, а не растягивается через colspan. По умолчанию это главная колонка — она всегда sticky-left, поэтому при горизонтальном скролле контент не уезжает из вьюпорта. Колонка переопределяется пропами groupContentColumnKey, depthFooter.columnKey, creationContentColumnKey и loadMoreContentColumnKey — так контент встаёт ровно под нужный столбец.

vue
<STable creation-row creation-content-column-key="title" load-more-content-column-key="title">
  <template #creation-row>
    <SButton :appearance="EButtonAppearance.BARE" :size="EButtonSize.SMALL" icon-left="plus" tabindex="-1">
      Создать задачу
    </SButton>
  </template>
</STable>

Единственная строка на всю ширину (colspan) — пустое состояние. Его контент прибит к вьюпорту скроллера через position: sticky на блочной обёртке, поэтому текст и кастомный слот #empty остаются по центру видимой области при любом горизонтальном скролле. Важно: position: sticky на inline-обёртке внутри colspan-ячейки не работает — прямоугольник прилипания сводится к line box; обёртка обязана быть блочной и иметь ширину вьюпорта.

Клавиатура

Таблица — составной виджет с одним табстопом: Tab входит в грид, следующий Tab уводит фокус наружу. Внутри перемещение идёт по ячейкам, а не по кнопкам, поэтому кнопки строк и шапки намеренно вне таб-порядка (tabindex="-1").

КлавишиДействие
Перемещение по ячейкам; вертикаль запоминает колонку и возвращается в неё после узких служебных строк
на колонке дереваРаскрыть узел, а если он уже раскрыт — перейти к первому потомку
на колонке дереваСвернуть узел, а если он свёрнут или это лист — перейти к родителю
Home / EndПервая и последняя ячейка строки
Ctrl+Home / Ctrl+EndПервая и последняя ячейка таблицы
PageUp / PageDownСтраница строк (размер окна виртуализации)
EnterСортировка в шапке, раскрытие узла в колонке дерева, действие служебной строки; иначе — вход в ячейку
F2Вход в ячейку: фокус на первый интерактивный элемент
Tab / Shift+Tab внутри ячейкиЦикл по элементам ячейки
EscapeВозврат фокуса с элемента на саму ячейку
SpaceПереключение выбора строки при selectable
Alt+Shift+←/→Ресайз колонки под фокусом
Alt+↓Kebab-меню колонки

Кнопки шапки (грип слева, сортировка и kebab справа) в покое не занимают места: по макету их в спокойном состоянии нет вовсе. Поэтому при наведении заголовок колонки с DnD-якорем уезжает вправо на 16px (12 зона грипа + 4 gap) — это поведение макета, а не дефект. У первичной колонки грипа нет, её заголовок неподвижен, и в покое заголовки всех колонок стоят по одной вертикали с контентом ячеек.

Фокус ведёт активную строку (v-model), поэтому она форс-монтируется виртуализатором и не исчезает при прокрутке; если строка всё же пропала из потока, фокус переезжает на ближайшую живую ячейку.

Sticky-строки

Групп-хедеры и depth-футеры — реальные строки таблицы с position: sticky на ячейках: без клонов и оверлеев, DnD/ховеры/меню на прилипших строках работают штатно. Активную строку выбирает индексная логика, само прилипание и возврат в поток выполняет CSS. Смена прилипших групп — мгновенная подмена (cover-replace), как в react-virtuoso. Футеры поддеревьев прижимаются с запасом в пикселях: следующие футеры уже стоят под уходящим, пока их строки ближе ~1200 px под кромкой, поэтому смена плашки не моргает даже при быстрой прокрутке, когда JS переключает роли на кадр позже композитора.

Быстрая прокрутка (прогрессивная гидрация)

Окно строк меняется на каждый кадр прокрутки, а монтирование строки с компонентами в ячейках стоит миллисекунды: при быстрой прокрутке или драге ползунка кадр не укладывался бы в 16 мс. Поэтому новые строки окна получают полный рендер порциями — не больше бюджета за кадр, остальные ждут упрощёнными плашками и добираются следующими кадрами, ближние к видимой области первыми. Бюджет адаптивный: он подстраивается под фактическое время кадра, так что на лёгких ячейках плашки не появляются вовсе, а на тяжёлых (аватары, теги, кнопки) прокрутка остаётся плавной и пустых полос не возникает. Отключается пропом scroll-seek-placeholders="false" — тогда все строки окна рендерятся сразу, и при быстром драге возможны подвисания.

Перф-протокол (повторяемая процедура)

Сторис PerfFlat и PerfTree (100 000 строк) — контрольные точки:

  1. mount < 300 мс — атрибут data-mount-ms на обёртке стори;
  2. скролл ≥ 55 fps — rAF-счётчик при scrollTop += 120 на кадр в течение 1–2 с;
  3. раскрытие/сворачивание группы < 50 мс — замер повторных toggle (первый прогревает кэш); стори PerfHeavyCells повторяет прогон на ячейках с аватарами, тегами и кнопками;
  4. число строк в DOM ≈ видимые + 2×overscan;
  5. при скролле вверх на переменных высотах опорная строка не «прыгает» (соседние кадры отличаются ровно на шаг скролла);
  6. стори PerfTreeCells (1 500 000 строк, компоненты в ячейках, rowHeightMode: fixed) — контроль сжатия прокрутки и гидрации: mount < 1 с (индекс дерева 1,5 млн узлов), сворачивание группы ≈ 100 мс, scrollHeight ниже потолка браузера, Ctrl+End и scrollToRow доводят до последней строки, прижатые группа и футер держатся на сжатых позициях, при 600–1500 px/кадр полоса строк не пустеет (плашки), после остановки строки дорисовываются за ~100 мс. Замеры в dev Storybook включают ~20 % оверхеда подсветки a11y-аддона на каждое изменение DOM — на перф-стори аддон выключен параметром a11y.disable.

Ширины колонок

Колонка без width в конфиге — гибкая: её дефолтная ширина (430 у главной, 160 у остальных) становится минимумом, а когда сумма ширин всех колонок меньше ширины вьюпорта скроллера, излишек делится между гибкими колонками пропорционально их базовым ширинам — таблица занимает всю доступную ширину без пустого хвоста. Колонки с width и колонки, ширину которых пользователь задал ресайзом (они попадают в v-model:column-widths), держат ровно свои пиксели; sticky-right колонки всегда прижаты к правому краю и в распределении не участвуют; maxWidth гибкой колонки уважается. Если гибких колонок нет, остаток занимает служебная колонка-филлер перед правой sticky-зоной. Ресайз ручкой, клавиатурой или автофит по двойному клику делает колонку явной, и излишек уходит оставшимся гибким колонкам.

Ограничения

  • Скринридеры видят только отрендеренное окно строк; aria-rowcount и абсолютные aria-rowindex чинят озвучку позиции, но не сквозную навигацию.
  • Высота элемента у браузера ограничена (Blink ≈ 33,5 млн px, Firefox ≈ 17,9 млн): выше этого предела таблица включает сжатие прокрутки, как ag-grid — tbody держится под потолком, а scrollTop скроллера переводится в позицию по строкам пропорционально (у начала и конца таблицы — один к одному). Всё поверх этого (sticky-строки, scrollToRow, Ctrl+End, DnD, сворачивание групп — строка остаётся на месте) работает штатно; тик колеса перехватывается и листает столько же строк, сколько без сжатия, а шаг ползунка скроллбара по-прежнему пропорционален высоте таблицы. Для таких таблиц держите rowHeightMode: fixed: перемер строк в auto двигает масштаб, а fixed обрезает контент выше строки, так что модель и DOM не расходятся.
  • Автоширины колонок «по контенту» невозможны при виртуализации — ширины задаются в px; колонки без width растягиваются только на свободное место вьюпорта, а не по контенту (двойной клик по ручке ресайза подгоняет по видимым ячейкам).
  • В режиме virtual: false sticky групп-хедеры не применяются.
  • Контент служебной строки ограничен шириной своей колонки: длинный контент строки создания или «Показать ещё» кладите в широкую колонку через creationContentColumnKey/loadMoreContentColumnKey — на всю ширину таблицы такой контент не растягивается.

Контент ячеек

Слот cell:[key]: люди, статусы, теги, прогресс, деньги, даты и ссылки рисует приложение.

Дерево и группы

Групповые строки-статусы, каунтеры и шевроны.

Sticky групп-хедеры

Активная группа прилипает под шапкой.

Каскадный выбор

Tri-state чекбоксы с indeterminate.

Управление колонками

columnOrder, columnWidths и hiddenColumns.

DnD строк в дереве

before/after/child и hover-раскрытие веток.

Футеры

Строка создания и агрегатный футер с тонами.

API Reference

Свойства

ИмяТипОписаниеПо умолчанию
items обязательныйTItem[]Плоский или древовидный набор строк. Массив иммутабелен по identity: любые изменения — новым массивом.
columns обязательныйTItem[]Конфигурация колонок (иммутабельна); состояние колонок — в v-model.
itemKeyTListItemKeyResolver<TItem>Ключ строки.'id'
childrenKeykeyof TItem & stringПоле с дочерними элементами для дерева.'children'
groupRowTSTableFieldResolverПризнак групповой строки-статуса: булево поле или предикат.
groupTotalKeykeyof TItem & stringПоле полного количества строк группы для каунтера «N/M».
groupContentColumnKeystringКолонка, в которой рендерится контент групповой строки; дефолт — главная.
disabledKeykeyof TItem & stringПоле флага disabled строки.
selectablebooleanЧекбокс-рейка слева: выбор строк с reveal по ховеру.false
cascadeSelectionbooleanКаскадный выбор поддеревьев с indeterminate у родителей.false
isItemSelectable(…) => …Предикат «строку можно выбирать».
columnsManageablebooleanКнопка управления колонками: своя служебная колонка 32px у правого края шапки (sticky-right), в строках её ячейки пустые. Кнопка — часть грида (ячейка шапки навигируется стрелками, F2 входит к кнопке).
rowState(…) => …Состояние строки: кит рисует модификатор, признак задаёт приложение.
rowColor(…) => …Цветовая подсветка строки: насыщенный цвет у левого края, растворяющийся к первой трети главной колонки. Оттенок палитры или кастомный #hex / CSS-переменная; момент установки и снятия цвета решает приложение — кит только плавно гасит слой.
expandColumnKeystringКлюч колонки с шевроном разворота дерева; дефолт — главная колонка.
expandOnClickbooleanКлик по строке с потомками разворачивает/сворачивает её.false
treeIndentnumberОтступ дерева на уровень, px.TABLE_TREE_INDENT
depthFooterISTableDepthFooterConfig<TItem>Строка-футер «добавить» в поддеревьях заданных глубин.
creationRowbooleanСтрока создания в tfoot: идёт за последней строкой, при переполнении скроллера прилипает к низу; контент — слот creation-row.false
creationContentColumnKeystringКолонка, в которой рендерится контент строки создания; дефолт — главная.
footerValuesReadonly<Record>Значения агрегатного футера по ключам колонок. Полоса ведёт себя как строка создания: прилипает к низу только при переполнении скроллера.
virtualbooleanВиртуализация строк.true
rowHeightModeESTableRowHeightModeРежим высоты строк: auto растёт от контента, fixed — ровно estimate.'auto'
estimateRowSizenumberОценка высоты строки для виртуализации.TABLE_ESTIMATE_ROW_SIZE
overscannumberЧисло строк вне вьюпорта, рендеримых заранее.TABLE_OVERSCAN
heightnumber | stringВысота контейнера таблицы.
maxHeightnumber | stringМаксимальная высота при авто-высоте.
endReachedDistancenumberРасстояние до низа для события endReached.0
canLoadMore(…) => …Предикат: можно ли запрашивать следующую порцию данных.
loadingbooleanПервичная загрузка: скелетон вместо строк.false
loadingMorebooleanДогрузка вниз: скелетон-строки после данных.false
skeletonRowCountnumberКоличество скелетон-строк.TABLE_SKELETON_ROW_COUNT
hasMoreChildrenKeykeyof TItem & stringПоле-флаг «у раскрытого узла есть ещё дети»; включает строку «Показать ещё».
loadingMoreChildrenKeysTItem[]Ключи узлов с активной догрузкой детей.[]
loadMoreContentColumnKeystringКолонка, в которой рендерится контент строки «Показать ещё»; дефолт — главная.
rowsDraggablebooleanDrag-and-drop строк.false
rowsDragModeESTableRowsDragModeРежим DnD строк; дефолт — tree при наличии дерева, иначе flat.
isRowDraggable(…) => …Предикат «строку можно тащить».
dragExpandDelayMsnumberЗадержка hover-раскрытия узла при перетаскивании, мс.TABLE_DRAG_EXPAND_DELAY_MS
columnsResizablebooleanРесайз колонок.true
columnsDraggablebooleanDrag-and-drop колонок.true
scrollSeekPlaceholdersbooleanПрогрессивная гидрация окна: новые строки монтируются порциями по адаптивному бюджету кадра, остальные ждут упрощёнными плашками — при быстрой прокрутке окно успевает за композитором, нет подвисаний и пустых полос.true
rowClass(…) => …Дополнительный класс строки.
rowStyle(…) => …Дополнительный инлайн-стиль строки — для значений, невыразимых классом (например, фон из произвольного цвета сущности). Стиль зависит только от item/row: identity функции входит в кэш строк, как у rowClass.
modelValue / v-model:selectedstring[]Двустороннее значение для v-model:selected.[]
modelValue / v-model:expandedstring[]Двустороннее значение для v-model:expanded.[]
modelValue / v-model:sortStateTSTableSortEntry[]Двустороннее значение для v-model:sortState.[]
modelValue / v-modelstring | undefinedДвустороннее значение компонента (v-model).
modelValue / v-model:columnOrderstring[] | undefinedДвустороннее значение для v-model:columnOrder.
modelValue / v-model:hiddenColumnsstring[] | undefinedДвустороннее значение для v-model:hiddenColumns.

События

| Имя | Описание | Payload | | ------------------ | ------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | ---------------- | | rowClick | Клик по строке (или Enter на активной строке). | { key: string; item: TItem; event: MouseEvent | KeyboardEvent } | | rowContextMenu | Контекстное меню строки. | { key: string; item: TItem; event: MouseEvent } | | cellClick | Клик по ячейке. | { key: string; item: TItem; columnKey: string; event: MouseEvent } | | sortChange | Изменение состояния сортировки (данные сортирует приложение). | Array | | endReached | Достигнут конец таблицы при прокрутке. | { direction: 'bottom' } | | loadMoreChildren | Запрос догрузки детей узла из строки «Показать ещё». | { key: string; item: TItem } | | depthFooterClick | Клик по кнопке depth-футера. | { parentKey: string; parentItem: TItem; depth: number } | | groupMenuOpen | Открытие kebab-меню группы. | { key: string; item: TItem } | | columnResizeEnd | Коммит ресайза колонки. | { key: string; width: number } | | columnReorder | Перенос колонки drag-and-drop или клавиатурой. | { key: string; toIndex: number; order: string[] } | | rowsReorder | Перенос строки в flat-режиме (BEFORE|AFTER + индексы). | ISTableRowsReorderPayload | | rowsDrop | Дроп строки в tree-режиме (BEFORE|AFTER|CHILD + итоговый родитель/глубина). | ISTableRowsDropPayload |

Слоты

ИмяОписание
header:${descriptor.key}
header-menu:${descriptor.key}
columns-menuКонтент меню управления колонками
emptyСостояние пустой таблицы
skeleton-rowКастомный скелетон ячейки
group-menuКонтент kebab-меню группы (kebab виден при заданном слоте)
group-rowПолная замена контента групповой строки
group-titleИконка и заголовок группы
group-actionsКнопки справа в групповой строке
depth-footerКонтент строки-футера поддерева
load-moreСтрока «Показать ещё» догрузки детей
cellSlotNamecell:[key] Контент ячейки конкретной колонки, например #cell:title
chevronКастомный шеврон разворота дерева
cell-actionsКнопки ячейки по ховеру; скоуп — строка, колонка и значение
indicatorКастомный контент 2px-индикатора
checkboxКастомный чекбокс выбора строки
right-edgeКастомный контент right-edge-ячейки
creation-rowКонтент строки создания
footer:${cellPlan.descriptor.key}footer:[key] Контент ячейки агрегатного футера колонки