GrTree
Берут, когда данные вложены.
Когда брать
- данные вложены — файлы, категории, оргструктура, разделы: уровни и есть суть данных;
- узлы отмечают галочками — с наследованием на родителей или строго (
checkStrictly); - дети грузятся по требованию —
lazyсloadвместо выгрузки всего дерева разом; - дерево фильтруют —
filterNodeMethodоставляет совпавшие узлы вместе с их ветками; - узлы переносят —
draggableработает мышью, пальцем и с клавиатуры.
Когда взять другое
| Нужно | Берите |
|---|---|
| Дерево нужно в панели выбора, а не на экране | GrTreeSelect |
| Уровень один | GrList |
| Данные табличные | GrDataTable |
| Порядок в плоском списке | GrSortableList |
| Разделы, из которых открыт один | GrCollapse |
Разметка: плоский список вместо вложенного DOM
Дерево рендерится одним списком строк, а не вложенными компонентами: узел
любого уровня — это div[role="treeitem"] в общем контейнере role="tree",
отступ задаёт padding-left строки. Иерархию несут aria-level,
aria-posinset и aria-setsize — при плоском представлении паттерн tree
требует все три.
Практические следствия:
- глубина дерева больше не превращается в глубину дерева компонентов: раскрытые 2 000 узлов — это 2 000 строк, а не 2 000 инстансов со своими вычислениями;
- подсветка строки (наведение, текущий узел) и кольцо фокуса начинаются от
отступа уровня, а не от левого края дерева: иначе фон накрывал бы направляющие
предков — линию ветки не было бы видно ровно там, где она нужна. Рисует их
отдельный слой строки, вставленный слева на
--gr-tree-row-indent; - шаг отступа настраивается пропом
indent(в пикселях) или темой —--gr-tree-indent-step.
Фильтрация
Значение фильтра — проп filterValue; метод filter() остаётся для
императивных сценариев. Результат дерево сообщает событием filter:
visibleCount — сколько строк реально видно (совпадения плюс раскрытые ради них
родители), matchedCount — сколько узлов совпало само.
Число наружу нужно не для счётчика: без него потребитель не отличит «данных нет» от «поиск ничего не нашёл», а это разные пустые экраны — второй пользователь может исправить сам, первый нет.
Виртуализация
virtual вместе с maxHeight оставляет в DOM только окно вокруг вьюпорта —
именно плоская разметка выше это и открывает. Скроллером становится сам корень
дерева, а срезанное сверху и снизу держат его отступы: обёртки между
role="tree" и role="treeitem" отняли бы у роли обязательных потомков.
<GrTree :data="nodes" node-key="id" virtual :max-height="400" />
aria-setsize и aria-posinset остаются от полного набора, а не от окна —
иначе диктор объявлял бы «1 из 20» на списке в тысячу узлов.
Включать осознанно: на сотне строк выигрыша нет, а разметка меняется, и вместе с
ней — то, что находит querySelector потребителя. Высота строки берётся из
--gr-tree-row-min-height как оценка и уточняется замером отрисованных
строк. Устройство и ограничения — virtual-list.md.
Раскрытие
| Проп | Что делает |
|---|---|
defaultExpandedKeys | стартовый набор раскрытых узлов |
defaultExpandAll | раскрывает узел в момент его появления в данных |
expandOnClickNode | клик по строке раскрывает узел, а не только выбирает |
accordion | на каждом уровне раскрыт максимум один узел |
defaultExpandAll намеренно не «держит всё раскрытым»: он отмечает уже
виденные ключи, поэтому свёрнутое руками не разворачивается обратно на каждом
обновлении data. Иначе живое дерево из БД схлопывало бы работу пользователя
при любом рефетче.
expandOnClickNode действует только на мышь. Enter в паттерне tree закреплён
за выбором, и подмешивать в него раскрытие нельзя — иначе выбрать папку с
клавиатуры стало бы невозможно.
Иконки
expandIcon, collapseIcon и dragHandleIcon не заданы — дерево рисует свои
встроенные, и от конфига приложения они не зависят. Своя иконка передаётся
Vue-компонентом либо классом иконки вашей UnoCSS-сборки (i-lucide-* — тогда
нужен ваш presetIcons, см. «Иконки»).
toggleIconRotate поворачивает иконку раскрытия на 90° вместо смены картинки —
поэтому одной expandIcon обычно достаточно.
Чекбоксы
<GrTree v-model:checked-keys="checked" :data="data" node-key="id" show-checkbox />
Отметки связаны по дереву: отмеченный родитель отмечает всех потомков, частично
отмеченный показывает aria-checked="mixed". checkStrictly эту связь
отключает — каждый узел отвечает сам за себя.
Состояние объявляется на самом узле (aria-checked на treeitem), а
видимый квадратик декоративен и скрыт от диктора: вкладывать интерактивный
чекбокс внутрь роли-виджета нельзя — роль объявляет потомков презентационными.
Корень при этом становится aria-multiselectable="true".
Space при включённых чекбоксах переключает отметку (это и есть выбор в
multi-select дереве), Enter по-прежнему выбирает узел.
Императивно: getCheckedKeys({ leafOnly }), setCheckedKeys(),
getHalfCheckedKeys(), setChecked(node, checked). checkedKeys снаружи может
содержать только листья — родители посчитаются сами.
Ленивая подгрузка
<GrTree :data="roots" node-key="id" lazy :load="loadChildren" />
function loadChildren(node: GrTreeNode<Folder>, resolve: (children: Folder[]) => void) {
fetchChildren(node.key).then(resolve)
}
В ленивом режиме узел считается разворачиваемым, пока не доказано обратное:
лист объявляется полем isLeaf в данных (имя настраивается картой props).
Ветка грузится по первому раскрытию — повторное запроса не делает; на время
запроса строка показывает спиннер и помечается aria-busy.
Загруженные дети живут в состоянии компонента, а не дописываются в data:
проп реактивным быть не обязан, а показать пришедшее дерево должно в любом
случае. Пустой ответ делает ветку листом.
defaultExpandAll незагруженные ветки не трогает — иначе первый рендер дёрнул
бы весь бэкенд.
Выбор и объявление скринридеру
Текущий узел задаётся пропом — v-model:current-key, — а не только методом
setCurrentKey(). Разница не в удобстве: подсветку строки рисует дерево, а всё,
что вокруг неё (заголовок панели, доступность действий), считает потребитель по
своему состоянию. Пока это два владельца одного понятия, они способны разойтись
на такт: строка подсвечена, а панель показывает другое. С пропом источник правды
один, и обёртке не нужен ни ref на дерево, ни nextTick вокруг вызова.
Проп не задан — дерево ведёт текущий узел само, как и раньше; метод остаётся для императивных сценариев.
aria-selected="true" стоит только на выбранном узле. Проставлять
false на каждом — значит заставить диктора проговаривать «не выбрано» на
каждом шаге навигации; для одиночного выбора APG этого не требует.
Фокус
Один узел на всё дерево держит tabindex="0" (roving tabindex), остальные
недостижимы Tab. DOM-узлы строк лежат в общем реестре key → element:
навигация стрелками не должна обходить DOM поддерева на каждое нажатие.
Снаружи фокус ставится через focus(key?) — без аргумента на держателя roving
tabindex. Именно им пользуется GrTreeSelect, чтобы отдать дереву клавиатуру.
При виртуализации порядок обязателен: сперва прокрутка к узлу, и только
следующим тиком фокус. Узла вне окна в DOM нет, и focus() по нему уронил бы
фокус на body вместе с размонтированной строкой.
Обратный случай — строка с фокусом уезжает из окна при прокрутке — дерево разбирает само: фокус переходит на корень, и следующая же стрелка возвращает его на видимую строку. Условие ровно одно: фокус был на этой строке. Прокрутка дерева, которого пользователь не касался, фокус страницы не трогает.
Drag & drop
draggable включает перетаскивание, allowDrag/allowDrop его ограничивают.
Ручка переноса видна по dragHandleVisibility. По умолчанию auto: под
курсором там, где наведение есть, и всегда там, где его не бывает
(@media (hover: none)). Это не украшение: на тач-устройстве событий наведения
нет вовсе, поэтому ручка «по наведению» не появляется никогда — то есть жеста,
которым начинается перетаскивание, на экране просто нет, хотя touch-action: none
у ручки стоит ровно ради него. Режимы hover и always фиксируют поведение
явно.
Решает это медиазапрос, а не matchMedia: ответ нужен и на сервере, а
спрашивать среду в первом рендере нельзя — гидрация разойдётся.
Клавиатурный путь от этого не зависит: Shift со стрелкой двигает узел без
всякой ручки.
Обработчик drop гасит дефолт браузера до всех проверок: если событие
дошло до дерева, дефолт — это навигация по брошенной ссылке или открытие
брошенного файла поверх страницы.
С virtual перетаскивание работает по отрисованным строкам: уронить узел на
тот, которого нет на экране, нельзя — автопрокрутки у края пока нет.
Контекстное меню
nodeContextMenu отдаёт исходное событие первым аргументом — вызвать
preventDefault() и показать своё меню решает потребитель.
Токены
Размер выражен переменными --gr-tree-* (высота строки, отступы, размер
иконок, кегль), а не утилитарными классами: те же переменные объявлены точками
кастомизации, и size задаёт им дефолты, а не спорит с ними вторым каналом.
Цвета выводятся из --gr-primary/--gr-muted через color-mix — без hex-
фолбэков, которые в тёмной теме давали непредсказуемый результат.
Фон строки безопасен при любом значении, включая непрозрачное. Подсветку
рисует отдельный слой (::before), вставленный слева на отступ уровня: фон на
самой коробке шёл бы от левого края дерева и накрывал направляющие предков. Слой
уведён под содержимое строки (z-index: -1), а строка изолирована
(isolation: isolate) — без изоляции отрицательный слой уехал бы за фон
ближайшего предка с заливкой, и подсветка пропала бы на карточке.
Пара обязательна целиком, и обе половины держит e2e-гейт
apps/showcase/e2e/geometry.spec.ts: он задаёт непрозрачный фон и меряет по
пикселям, что подпись видна и что подсветка при этом есть. В jsdom это не
ловится — CSS компонента там не применяется, а полупрозрачные дефолты
маскируют дефект и в браузере.
Playground 18
Загружается…
<GrTree />Установка
npm i @feugene/granularityИмпорт
import { GrTree } from '@feugene/granularity/components/GrTree'API
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
dataобязательный | T[] | — | — |
props | GrTreePropsMap | undefined | {
children: "children",
label: "label",
} | — |
nodeKey | "id" | Extract<keyof T, string> | undefined | "id" as any | — |
defaultExpandedKeys | GrTreeKey[] | undefined | [] | — |
defaultExpandAll | boolean | undefined | false | Раскрывать узлы, как только они появились в данных. Уже свёрнутое руками не раскрывается обратно на каждом обновлении `data`. |
filterNodeMethod | GrTreeFilterNodeMethod<T> | undefined | — | — |
filterValue | string | undefined | undefined | Значение фильтра. Проп, а не только метод `filter()`: обёртке иначе приходится держать `ref` на дерево и гонять значение в обход собственного реактивного контура. Результат фильтрации дерево сообщает событием `filter` — без него нельзя отличить «данных нет» от «поиск ничего не нашёл», а это разные экраны. |
lazy | boolean | undefined | false | Ленивый режим: дети ветки приходят по её раскрытию через `load`. Узел считается разворачиваемым, пока не доказано обратное — полем `isLeaf` из карты `props`. |
load | GrTreeLoad<T> | undefined | undefined | Загрузчик ветки. `resolve` дописывает детей в данные узла. |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | Размер строки: высота, отступы, иконки и кегль подписи. |
indent | number | undefined | 0 | Шаг отступа уровня в пикселях. `0` — значение из темы (`--gr-tree-indent-step`). |
virtual | boolean | undefined | false | Виртуализация: в DOM живёт только окно вокруг вьюпорта. Требует `maxHeight` — без ограниченной высоты окна прокрутки не существует; скроллером в этом режиме становится сам корень дерева. Включается осознанно: на списке в сотню узлов выигрыша нет, а в DOM остаётся только окно — вместе с ним меняется и то, что находит `querySelector` потребителя. Ограничение: перетаскивание работает по отрисованным строкам — уронить узел на тот, которого нет на экране, нельзя. |
maxHeight | string | number | undefined | undefined | Максимальная высота дерева со своим скроллером. Число — пиксели. |
highlightCurrent | boolean | undefined | true | — |
expandIcon | string | Component | undefined | undefined | Иконка свёрнутого узла: Vue-компонент либо класс иконки вашей UnoCSS-сборки (`'i-lucide-plus'` — тогда нужен ваш `presetIcons`, см. `docs/installation.md`). Не задана — встроенная стрелка. |
collapseIcon | string | Component | undefined | undefined | Иконка раскрытого узла. Не задана — та же встроенная стрелка, повёрнутая. |
toggleIconRotate | boolean | undefined | true | — |
branchLine | boolean | undefined | false | — |
branchLineColor | GrTreeBranchLineColor<T> | undefined | undefined | — |
branchLineActiveColor | GrTreeBranchLineColor<T> | undefined | undefined | — |
rowClass | GrTreeNodeClass<T> | undefined | — |
dragHandleClass | GrTreeNodeClass<T> | undefined | — |
toggleClass | GrTreeNodeClass<T> | undefined | — |
toggleIconClass | GrTreeNodeClass<T> | undefined | — |
toggleSpacerClass | GrTreeNodeClass<T> | undefined | — |
contentClass | GrTreeNodeClass<T> | undefined | — |
dragLabel | string | undefined | undefined | i18n-метка кнопки "Перетащить" (default: 'Drag'). |
expandLabel | string | undefined | undefined | i18n-метка кнопки "Развернуть" (default: 'Expand'). |
collapseLabel | string | undefined | undefined | i18n-метка кнопки "Свернуть" (default: 'Collapse'). |
showCheckbox | boolean | undefined | false | Чекбоксы у узлов: множественный выбор поверх дерева. |
currentKey | GrTreeKey | null | undefined | undefined | Текущий узел — `v-model:current-key`. Не задан — дерево ведёт текущий узел само, как и раньше. Задан — источник правды снаружи: подсветка строки и признак, по которому обёртка рисует собственные детали, перестают быть двумя разными состояниями, способными разойтись на такт. |
checkedKeys | GrTreeKey[] | undefined | undefined | Отмеченные ключи (`v-model:checked-keys`). |
defaultCheckedKeys | GrTreeKey[] | undefined | [] | — |
checkStrictly | boolean | undefined | false | Не связывать родителей и детей: каждый узел отмечается сам по себе. |
expandOnClickNode | boolean | undefined | false | Клик по строке раскрывает/сворачивает узел, а не только выбирает его. |
accordion | boolean | undefined | false | На каждом уровне раскрыт максимум один узел. |
draggable | boolean | undefined | false | — |
dragHandleIcon | string | Component | undefined | undefined | Иконка ручки переноса: компонент, класс иконки или ничего — тогда встроенная. |
allowDrop | ((draggingNode: GrTreeNode<T>, dropNode: GrTreeNode<T>, type: GrTreeNodeDropType) => boolean) | undefined | — | — |
allowDrag | ((draggingNode: GrTreeNode<T>) => boolean) | undefined | — | — |
dragHandleVisibility | "auto" | "hover" | "always" | undefined | "auto" | Когда показывать ручку переноса. `hover` — только под курсором; `always` — всегда; `auto` (по умолчанию) — `always` там, где наведения не бывает (`@media (hover: none)`), иначе `hover`. На тач-устройстве ручка «по наведению» недостижима вовсе, то есть перетаскивания там нет — а это не решение дизайна, а отсутствие функции. |
Slots
| Slot | Type | Описание |
|---|---|---|
default | { node: GrTreeNode<T>; data: T; } | — |
Events
| Event | Type | Описание |
|---|---|---|
nodeClick | [T, GrTreeNode<T>] | — |
nodeExpand | [T, GrTreeNode<T>] | — |
nodeCollapse | [T, GrTreeNode<T>] | — |
nodeDrop | [GrTreeNode<T>, GrTreeNode<T>, GrTreeNodeDropType] | — |
nodeContextMenu | [MouseEvent, T, GrTreeNode<T>] | — |
update:currentKey | [GrTreeKey | undefined] | — |
filter | [{ value: string; visibleCount: number; matchedCount: number; }] | Результат фильтрации. Без него потребитель не отличит «данных нет» от «поиск ничего не нашёл» — а это разные пустые экраны: второй пользователь может исправить сам. |
update:checkedKeys | [GrTreeKey[]] | — |
check | [T, GrTreeNode<T>, { checkedKeys: GrTreeKey[]; halfCheckedKeys: GrTreeKey[]; }] | — |
Примеры 9
Управляемый выбор и достижимая ручка переноса
Выбранный узел живёт снаружи через v-model:current-key, поэтому подсветка строки и состояние обёртки не могут разойтись. dragHandleVisibility решает, когда видна ручка переноса: по умолчанию auto — всегда там, где наведения не бывает.
Выбрано: Одежда (11)
dragHandleVisibility решает, когда видна ручка переноса. По умолчанию auto: под курсором на устройстве с наведением и всегда там, где наведения не бывает. На тач-экране ручка «по наведению» недостижима, то есть перетаскивания там нет вовсе.
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { GrTreeKey } from '@feugene/granularity'
import { GrRadioGroup, GrTree } from '@feugene/granularity'
type TreeItem = {
id: number
label: string
children?: TreeItem[]
}
const treeData: TreeItem[] = [
{
id: 1,
label: 'Каталог',
children: [
{ id: 11, label: 'Одежда' },
{ id: 12, label: 'Обувь' },
],
},
{
id: 2,
label: 'Склады',
children: [
{ id: 21, label: 'Москва' },
{ id: 22, label: 'Казань' },
],
},
]
/**
* Выбранный узел живёт снаружи, и это не поза: подсветку строки рисует дерево,
* а всё остальное — заголовок панели, доступность действий — потребитель. Будь
* у понятия два владельца, они разошлись бы на такт, и строка оказалась бы
* подсвечена там, где панель показывает другое.
*/
const currentKey = ref<GrTreeKey | null>(11)
const flat = computed(() => {
const result: TreeItem[] = []
const walk = (items: TreeItem[]) => items.forEach((item) => {
result.push(item)
if (item.children)
walk(item.children)
})
walk(treeData)
return result
})
const currentLabel = computed(() => flat.value.find(item => item.id === currentKey.value)?.label ?? '—')
const handleVisibility = ref<'auto' | 'hover' | 'always'>('auto')
const visibilityOptions = [
{ value: 'auto', label: 'auto' },
{ value: 'hover', label: 'hover' },
{ value: 'always', label: 'always' },
] satisfies Array<{ value: 'auto' | 'hover' | 'always', label: string }>
</script>
<template>
<div class="grid gap-4">
<GrTree
v-model:current-key="currentKey"
:data="treeData"
node-key="id"
default-expand-all
draggable
:drag-handle-visibility="handleVisibility"
branch-line
/>
<div class="showcase-demo-panel grid gap-3 rounded-[var(--gr-radius-lg)] border p-4">
<p class="showcase-demo-text text-sm">
Выбрано: <strong>{{ currentLabel }}</strong> (<code>{{ currentKey ?? 'null' }}</code>)
</p>
<GrRadioGroup v-model="handleVisibility" :options="visibilityOptions" variant="button" size="sm" />
<p class="showcase-demo-text text-sm">
<code>dragHandleVisibility</code> решает, когда видна ручка переноса. По умолчанию
<code>auto</code>: под курсором на устройстве с наведением и всегда там, где наведения не
бывает. На тач-экране ручка «по наведению» недостижима, то есть перетаскивания там нет вовсе.
</p>
</div>
</div>
</template>Управляемое раскрытие и линии ветвей
Показываем GrTree как иерархический explorer, где внешняя orchestration управляет раскрытием групп и визуальными branch lines.
<script setup lang="ts">
import { ref } from 'vue'
import { GrBadge, GrButton, GrTree } from '@feugene/granularity'
type TreeItem = {
id: number
label: string
children?: TreeItem[]
}
const treeData: TreeItem[] = [
{
id: 1,
label: 'Operations',
children: [
{ id: 11, label: 'Escalations' },
{ id: 12, label: 'Runbooks' },
],
},
{
id: 2,
label: 'Billing',
children: [
{ id: 21, label: 'Invoices' },
{ id: 22, label: 'Disputes' },
],
},
{
id: 3,
label: 'Support',
children: [
{ id: 31, label: 'Priority queue' },
{ id: 32, label: 'Knowledge base' },
],
},
]
const expandedKeys = ref<Array<number | string>>([1, 2])
</script>
<template>
<div class="grid gap-4">
<div class="flex flex-wrap gap-2">
<GrButton size="sm" variant="outline" @click="expandedKeys = [1, 2, 3]">
Expand all groups
</GrButton>
<GrButton size="sm" variant="ghost" @click="expandedKeys = [2]">
Focus billing
</GrButton>
</div>
<GrTree :data="treeData" :default-expanded-keys="expandedKeys" branch-line />
<div class="flex flex-wrap gap-2">
<GrBadge v-for="key in expandedKeys" :key="key">
Expanded: {{ key }}
</GrBadge>
</div>
</div>
</template>Этот сценарий подчёркивает, что GrTree хорошо работает как controlled navigation/data primitive, а не только как статичное дерево.
Фильтрация через API экземпляра
Фильтрацию важно показывать не как магический prop, а как реальную интеграцию через expose-метод filter() и внешний input.
<script setup lang="ts">
import { ref } from 'vue'
import { GrBadge, GrEmptyState, GrInput, GrTree } from '@feugene/granularity'
type TreeItem = {
id: number
label: string
team: string
children?: TreeItem[]
}
const treeData: TreeItem[] = [
{
id: 1,
label: 'Incident management',
team: 'Operations',
children: [
{ id: 11, label: 'Pager duty', team: 'Operations' },
{ id: 12, label: 'Postmortems', team: 'Operations' },
],
},
{
id: 2,
label: 'Revenue ops',
team: 'Billing',
children: [
{ id: 21, label: 'Chargebacks', team: 'Billing' },
{ id: 22, label: 'Usage reports', team: 'Billing' },
],
},
{
id: 3,
label: 'Customer support',
team: 'Support',
children: [
{ id: 31, label: 'Macros', team: 'Support' },
{ id: 32, label: 'SLA queues', team: 'Support' },
],
},
]
const query = ref('')
/**
* Результат фильтрации приходит от дерева, а не считается вторым проходом по
* данным. Разница видна в пустом экране: «ничего не нашлось» и «данных нет» —
* разные сообщения, и первое пользователь может исправить сам.
*/
const matched = ref(treeData.length)
const visible = ref(treeData.length)
</script>
<template>
<div class="grid gap-4">
<GrInput v-model="query" placeholder="Filter tree nodes by label or team" aria-label="Filter tree nodes" />
<GrTree
:data="treeData"
:filter-value="query"
:filter-node-method="(value, data) => `${data.label} ${data.team}`.toLowerCase().includes(String(value).toLowerCase())"
branch-line
@filter="({ matchedCount, visibleCount }) => { matched = matchedCount; visible = visibleCount }"
/>
<GrEmptyState
v-if="visible === 0"
title="Nothing matches the query"
description="Try a shorter word — the filter looks at both the label and the team."
/>
<GrBadge>
Matches: {{ matched }}
</GrBadge>
</div>
</template>Полезный integration recipe для search/filter поверх больших справочников и nested navigation.
Перетаскивание и свой слот строки
Комбинируем две важные возможности complex-дерева: rearrange drag-and-drop и кастомный рендер строки через default slot.
<script setup lang="ts">
import { ref } from 'vue'
import { GrBadge, GrTree } from '@feugene/granularity'
type TreeItem = {
id: number
label: string
status: 'healthy' | 'warning' | 'critical'
children?: TreeItem[]
}
const treeData = ref<TreeItem[]>([
{
id: 1,
label: 'Payments',
status: 'critical',
children: [
{ id: 11, label: 'Retries', status: 'warning' },
{ id: 12, label: 'Settlement', status: 'healthy' },
],
},
{
id: 2,
label: 'Identity',
status: 'warning',
children: [
{ id: 21, label: 'Sessions', status: 'healthy' },
{ id: 22, label: 'Recovery', status: 'warning' },
],
},
])
const lastDrop = ref('Drag a row handle to reorder or nest nodes')
// Текст на тонированной подложке — из `-text`, а не из насыщенного тона:
// `--gr-success` на `--gr-success-light` даёт 2.24:1.
function resolveTone(status: TreeItem['status']) {
if (status === 'critical')
return 'bg-[var(--gr-danger-light)] text-[var(--gr-danger-text)]'
if (status === 'warning')
return 'bg-[var(--gr-warning-light)] text-[var(--gr-warning-text)]'
return 'bg-[var(--gr-success-light)] text-[var(--gr-success-text)]'
}
</script>
<template>
<div class="grid gap-4">
<GrTree
:data="treeData"
:default-expanded-keys="[1, 2]"
draggable
branch-line
@node-drop="(draggingNode, dropNode, dropType) => lastDrop = `${draggingNode.label} → ${dropNode.label} (${dropType})`"
>
<template #default="{ data }">
<div class="flex flex-wrap items-center gap-2">
<span>{{ data.label }}</span>
<span class="rounded-full px-2 py-1 text-xs font-600" :class="resolveTone(data.status)">
{{ data.status }}
</span>
</div>
</template>
</GrTree>
<GrBadge>
{{ lastDrop }}
</GrBadge>
</div>
</template>Сценарий особенно важен для real-world деревьев с ownership/status метаданными и операторскими перестановками.
Шкала размеров
Размер выражен CSS-переменными --gr-tree-*: те же точки кастомизации, что и для ручной настройки, — size просто задаёт им дефолты.
<script setup lang="ts">
import { GrTree } from '@feugene/granularity'
const sizes = ['xs', 'sm', 'md', 'lg'] as const
const data = [
{
id: 'src',
label: 'src',
children: [
{ id: 'components', label: 'components' },
{ id: 'composables', label: 'composables' },
],
},
]
</script>
<template>
<div class="grid gap-4 sm:grid-cols-2">
<div v-for="size in sizes" :key="size" class="grid gap-2">
<div class="text-xs font-semibold text-[var(--gr-muted-fg)]">
size="{{ size }}"
</div>
<GrTree :data="data" :size="size" :default-expanded-keys="['src']" />
</div>
</div>
</template>Чекбоксы и множественный выбор
Чекбоксы включаются пропом show-checkbox, набор ведётся через v-model:checked-keys. Родитель отмечается каскадом и показывает mixed, когда отмечена часть детей; check-strictly эту связь отключает. Состояние объявляется на самом узле (aria-checked), а квадратик остаётся декоративным — вкладывать интерактивный чекбокс внутрь роли treeitem нельзя.
<script setup lang="ts">
import { ref } from 'vue'
import { GrBadge, GrTree } from '@feugene/granularity'
type Permission = { id: string, label: string, children?: Permission[] }
// Типовой сценарий чекбоксов в дереве — выдача прав по разделам.
const permissions: Permission[] = [
{
id: 'billing',
label: 'Billing',
children: [
{ id: 'billing.read', label: 'View invoices' },
{ id: 'billing.write', label: 'Issue invoices' },
{ id: 'billing.refund', label: 'Refund payments' },
],
},
{
id: 'team',
label: 'Team',
children: [
{ id: 'team.read', label: 'View members' },
{ id: 'team.invite', label: 'Invite members' },
],
},
]
const checkedKeys = ref<(string | number)[]>(['billing.read'])
</script>
<template>
<div class="grid gap-3">
<GrTree
v-model:checked-keys="checkedKeys"
:data="permissions"
node-key="id"
show-checkbox
:default-expanded-keys="['billing', 'team']"
/>
<div class="flex flex-wrap items-center gap-2">
<GrBadge tone="neutral">
Отмечено: {{ checkedKeys.length }}
</GrBadge>
<GrBadge v-for="key in checkedKeys" :key="key" tone="info">
{{ key }}
</GrBadge>
</div>
</div>
</template>Ленивые ветки
В режиме lazy дети ветки приходят по её раскрытию: load получает узел и resolve, на время запроса строка показывает спиннер и помечается aria-busy. Повторное раскрытие запрос не делает. Лист объявляется полем isLeaf в данных — иначе дерево считает ветку разворачиваемой, пока не доказано обратное.
<script setup lang="ts">
import { GrTree, type GrTreeNode } from '@feugene/granularity'
type Folder = { id: string, label: string, isLeaf?: boolean, children?: Folder[] }
// Корень приходит с сервера сразу, ветки — по раскрытию.
const roots: Folder[] = [
{ id: 'src', label: 'src' },
{ id: 'docs', label: 'docs' },
{ id: 'README.md', label: 'README.md', isLeaf: true },
]
function loadChildren(node: GrTreeNode<Folder>, resolve: (children: Folder[]) => void): void {
window.setTimeout(() => {
resolve([
{ id: `${node.key}/index.ts`, label: 'index.ts', isLeaf: true },
{ id: `${node.key}/nested`, label: 'nested' },
])
}, 600)
}
</script>
<template>
<GrTree
:data="roots"
node-key="id"
lazy
:load="loadChildren"
/>
</template>Клавиатура и режимы раскрытия
Typeahead по первым буквам, * на весь уровень, плюс accordion и expandOnClickNode — то, чем дерево управляется без мыши.
- Наберите «cur» — фокус уедет на Currencies.
- Повторное нажатие одной буквы идёт по кругу.
*раскрывает все узлы уровня разом.
<script setup lang="ts">
import { ref } from 'vue'
import { GrBadge, GrSwitch, GrTree } from '@feugene/granularity'
type Node = {
id: string
label: string
children?: Node[]
}
const data: Node[] = [
{
id: 'billing',
label: 'Billing',
children: [
{ id: 'invoices', label: 'Invoices' },
{ id: 'payouts', label: 'Payouts' },
],
},
{
id: 'catalog',
label: 'Catalog',
children: [
{ id: 'categories', label: 'Categories' },
{ id: 'currencies', label: 'Currencies' },
],
},
{
id: 'delivery',
label: 'Delivery',
children: [
{ id: 'couriers', label: 'Couriers' },
{ id: 'warehouses', label: 'Warehouses' },
],
},
]
const accordion = ref(true)
const expandOnClickNode = ref(true)
const lastSelected = ref('—')
</script>
<template>
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_260px]">
<div class="grid gap-4">
<div class="flex flex-wrap items-center gap-4">
<label class="flex items-center gap-2 text-sm text-[var(--gr-muted-fg)]">
<GrSwitch v-model="accordion" size="sm" />
accordion
</label>
<label class="flex items-center gap-2 text-sm text-[var(--gr-muted-fg)]">
<GrSwitch v-model="expandOnClickNode" size="sm" />
expandOnClickNode
</label>
</div>
<div class="rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-3">
<GrTree
:data="data"
node-key="id"
:accordion="accordion"
:expand-on-click-node="expandOnClickNode"
default-expand-all
@node-click="(item: Node) => (lastSelected = item.label)"
/>
</div>
</div>
<div class="rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4 text-sm text-[var(--gr-muted-fg)]">
<div>
Выбрано:
<GrBadge class="ml-1">
{{ lastSelected }}
</GrBadge>
</div>
<ul class="mt-3 grid gap-1">
<li>Наберите «cur» — фокус уедет на Currencies.</li>
<li>Повторное нажатие одной буквы идёт по кругу.</li>
<li><code>*</code> раскрывает все узлы уровня разом.</li>
</ul>
</div>
</div>
</template>Дерево на 10 000 узлов
С virtual и maxHeight дерево держит в DOM только окно вокруг вьюпорта, а скроллером становится его корень. Плоская разметка строк это и открывает.
<script setup lang="ts">
import { GrTree } from '@feugene/granularity'
// Настоящее дерево на три уровня: 100 регионов × 10 площадок × 10 линий = 10 000
// листьев (плюс 1100 ветвей). Виртуализация работает по раскрытым строкам, а не по
// корням, поэтому раскрытие ветки в таком дереве стоит столько же, сколько в малом.
const data = Array.from({ length: 100 }, (_, region) => ({
id: `r${region + 1}`,
label: `Region ${region + 1}`,
children: Array.from({ length: 10 }, (_, site) => ({
id: `r${region + 1}-s${site + 1}`,
label: `Site ${region + 1}.${site + 1}`,
children: Array.from({ length: 10 }, (_, line) => ({
id: `r${region + 1}-s${site + 1}-l${line + 1}`,
label: `Line ${region + 1}.${site + 1}.${line + 1}`,
})),
})),
}))
// Пара раскрытых ветвей на старте: видно и вложенность, и направляющие уровней.
const defaultExpandedKeys = ['r1', 'r1-s1', 'r2']
</script>
<template>
<GrTree
:data="data"
node-key="id"
:default-expanded-keys="defaultExpandedKeys"
branch-line
virtual
:max-height="320"
/>
</template>aria-setsize/aria-posinset остаются от полного набора, а не от окна. Перетаскивание при этом работает по отрисованным строкам: уронить узел на тот, которого нет на экране, нельзя.
Доступность
- Паттерн APG
tree (roving tabindex)- Клавиши
↓/↑— по видимым узлам,→— раскрыть/внутрь,←— свернуть/к родителю,Home/End— к краям,Enter— выбрать,Space— выбрать (а приshowCheckbox— переключить отметку),*— раскрыть всех соседей уровня, печатные символы — typeahead; приdraggable—Shift+↑/↓переставить среди соседей,Shift+→сделать дочерним предыдущего соседа,Shift+←вынести к родителю