TreeTable
Дерево-таблиця з віртуальним скролом — ієрархія в першій колонці, звичайні сортовані колонки далі.
Рендер плоский: дерево розгортається у список видимих рядків, і в шаблоні
лишається один v-for. Понад сотню рядків список віртуалізується —
у DOM тримаються лише видиме вікно й дві розпірки.
Приклад
12 груп · 492 вузли · видимих рядків: 204+
492 вузли, чотири рівні. Розгорніть усе й подивіться в інспекторі: рядків
у DOM лишається кілька десятків, а aria-rowcount на таблиці називає
справжнє число — саме воно потрібне скрінрідеру, бо DOM тут бреше.
Ліниве завантаження
expandAll() (і «Розгорнути всі» в меню налаштувань) для лінивих гілок,
відкритих уперше, віддає expand з loaded: false — так само, як клік.
Інакше вони лишалися б відкритими й порожніми без жодного сигналу.
undefined і [] у полі дітей — різні стани. Перше означає «діти ще
їдуть»: рядок лишається aria-busy, замість шеврона крутиться індикатор
із loadingIds. Друге означає «гілка справді порожня». Компонент розрізняє
їх сам, режим перемикати не треба.
Дані з файлу
Перетягніть JSON сюди
або натисніть, щоб обрати
Масив вузлів: id, parent, name, children[], data{}. Файл нікуди не надсилається — читається у браузері.
Або вставити JSON текстом
Джерело — зразок · 7 вузлів · глибина 2 · 5 листків
Перетягніть у зону власне вивантаження — масив вузлів
{ id, parent, name, children[], data{} } — і воно відрендериться як є.
Файл нікуди не надсилається: його читає FileReader у браузері.
Поля тут лежать не на вузлі, а у вкладеному data, і саме для цього
існує getValue: перекладати дані під форму колонок не треба, достатньо
описати, звідки брати значення. Ключі колонок при цьому — без крапок,
інакше #cell-data.active розбереться як слот cell-data з
модифікатором active.
Над таблицею — пошук і фільтри. Плоский filter тут не працює
принципово: вузол, що збігся, без предків втрачає місце в ієрархії, а
без предків його ще й не видно. Тому filterTree лишає вузол, якщо
збігся він сам АБО хтось у його піддереві, і окремо повертає список
гілок, які треба розгорнути. Розгортання користувача при цьому не
губиться — воно повертається, коли фільтр знімають.
Діти фільтруються навіть у вузла, що збігся сам: інакше «лише активні» показувало б вимкнених дітей активної теки, тобто рівно те, що просили сховати.
Демо заразом перевіряє те, що зазвичай помічають надто пізно: чи
унікальні id. Рядки ключуються саме ними, тож у вивантаженні з
повторами ламаються :key, виділення й фокус — а назви, на відміну від
id, повторюються суцільно.
Пагінація
Сторінками йдуть корені, а не рядки. Різати плоский список видимих рядків не можна: дитина опинилась би на наступній сторінці без свого батька — тобто без єдиного, що пояснює її місце. Тому сторінка — це N коренів РАЗОМ з їхніми піддеревами, і загальна кількість рахується теж по коренях.
Оновлення вже показаних даних (loading при непорожньому items) не
замінює дерево скелетоном — розгорнуті гілки лишаються на місці під
напівпрозорим оверлеєм. Скелетон з'являється лише на першому завантаженні.
Пагінація й віртуалізація не конкурують: перша обмежує, скільки гілок взагалі приходить у компонент, друга — скільки рядків із них живе в DOM. Одна гілка, розгорнута на всю глибину, легко дає більше рядків, ніж уся сторінка коренів.
API
Props
| Назва | Тип | Типово | Опис |
|---|---|---|---|
headers* | TreeTableHeader[] | — | Опис колонок. Колонка ієрархії — перша або названа в `treeColumn`. |
items* | T[] | — | Корені дерева. Діти лежать у полі, названому в `childrenField`. |
keyRow | string | "id" | Поле-ідентифікатор вузла. Значення унікальне в УСЬОМУ дереві. |
childrenField | string | "children" | Поле з масивом дітей. `undefined` і `[]` — різні стани, див. `expand`. |
treeColumn | string | — | Яка колонка малює ієрархію. Типово — перша в `headers`. |
getValue | (item: T, key: string) => unknown | — | Значення комірки й ключ сортування, коли поля лежать не на вузлі. |
hasChildren | (item: T) => boolean | — | Чи має вузол дітей, коли їх ще не завантажено. Типово — довжина `children`. |
loadingIds | (string | number)[] | [] | Вузли, чиї діти зараз вантажаться: індикатор і `aria-busy` на рядку. |
rowHeight | number | — | Висота рядка в ПІКСЕЛЯХ. Перекриває висоту, задану щільністю. |
cardHeight | number | 96 | Висота картки нижче `md`, у пікселях. Той самий контракт, що `rowHeight`. |
maxHeight | string | "28rem" | Висота контейнера прокрутки, напр. `"28rem"`. `"none"` знімає обмеження. |
virtualizeFrom | number | 100 | З якої кількості ВИДИМИХ рядків вмикати віртуалізацію. `Infinity` вимикає. |
overscan | number | 5 | Скільки рядків тримати відрендереними за межами вікна з кожного боку. |
expanded | (string | number)[] | [] | Відкриті гілки. Використовуйте через `v-model:expanded`. |
sort | TreeTableSort | null | Поточне сортування. Використовуйте через `v-model:sort`. |
density | smmd | "md" | Щільність рядків. Обирає висоту рядка, якщо не задано `rowHeight`. |
tableId | string | — | Вмикає меню налаштувань і збереження розкладки під `tree_table_settings_${tableId}`. |
settingsVersion | number | 0 | Версія ВАШИХ дефолтів. Змінили ширини — підніміть, і збережене скинеться. |
selected | (string | number)[] | [] | Ключі обраних вузлів, включно з батьками. Використовуйте через `v-model:selected`. |
selectableRow | (item: T) => boolean | — | Які вузли можна обрати. Каскад незбиральних не додає. |
rowClass | (item: T) => string | — | Клас на рядок — для підсвітки. Заливка має йти в CSS після `bg-card`. |
skeletonRows | number | 6 | Скільки рядків-заглушок показати під час ПЕРШОГО завантаження. |
emptyText | string | "Даних немає" | Текст, коли рядків немає. Складніший стан — слот `empty`. |
ariaLabel | string | "Дерево-таблиця" | Доступна назва таблиці для скрінрідера. |
serverSort | boolean | — | Сортувати на сервері. Сервер мусить сортувати СУСІДІВ, не все дерево. |
densityToggle | boolean | true | Показати перемикач щільності в меню налаштувань. |
selectable | boolean | — | Вмикає колонку прапорців із каскадом на все піддерево. |
selectionBar | boolean | true | Панель «Вибрано N» над таблицею. |
expandOnClick | boolean | — | Клік по рядку з дітьми тогглить гілку. |
rowClickable | boolean | — | Робить рядки клікабельними: курсор і подія `rowClick`. |
stickyTreeColumn | boolean | true | Закріпити колонку ієрархії при горизонтальній прокрутці. |
mobileCards | boolean | — | Нижче `md` замість таблиці — картки з відступом і шевроном. |
loading | boolean | — | Показує скелетони замість рядків, зберігаючи висоту таблиці. |
Події
| Назва | 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: збережена щільність міняє висоту рядка, а з нею — розпірки
й діапазон вікна.