Перейти до вмісту
tatetUI

TreeTable

Дерево-таблиця з віртуальним скролом — ієрархія в першій колонці, звичайні сортовані колонки далі.

Рендер плоский: дерево розгортається у список видимих рядків, і в шаблоні лишається один v-for. Понад сотню рядків список віртуалізується — у DOM тримаються лише видиме вікно й дві розпірки.

Приклад

12 груп · 492 вузли · видимих рядків: 204+

Техніка та електроніка

Alias
техніка-та-е-41
Товарів
Створено
20.06.2026
Оновлено
19.11.2026

Аксесуари

Alias
аксесуари-10
Товарів
Створено
07.11.2026
Оновлено
06.04.2026

Комплектуючі

Alias
комплектуючі-20
Товарів
Створено
13.09.2026
Оновлено
12.02.2026

Витратні матеріали

Alias
витратні-мат-30
Товарів
Створено
19.07.2026
Оновлено
18.12.2026

492 вузли, чотири рівні. Розгорніть усе й подивіться в інспекторі: рядків у DOM лишається кілька десятків, а aria-rowcount на таблиці називає справжнє число — саме воно потрібне скрінрідеру, бо DOM тут бреше.

Ліниве завантаження

AliasТоварів
Аналітика
folder-3
92

expandAll() (і «Розгорнути всі» в меню налаштувань) для лінивих гілок, відкритих уперше, віддає expand з loaded: false — так само, як клік. Інакше вони лишалися б відкритими й порожніми без жодного сигналу.

undefined і [] у полі дітей — різні стани. Перше означає «діти ще їдуть»: рядок лишається aria-busy, замість шеврона крутиться індикатор із loadingIds. Друге означає «гілка справді порожня». Компонент розрізняє їх сам, режим перемикати не треба.

Дані з файлу

Перетягніть JSON сюди

або натисніть, щоб обрати

Масив вузлів: id, parent, name, children[], data{}. Файл нікуди не надсилається — читається у браузері.

Або вставити JSON текстом
Статус

Джерело — зразок · 7 вузлів · глибина 2 · 5 листків

it off
не прив’язано
0
0
Вимкнена
Захист екрану хлам
не прив’язано
26
0
Вимкнена
Технічні та загальні товари
не прив’язано
1
0
Активна
Відеоспостереження
IP-камери
14
14
Активна
Катя
не прив’язано
0
0
Активна
Дитяча термобілизна
Дитяча термобілизна
13
13
Активна
Ігрові маніпулятори й аксесуари до консолей
Ігрові маніпулятори й аксесуари до консолей
8
8
Активна

Перетягніть у зону власне вивантаження — масив вузлів { id, parent, name, children[], data{} } — і воно відрендериться як є. Файл нікуди не надсилається: його читає FileReader у браузері.

Поля тут лежать не на вузлі, а у вкладеному data, і саме для цього існує getValue: перекладати дані під форму колонок не треба, достатньо описати, звідки брати значення. Ключі колонок при цьому — без крапок, інакше #cell-data.active розбереться як слот cell-data з модифікатором active.

Над таблицею — пошук і фільтри. Плоский filter тут не працює принципово: вузол, що збігся, без предків втрачає місце в ієрархії, а без предків його ще й не видно. Тому filterTree лишає вузол, якщо збігся він сам АБО хтось у його піддереві, і окремо повертає список гілок, які треба розгорнути. Розгортання користувача при цьому не губиться — воно повертається, коли фільтр знімають.

Діти фільтруються навіть у вузла, що збігся сам: інакше «лише активні» показувало б вимкнених дітей активної теки, тобто рівно те, що просили сховати.

Демо заразом перевіряє те, що зазвичай помічають надто пізно: чи унікальні id. Рядки ключуються саме ними, тож у вивантаженні з повторами ламаються :key, виділення й фокус — а назви, на відміну від id, повторюються суцільно.

Пагінація

Розділ 1
Тарас
24.02.2026
1–10 з 40

Сторінками йдуть корені, а не рядки. Різати плоский список видимих рядків не можна: дитина опинилась би на наступній сторінці без свого батька — тобто без єдиного, що пояснює її місце. Тому сторінка — це N коренів РАЗОМ з їхніми піддеревами, і загальна кількість рахується теж по коренях.

Оновлення вже показаних даних (loading при непорожньому items) не замінює дерево скелетоном — розгорнуті гілки лишаються на місці під напівпрозорим оверлеєм. Скелетон з'являється лише на першому завантаженні.

Пагінація й віртуалізація не конкурують: перша обмежує, скільки гілок взагалі приходить у компонент, друга — скільки рядків із них живе в DOM. Одна гілка, розгорнута на всю глибину, легко дає більше рядків, ніж уся сторінка коренів.

API

Props

НазваТипТиповоОпис
headers*TreeTableHeader[]Опис колонок. Колонка ієрархії — перша або названа в `treeColumn`.
items*T[]Корені дерева. Діти лежать у полі, названому в `childrenField`.
keyRowstring"id"Поле-ідентифікатор вузла. Значення унікальне в УСЬОМУ дереві.
childrenFieldstring"children"Поле з масивом дітей. `undefined` і `[]` — різні стани, див. `expand`.
treeColumnstringЯка колонка малює ієрархію. Типово — перша в `headers`.
getValue(item: T, key: string) => unknownЗначення комірки й ключ сортування, коли поля лежать не на вузлі.
hasChildren(item: T) => booleanЧи має вузол дітей, коли їх ще не завантажено. Типово — довжина `children`.
loadingIds(string | number)[][]Вузли, чиї діти зараз вантажаться: індикатор і `aria-busy` на рядку.
rowHeightnumberВисота рядка в ПІКСЕЛЯХ. Перекриває висоту, задану щільністю.
cardHeightnumber96Висота картки нижче `md`, у пікселях. Той самий контракт, що `rowHeight`.
maxHeightstring"28rem"Висота контейнера прокрутки, напр. `"28rem"`. `"none"` знімає обмеження.
virtualizeFromnumber100З якої кількості ВИДИМИХ рядків вмикати віртуалізацію. `Infinity` вимикає.
overscannumber5Скільки рядків тримати відрендереними за межами вікна з кожного боку.
expanded(string | number)[][]Відкриті гілки. Використовуйте через `v-model:expanded`.
sortTreeTableSortnullПоточне сортування. Використовуйте через `v-model:sort`.
densitysmmd"md"Щільність рядків. Обирає висоту рядка, якщо не задано `rowHeight`.
tableIdstringВмикає меню налаштувань і збереження розкладки під `tree_table_settings_${tableId}`.
settingsVersionnumber0Версія ВАШИХ дефолтів. Змінили ширини — підніміть, і збережене скинеться.
selected(string | number)[][]Ключі обраних вузлів, включно з батьками. Використовуйте через `v-model:selected`.
selectableRow(item: T) => booleanЯкі вузли можна обрати. Каскад незбиральних не додає.
rowClass(item: T) => stringКлас на рядок — для підсвітки. Заливка має йти в CSS після `bg-card`.
skeletonRowsnumber6Скільки рядків-заглушок показати під час ПЕРШОГО завантаження.
emptyTextstring"Даних немає"Текст, коли рядків немає. Складніший стан — слот `empty`.
ariaLabelstring"Дерево-таблиця"Доступна назва таблиці для скрінрідера.
serverSortbooleanСортувати на сервері. Сервер мусить сортувати СУСІДІВ, не все дерево.
densityTogglebooleantrueПоказати перемикач щільності в меню налаштувань.
selectablebooleanВмикає колонку прапорців із каскадом на все піддерево.
selectionBarbooleantrueПанель «Вибрано N» над таблицею.
expandOnClickbooleanКлік по рядку з дітьми тогглить гілку.
rowClickablebooleanРобить рядки клікабельними: курсор і подія `rowClick`.
stickyTreeColumnbooleantrueЗакріпити колонку ієрархії при горизонтальній прокрутці.
mobileCardsbooleanНижче `md` замість таблиці — картки з відступом і шевроном.
loadingbooleanПоказує скелетони замість рядків, зберігаючи висоту таблиці.

Події

НазваPayloadОпис
update:expanded[ids: (string | number)[]]Набір відкритих гілок. Використовуйте через `v-model:expanded`.
update:selected[keys: (string | number)[]]Ключі обраних вузлів, включно з батьками. Використовуйте через `v-model:selected`.
update:sort[value: TreeTableSort]Нове сортування або `null` на третьому кліку. Використовуйте через `v-model:sort`.
update:headers[value: TreeTableHeader[]]Розкладка колонок після ресайзу чи меню налаштувань.
expand[payload: { item: T; id: string | number; loaded: boolean; }]Гілку відкрито. `loaded` каже, чи діти вже завантажені.
collapse[payload: { item: T; id: string | number; }]Гілку закрито.
rowClick[item: T]Активація рядка кліком або Enter при `rowClickable`.

Слоти

НазваОпис
iconІконка типу вузла перед підписом у колонці ієрархії.
labelПідпис у колонці ієрархії замість значення `treeColumn`.
mobile-cardВміст картки нижче `md`, якщо стандартний список пар не підходить.
emptyПоказується замість «Даних немає».
selection-actionsДії в панелі «Вибрано N». `clear` знімає виділення.

Доступно через ref

НазваТипОпис
scrollToIndex(index: number) => voidПрокрутити до рядка за номером у ПЛОСКОМУ списку видимих рядків.
scrollToKey(key: string | number) => booleanРозгорнути предків вузла і перейти до нього. `false`, якщо вузла немає.
expandAll() => voidРозгорнути всі гілки. Повний обхід дерева — дорого на великих даних.
collapseAll() => voidЗгорнути всі гілки.
clearSelection() => voidЗняти виділення. Потрібне після успішної масової дії.

Коли використовувати

Ієрархічні дані, у яких на кожен вузол є ще й колонки: дерево сторінок сайту з alias і датами, каталог категорій із лічильниками товарів, дерево рахунків із сумами. Тобто всюди, де Tree не вистачає через колонки, а Table — через вкладеність.

Великі дерева. Поріг virtualizeFrom (типово 100 видимих рядків) вмикає вікно автоматично, тож той самий компонент обслуговує і десять рядків, і десять тисяч.

Коли НЕ використовувати

Плоскі дані — Table. Він уміє те саме з колонками, але без контракту фіксованої висоти, тож рядок там росте за вмістом.

Дерево без колонок — Tree. Легший, без арифметики вікна й без <colgroup>.

Однорідний список без ієрархії — VirtualList.

Рядки змінної висоти. rowHeight — це передумова, а не налаштування: уся арифметика вікна множить його на індекс. Рядок мусить вкладатися у задану висоту (truncate, фіксована кількість рядків тексту). Це інший компонент, а не важчий випадок цього.

Пошук по сторінці і друк. Ctrl+F і принтер бачать лише вікно — властивість будь-якої віртуалізації. Для друку й експорту вимикайте її: :virtualize-from="Infinity" разом із max-height="none".

Перетягування вузлів. Зміна батька — це мутація даних, а не подання; компонент її не робить і робити не буде.

Доступність

role="treegrid" з aria-rowcount на таблиці; на рядку — aria-level, aria-posinset, aria-setsize, aria-rowindex, aria-expanded (у листків його немає взагалі), aria-selected, aria-busy. Колонка ієрархії — rowheader, решта — gridcell.

Roving tabindex: Tab бере лише активний рядок. ↑↓ рухають, → розгортає й далі спускається, ← згортає й далі підіймається до батька, * розкриває всіх сусідів рівня, Home/End і PageUp/PageDown ходять по списку, Space перемикає вибір гілки. Літери шукають набором: «ан» переводить фокус на наступний рядок, чий підпис починається з «ан»; буфер живе 600 мс.

При selectable Ctrl/Cmd+A обирає все дерево, Escape знімає вибір (і не спливає далі, поки вибір є — модалка з таблицею від нього не закриється).

Коли активний рядок зникає — згорнули предка кліком по шеврону чи «Згорнути все» — Tab-зупинка переходить до найближчого видимого предка, а не на початок списку.

Клавіші з вкладених контролів рядок не перехоплює: Enter на кнопці в слоті комірки натискає кнопку, стрілки в полі вводу рухають каретку. Прапорець виділення — виняток: стрілки з нього рухають рядки.

Рядок за межами вікна фізично відсутній у DOM, тож перед focus() компонент зсуває вікно і лише потім фокусує — з preventScroll, інакше браузер додає власну прокрутку і виходить подвійний стрибок.

Шеврон — span під aria-hidden, а не кнопка. Кнопка на кожному рядку поставила б у Tab-порядок між двома сусідніми рядками стільки кнопок, скільки рядків у вікні; доступна афорданс розгортання — це aria-expanded плюс ← і →.

Прапорець рядка при selectable лишається справжнім input, тож Tab обходить прапорці ВІДРЕНДЕРЕНИХ рядків. Клавіатурному користувачу вони не потрібні: Space на рядку робить те саме.

Каскадне виділення

Вибір гілки бере її саму й усіх нащадків; батько показує indeterminate, поки обрано частину. Масив selected містить і батьків, і дітей — інакше масова дія «видалити обране» мовчки пропускала б теку, яку користувач і клікнув. Потрібні лише листки — відфільтруйте на своєму боці.

У лінивому режимі це має третій наслідок: діти, що приїхали під уже позначену теку, автоматично доливаються в набір, тож selected росте з розгортанням. Якщо ваш API приймає id теки й сам розбирається з піддеревом, приберіть нащадків перед відправкою.

Прапорець у шапці рахується по ВСЬОМУ дереву, а не по видимих рядках. У плоскій таблиці «сторінка» дорівнює видимому; у дереві прив'язка до видимого зробила б сенс галочки залежним від того, які теки випадково відкриті.

Сортування

Сортуються тільки сусіди в межах одного батька — ієрархія не сплющується ніколи, бо обхід сортує рівно той масив, у який зараз спускається.

При serverSort компонент лише повідомляє намір через update:sort. Сервер при цьому зобов'язаний сортувати сусідів усередині батька: поверне плоский глобально відсортований список — ієрархія загине, і компонент цього не помітить, бо діти лишаться дітьми, просто не тими.

Деталі реалізації

Віртуалізація зроблена розпірними рядками, а не абсолютним позиціюванням: абсолютний tr вибиває рядок із табличного боксу, і <colgroup> перестає керувати ширинами. Тому таблиця справжня, а над і під вікном стоять два порожні tr порахованої висоти.

Таблиця — border-separate з нульовим border-spacing, а роздільник рядків — псевдоелемент. Будь-який border на tr чи td увійшов би у висоту рядка, а вікно множить її на індекс: один зайвий піксель накопичується і зсуває хвіст списку.

Щільність задає ЧИСЛО висоти рядка, а не паддінг: висота мусить бути відома до рендеру й однакова на сервері та клієнті. Адаптивної пари { base, md } тому немає — сервер не знає ширини вікна.

mobileCards тримає обидві розкладки в DOM, а вибирає між ними CSS. Прихований контейнер спорожнюється виміром: ResizeObserver віддає під display: none нульовий бокс. matchMedia тут неможливий — він правдивий лише після монтування, і гілка по брейкпойнту розійшлася б із прередереним HTML на все тіло таблиці.

Панель налаштувань колонок — спільний компонент із UiTable (app/components/ui/table/ColumnSettings.vue), як і арифметика ширин, порівняння значень і зведення збереженої розкладки (app/utils/tableColumns.ts — імпортуйте звідти напряму, treeTable.ts цих функцій не ре-експортує). Дві копії тієї самої панелі розходяться мовчки: у дереві кнопки порядку встигли отримати зону дотику й SVG-стрілки, а в плоскій таблиці лишались текстові «↑↓» без зони. Керування гілками приходить у панель слотом — це єдине, чого в плоскій таблиці немає.

З tableId розкладка зберігається під ключем tree_table_settings_${tableId}, а відкриті гілки — окремо, під tree_table_expanded_${tableId}. Ключі різні, бо різний час життя: розкладка вмирає від settingsVersion, розгортання — разом із даними. Один payload означав би, що зміна дефолтної ширини колонки заразом закриває всім користувачам усі теки.

Збережене застосовується лише після монтування, тож на прередереній сторінці видно короткий проблиск дефолтної розкладки. Тут він помітніший, ніж у Table: збережена щільність міняє висоту рядка, а з нею — розпірки й діапазон вікна.