GrTree
Shows a hierarchy of items with node expansion and selection.
Machine-translated from the Russian original, not yet reviewed. Read the original
When to take it
- the data is nested — files, categories, an org structure, sections: the levels are the essence of the data;
- the nodes are ticked — with inheritance to the parents or strictly (
checkStrictly); - the children are loaded on demand —
lazywithloadinstead of dumping the whole tree at once; - the tree is filtered —
filterNodeMethodkeeps the matched nodes together with their branches; - the nodes are moved —
draggableworks with the mouse, with a finger and from the keyboard.
When to take something else
| Need | Take |
|---|---|
| The tree is needed in a selection panel rather than on the screen | GrTreeSelect |
| There is one level | GrList |
| The data is tabular | GrDataTable |
| The order in a flat list | GrSortableList |
| Sections of which one is open | GrCollapse |
The markup: a flat list instead of a nested DOM
The tree is rendered as a single list of rows rather than as nested components: a node of any
level is a div[role="treeitem"] in the shared role="tree" container, and the indentation is set
by the padding-left of the row. The hierarchy is carried by aria-level, aria-posinset and
aria-setsize — with a flat representation the tree pattern requires all three.
The practical consequences:
- the depth of the tree no longer turns into the depth of the tree of components: 2,000 expanded nodes are 2,000 rows rather than 2,000 instances with computations of their own;
- the highlight of a row (hover, the current node) and the focus ring start from the indentation
of the level rather than from the left edge of the tree: otherwise the background would cover the
guides of the ancestors — the line of the branch would be invisible exactly where it is needed. They
are drawn by a separate layer of the row inserted on the left by
--gr-tree-row-indent; - the step of the indentation is configured with the
indentprop (in pixels) or by the theme —--gr-tree-indent-step.
Filtering
The value of the filter is the filterValue prop; the filter() method remains for imperative
scenarios. The result is reported by the tree with a filter event: visibleCount is how many rows
are really visible (the matches plus the parents expanded for their sake), and matchedCount is how
many nodes matched themselves.
The number is needed outside not for a counter: without it the consumer will not tell “there is no data” from “the search found nothing”, and those are different empty screens — the second the user can fix themselves, the first they cannot.
Virtualisation
virtual together with maxHeight keeps in the DOM only the window around the viewport — it is
precisely the flat markup above that makes that possible. The root of the tree itself becomes the
scroller, and what is cut off at the top and at the bottom is held by its padding: wrappers between
role="tree" and role="treeitem" would take the required children away from the role.
<GrTree :data="nodes" node-key="id" virtual :max-height="400" />
aria-setsize and aria-posinset stay from the full set rather than from the window — otherwise a
screen reader would announce “1 of 20” on a list of a thousand nodes.
Switch it on deliberately: on a hundred rows there is no gain, while the markup changes and with it
what the consumer’s querySelector finds. The height of a row is taken from
--gr-tree-row-min-height as an estimate and refined by measuring the rendered rows. How it
works and what it costs — virtual-list.md.
Expansion
| Prop | What it does |
|---|---|
defaultExpandedKeys | the starting set of expanded nodes |
defaultExpandAll | expands a node at the moment it appears in the data |
expandOnClickNode | a click on a row expands a node rather than only selecting it |
accordion | at most one node is expanded at every level |
defaultExpandAll deliberately does not “keep everything expanded”: it marks the keys already seen,
so what was collapsed by hand is not expanded back on every update of data. Otherwise a live tree
from a database would collapse the user’s work on every refetch.
expandOnClickNode applies to the mouse only. Enter in the tree pattern is reserved for the
selection, and mixing expansion into it is not allowed — otherwise selecting a folder from the
keyboard would become impossible.
The icons
expandIcon, collapseIcon and dragHandleIcon are not set — the tree draws its built-in ones,
and they do not depend on the config of the application. An icon of your own is passed as a Vue
component or as an icon class from your UnoCSS build (i-lucide-* — then your presetIcons is
needed, see “Icons”).
toggleIconRotate rotates the expansion icon by 90° instead of changing the picture — so a single
expandIcon is usually enough.
The checkboxes
<GrTree v-model:checked-keys="checked" :data="data" node-key="id" show-checkbox />
The ticks are linked along the tree: a ticked parent ticks all of its descendants, and a partly
ticked one shows aria-checked="mixed". checkStrictly switches that link off — every node answers
for itself.
The state is announced on the node itself (aria-checked on the treeitem), while the visible
square is decorative and hidden from a screen reader: nesting an interactive checkbox inside a
widget role is not allowed — the role declares its descendants presentational. The root meanwhile
becomes aria-multiselectable="true".
Space with the checkboxes switched on toggles the tick (that is what selection in a multi-select
tree is), and Enter still selects a node.
Imperatively: getCheckedKeys({ leafOnly }), setCheckedKeys(), getHalfCheckedKeys(),
setChecked(node, checked). The checkedKeys from the outside may contain leaves only — the
parents will be computed by themselves.
Lazy loading
<GrTree :data="roots" node-key="id" lazy :load="loadChildren" />
function loadChildren(node: GrTreeNode<Folder>, resolve: (children: Folder[]) => void) {
fetchChildren(node.key).then(resolve)
}
In the lazy mode a node counts as expandable until the opposite is proved: a leaf is declared with
the isLeaf field in the data (the name is configured with the props map). A branch is loaded on
the first expansion — a repeat makes no request; for the duration of the request the row shows a
spinner and is marked aria-busy.
The loaded children live in the state of the component rather than being written into data:
the prop is not obliged to be reactive, and the tree that has arrived has to be shown in any case.
An empty answer makes the branch a leaf.
defaultExpandAll does not touch unloaded branches — otherwise the first render would pull the
whole backend.
Selection and announcing to a screen reader
The current node is set with a prop — v-model:current-key — rather than only with the
setCurrentKey() method. The difference is not one of convenience: the highlight of the row is
drawn by the tree, while everything around it (the heading of the panel, the availability of the
actions) is computed by the consumer from their own state. As long as those are two owners of one
notion, they are able to diverge by a tick: the row is highlighted while the panel shows something
else. With the prop the source of truth is one, and the wrapper needs neither a ref on the tree
nor a nextTick around the call.
If the prop is not set, the tree runs the current node itself, as before; the method remains for imperative scenarios.
aria-selected="true" stands only on the selected node. Putting a false on every one of them
would mean making a screen reader pronounce “not selected” at every step of the navigation; for a
single selection the APG does not require that.
The focus
One node in the whole tree holds tabindex="0" (a roving tabindex), and the rest are unreachable
with Tab. The DOM nodes of the rows lie in a shared key → element registry: navigation with the
arrows must not walk the DOM of the subtree on every press.
From the outside the focus is set through focus(key?) — without an argument onto the holder of the
roving tabindex. That is exactly what GrTreeSelect uses to give the keyboard to the tree.
With virtualisation the order is mandatory: first the scroll to the node, and only on the next tick
the focus. A node outside the window is not in the DOM, and a focus() on it would drop the focus
onto body together with the unmounted row.
The reverse case — the focused row moving out of the window while scrolling — is handled by the tree itself: the focus moves to the root, and the very next arrow returns it to a visible row. There is exactly one condition: the focus was on that row. Scrolling a tree the user has not touched does not affect the focus of the page.
Drag & drop
draggable switches dragging on, and allowDrag/allowDrop limit it.
The drag handle is visible by dragHandleVisibility. The default is auto: under the cursor
where hovering exists, and always where it does not (@media (hover: none)). That is not an
ornament: on a touch device there are no hover events at all, so a handle “on hover” never appears —
that is, the gesture with which the dragging begins is simply not on the screen, although the
touch-action: none of the handle is there precisely for it. The hover and always modes fix the
behaviour explicitly.
That is decided by a media query rather than by matchMedia: the answer is needed on the server as
well, and asking the environment in the first render is not allowed — the hydration would diverge.
The keyboard path does not depend on that: Shift with an arrow moves a node with no handle at all.
The drop handler suppresses the browser’s default before all of the checks: if the event
reached the tree, the default is navigating to the dropped link or opening the dropped file over the
page.
With virtual dragging works over the rendered rows: a node cannot be dropped onto one that is not
on the screen — there is no auto-scrolling at the edge yet.
The tokens
The size is expressed with the --gr-tree-* variables (the height of a row, the padding, the size
of the icons, the type size) rather than with utility classes: the same variables are declared as
points of customisation, and size sets defaults for them rather than arguing with them through a
second channel. The colours are derived from --gr-primary/--gr-muted through color-mix —
without hex fallbacks, which gave an unpredictable result in the dark theme.
The background of a row is safe with any value, an opaque one included. The highlight is drawn
by a separate layer (::before) inserted on the left by the indentation of the level: a background
on the box itself would run from the left edge of the tree and would cover the guides of the
ancestors. The layer is taken under the content of the row (z-index: -1), and the row is isolated
(isolation: isolate) — without the isolation the negative layer would go behind the background of
the nearest ancestor with a fill, and the highlight would disappear on a card.
The pair is mandatory as a whole, and both halves are held by the
apps/showcase/e2e/geometry.spec.ts e2e gate: it sets an opaque background and measures by pixels
that the label is visible and that the highlight is there. In jsdom that is not caught — the CSS of
the component does not apply there, and semi-transparent defaults mask the defect in the browser as
well.
Playground 18
Loading…
<GrTree />Install
npm i @feugene/granularityImport
import { GrTree } from '@feugene/granularity/components/GrTree'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
datarequired | 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 | Expand the nodes as soon as they appear in the data. What has been collapsed by hand is not expanded back on every update of `data`. |
filterNodeMethod | GrTreeFilterNodeMethod<T> | undefined | — | — |
filterValue | string | undefined | undefined | The value of the filter. A prop rather than the `filter()` method alone: otherwise a wrapper has to keep a `ref` to the tree and drive the value around its own reactive circuit. The tree reports the result of the filtering by the `filter` event — without it "there is no data" cannot be told from "the search found nothing", and those are different screens. |
lazy | boolean | undefined | false | The lazy mode: the children of a branch arrive on its expansion through `load`. A node counts as expandable until the opposite is proved — by the `isLeaf` field from the `props` map. |
load | GrTreeLoad<T> | undefined | undefined | The loader of a branch. `resolve` appends the children to the data of the node. |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | The size of a row: the height, the paddings, the icons and the type size of the label. |
indent | number | undefined | 0 | The step of the indent of a level in pixels. `0` means the value from the theme (`--gr-tree-indent-step`). |
virtual | boolean | undefined | false | Virtualisation: only a window around the viewport lives in the DOM. It requires `maxHeight` — without a limited height there is no window of scrolling; the root of the tree itself becomes the scroller in this mode. It is turned on deliberately: on a list of a hundred nodes there is no gain, and only the window stays in the DOM — and with it changes what the consumer’s `querySelector` finds. A limitation: dragging works over the rendered rows — a node cannot be dropped onto one that is not on the screen. |
maxHeight | string | number | undefined | undefined | The maximum height of the tree with a scroller of its own. A number means pixels. |
highlightCurrent | boolean | undefined | true | — |
expandIcon | string | Component | undefined | undefined | The icon of a collapsed node: a Vue component or the class of an icon from your UnoCSS build (`'i-lucide-plus'` — then your `presetIcons` is needed, see `docs/installation.md`). Unset — the built-in arrow. |
collapseIcon | string | Component | undefined | undefined | The icon of an expanded node. Unset — the same built-in arrow, rotated. |
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 | The i18n label of the "Drag" button (default: 'Drag'). |
expandLabel | string | undefined | undefined | The i18n label of the "Expand" button (default: 'Expand'). |
collapseLabel | string | undefined | undefined | The i18n label of the "Collapse" button (default: 'Collapse'). |
showCheckbox | boolean | undefined | false | Checkboxes at the nodes: a multiple choice on top of the tree. |
currentKey | GrTreeKey | null | undefined | undefined | The current node — `v-model:current-key`. Unset and the tree runs the current node itself, as before. Set and the source of truth is outside: the highlight of the row and the token by which the wrapper draws details of its own stop being two different states able to diverge by a tick. |
checkedKeys | GrTreeKey[] | undefined | undefined | The checked keys (`v-model:checked-keys`). |
defaultCheckedKeys | GrTreeKey[] | undefined | [] | — |
checkStrictly | boolean | undefined | false | Do not link the parents and the children: every node is checked on its own. |
expandOnClickNode | boolean | undefined | false | A click on a row expands or collapses the node rather than merely selecting it. |
accordion | boolean | undefined | false | At every level at most one node is expanded. |
draggable | boolean | undefined | false | — |
dragHandleIcon | string | Component | undefined | undefined | The icon of the drag handle: a component, the class of an icon, or nothing — then the built-in one. |
allowDrop | ((draggingNode: GrTreeNode<T>, dropNode: GrTreeNode<T>, type: GrTreeNodeDropType) => boolean) | undefined | — | — |
allowDrag | ((draggingNode: GrTreeNode<T>) => boolean) | undefined | — | — |
dragHandleVisibility | "auto" | "hover" | "always" | undefined | "auto" | When to show the drag handle. `hover` — only under the cursor; `always` — always; `auto` (the default) — `always` where there is no hovering (`@media (hover: none)`), otherwise `hover`. On a touch device a handle "on hover" is unreachable altogether, which is to say there is no dragging there — and that is not a decision of the design but the absence of a feature. |
Slots
| Slot | Type | Description |
|---|---|---|
default | { node: GrTreeNode<T>; data: T; } | — |
Events
| Event | Type | Description |
|---|---|---|
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; }] | The result of the filtering. Without it the consumer cannot tell "there is no data" from "the search found nothing" — and those are different empty screens: the second one the user can put right themselves. |
update:checkedKeys | [GrTreeKey[]] | — |
check | [T, GrTreeNode<T>, { checkedKeys: GrTreeKey[]; halfCheckedKeys: GrTreeKey[]; }] | — |
Examples 9
Controlled selection and reachable drag handle
Выбрано: Одежда (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>Controlled expanded state and 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>Filtering through instance API
<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>Drag-and-drop with custom row 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>Sizes
<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>Checkboxes and multiple selection
<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 branches
<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>Keyboard
- Наберите «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>Virtual
<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>Accessibility
- APG pattern
tree (roving tabindex)