GrTree
Shows a hierarchy of items with node expansion and selection.
Overview
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 |
Playground controls come from the API, not from hand
Loading…
0 changed· state is in the URL
<GrTree :data="data" defaultExpandAll />Examples 3 each with a live demo and its code
Files of a repository
The
explorerview is a set of the tree’s own variables laid intostyle, with the monospace family next to it;branch-line="elbow"draws the knee of every link and the default slot gives it a file glyph to land on.vendorislazy—loadbrings its children on opening. Files move withdraggable, mouse or Shift with the arrows;allow-dropkeeps them out of files,allow-dragpins the README, andnode-dropandnode-context-menureport back.billing-service Drag a file into another folder, or focus it and use Shift with the arrowssrcinvoicesprorate.tsrender.tspaymentsindex.tsvendorREADME.mdRights of a role, area by area
show-checkboxwithv-model:checked-keysmakes the tree a set of rights, and a half-checked area is the honest answer «part of it»: thecheckevent andgetHalfCheckedKeys()name such areas for the summary.check-strictlyunties parents from children when an area is a right of its own. Thepickerview gives 38px rows and a bigger box — the target is the box, not the label.Role · Accountant6 rights · every area whole or noneInvoicesView invoicesCreate and edit draftsSend to customersVoid issued invoicesPaymentsView paymentsIssue refundsReportsRevenueTax summary
API generated from the package · cannot drift
Props
| Prop | Type | default | Description |
|---|---|---|---|
datarequired | T[] | — | The root nodes in their original form. The tree does not copy them: a move and `appendNode` change these arrays in place. |
props | GrTreePropsMap | undefined | {
children: "children",
label: "label",
} | The names of the fields in the data: `children`, `label`, `isLeaf`. `children` and `label` by default. |
nodeKey | "id" | Extract<keyof T, string> | undefined | "id" as any | The field with the unique key of a node: expansion, checks and moves rest on it. `id` by default. |
defaultExpandedKeys | GrTreeKey[] | undefined | [] | The initial set of expanded nodes. A new array in the prop replaces the expansion entirely. |
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 | — | A rule of your own for matching a node against the filter; `undefined` from it means the default rule: a case-insensitive substring of the label. A matched node stays visible together with its ancestors. |
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 its own scroller — always, not only with `virtual`: that one merely lays a window of rows on top. A number is pixels. |
highlightCurrent | boolean | undefined | true | Highlight the row of the current node. `true` by default. |
expandIcon | string | Component | undefined | undefined | The icon of a collapsed node: a Vue component or an icon class (`'i-lucide-plus'` — the application sets up the rule for the class: in the engine factory or through a provider, see the “Icons” section of `docs/installation.md`). Not set — 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 | Rotate the icon of an expanded node by 90°: a single `expandIcon` is enough. `true` by default. |
branchLine | boolean | GrTreeBranchLine | undefined | "line" | The guide lines of the levels. `true` is the same as `'line'`: one vertical per level. `'elbow'` adds a horizontal elbow to the row and cuts the line off at the middle of the last child — that way not only the level is visible, but also which branches still continue. |
branchLineColor | GrTreeBranchLineColor<T> | undefined | undefined | The colour of the guide lines: a CSS value or a function of the node. Not set — `--gr-tree-branch-line-default-color`, otherwise the border colour. |
branchLineActiveColor | GrTreeBranchLineColor<T> | undefined | undefined | The colour of the guide line at the branch of the current node: a value or a function. Not set — `branchLineColor`. |
rowClass | GrTreeNodeClass<T> | undefined | The class of a node row — as a value or a function of the row (`node`, `isExpanded`, `isLeaf`, `isMatched`). It overrides the type size and the colour of the tree: that is how steps by level are made. |
dragHandleClass | GrTreeNodeClass<T> | undefined | The class of the drag handle — as a value or a function of the row. |
toggleClass | GrTreeNodeClass<T> | undefined | The class of the expand button — as a value or a function of the row. |
toggleIconClass | GrTreeNodeClass<T> | undefined | The class of the expand icon — as a value or a function of the row. |
toggleSpacerClass | GrTreeNodeClass<T> | undefined | The class of the spacer in place of the expand button on a leaf — as a value or a function of the row. |
contentClass | GrTreeNodeClass<T> | undefined | The class of the wrapper of the row content (the label or the slot) — as a value or a function of the row. |
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. |
currentKeyv-model | 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. |
checkedKeysv-model | GrTreeKey[] | undefined | undefined | The checked keys (`v-model:checked-keys`). |
defaultCheckedKeys | GrTreeKey[] | undefined | [] | The initial checks when `checkedKeys` are not driven from outside. Read once, on creation. |
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 | Moving nodes with the mouse, a finger and `Shift` with an arrow. Limited by `allowDrag` and `allowDrop`. |
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 |
| — | Whether a node can be dropped onto the target: `prev` — before it, `inner` — inside, `next` — after. |
allowDrag | ((draggingNode: GrTreeNode<T>) => boolean) | undefined | — | Whether a node can be picked up: `false` switches off its drag handle. |
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; } | The content of a row instead of the label: a badge, a counter, a mark. It receives `node` and `data`. |
Events
| Event | Signature | Description |
|---|---|---|
nodeClick | (e: "nodeClick", data: T, node: GrTreeNode<T>): void | A node has been selected by a click or `Enter` and has become the current one. |
nodeExpand | (e: "nodeExpand", data: T, node: GrTreeNode<T>): void | A node has been expanded by the button, the keyboard or a click with `expandOnClickNode`. |
nodeCollapse | (e: "nodeCollapse", data: T, node: GrTreeNode<T>): void | A node has been collapsed — by the user or by `accordion` when a sibling was expanded. |
nodeDrop |
| A node has been moved by the pointer or `Shift` with an arrow and is already rearranged in `data`. `dropType` is `prev`, `inner` or `next` relative to `dropNode`. |
nodeContextMenu | (e: "nodeContextMenu", evt: MouseEvent, data: T, node: GrTreeNode<T>): void | The context menu of a row. The event comes as the first argument: `preventDefault()` and a menu of your own. |
update:currentKey | (e: "update:currentKey", key: GrTreeKey | undefined): void | The current node has changed by a click or `Enter` (`v-model:current-key`). |
filter |
| 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 | (e: "update:checkedKeys", keys: GrTreeKey[]): void | The checked keys (`v-model:checked-keys`) after a check by the user or by a method; parents checked in a cascade are included too. |
check |
| The check of a node has changed — `Space`, a click or `setChecked()`; with the resulting sets of keys. |
Types
Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.
import type { GrTreeKey, GrTreeNodeDropType, GrTreeAllowDropType } from '@feugene/granularity/components/GrTree'Filedist/types/components/GrTree/grTreeTypes.d.ts
type GrTreeKey = string | number;type GrTreeNodeDropType = 'prev' | 'inner' | 'next';type GrTreeAllowDropType = GrTreeNodeDropType;type GrTreeNode<T = any> = {
key: GrTreeKey;
label: string;
data: T;
level: number;
parent?: GrTreeNode<T>;
childNodes: GrTreeNode<T>[];
};type GrTreeNodeTarget<T = any> = GrTreeKey | GrTreeNode<T> | T;type GrTreeInstance<T = any> = {
filter: (value: string) => void;Filedist/types/components/GrTree/grTreeProps.d.ts
type GrTreePropsMap = {
children?: string;
label?: string;
isLeaf?: string;
};type GrTreeFilterNodeMethod<T extends object = any> = (value: string, data: T, node?: GrTreeNode<T>) => boolean;
export type GrTreeBranchLine = 'line' | 'elbow';
export type GrTreeBranchLineColor<T extends object = any> = string | ((node: GrTreeNode<T>) => string | undefined | null);
export type GrTreeVisibleRow<T extends object> = {
node: GrTreeNode<T>;
isExpanded: boolean;
isLeaf: boolean;
isMatched: boolean;
posInSet: number;
setSize: number;
isLoading: boolean;
branchColors: string[];
};
export type GrTreeClassValue = HTMLAttributes['class'];
export type GrTreeNodeClass<T extends object> = GrTreeClassValue | ((row: GrTreeVisibleRow<T>) => GrTreeClassValue);
export type GrTreeDataProps<T extends object> = {
data: T[];
props?: GrTreePropsMap;
nodeKey?: NodeKeyProp<T> | 'id';
defaultExpandedKeys?: GrTreeKey[];
defaultExpandAll?: boolean;
filterNodeMethod?: GrTreeFilterNodeMethod<T>;
filterValue?: string;
lazy?: boolean;
load?: GrTreeLoad<T>;
};
export type GrTreeLoad<T extends object = any> = (node: GrTreeNode<T>, resolve: (children: T[]) => void) => void;
export type GrTreeViewProps<T extends object> = {
size?: GrTreeSize;
indent?: number;
virtual?: boolean;
maxHeight?: number | string;
highlightCurrent?: boolean;
expandIcon?: string | Component;
collapseIcon?: string | Component;
toggleIconRotate?: boolean;
branchLine?: boolean | GrTreeBranchLine;
branchLineColor?: GrTreeBranchLineColor<T>;
branchLineActiveColor?: GrTreeBranchLineColor<T>;
rowClass?: GrTreeNodeClass<T>;
dragHandleClass?: GrTreeNodeClass<T>;
toggleClass?: GrTreeNodeClass<T>;
toggleIconClass?: GrTreeNodeClass<T>;
toggleSpacerClass?: GrTreeNodeClass<T>;
contentClass?: GrTreeNodeClass<T>;
dragLabel?: string;
expandLabel?: string;
collapseLabel?: string;
};
export type GrTreeInteractionProps<T extends object> = {
expandOnClickNode?: boolean;
accordion?: boolean;
draggable?: boolean;
dragHandleIcon?: string | Component;
allowDrop?: (draggingNode: GrTreeNode<T>, dropNode: GrTreeNode<T>, type: GrTreeAllowDropType) => boolean;
allowDrag?: (draggingNode: GrTreeNode<T>) => boolean;
dragHandleVisibility?: 'hover' | 'always' | 'auto';
};
export type GrTreeSelectionProps = {
showCheckbox?: boolean;
currentKey?: GrTreeKey | null;
checkedKeys?: GrTreeKey[];
defaultCheckedKeys?: GrTreeKey[];
checkStrictly?: boolean;
};
export type GrTreeProps<T extends object> = GrTreeDataProps<T> & GrTreeViewProps<T> & GrTreeSelectionProps & GrTreeInteractionProps<T>;type GrTreeBranchLineColor<T extends object = any> = string | ((node: GrTreeNode<T>) => string | undefined | null);
export type GrTreeVisibleRow<T extends object> = {type GrTreeVisibleRow<T extends object> = {
node: GrTreeNode<T>;
isExpanded: boolean;
isLeaf: boolean;
isMatched: boolean;
posInSet: number;
setSize: number;
isLoading: boolean;
branchColors: string[];
};type GrTreeNodeClass<T extends object> = GrTreeClassValue | ((row: GrTreeVisibleRow<T>) => GrTreeClassValue);
export type GrTreeDataProps<T extends object> = {type GrTreeLoad<T extends object = any> = (node: GrTreeNode<T>, resolve: (children: T[]) => void) => void;
export type GrTreeViewProps<T extends object> = {
size?: GrTreeSize;
indent?: number;
virtual?: boolean;
maxHeight?: number | string;
highlightCurrent?: boolean;
/**
* Иконка свёрнутого узла: Vue-компонент либо класс иконки (`'i-lucide-plus'` —type GrTreeSelectionProps = {
showCheckbox?: boolean;
currentKey?: GrTreeKey | null;
checkedKeys?: GrTreeKey[];
defaultCheckedKeys?: GrTreeKey[];
checkStrictly?: boolean;
};type GrTreeProps<T extends object> = GrTreeDataProps<T> & GrTreeViewProps<T> & GrTreeSelectionProps & GrTreeInteractionProps<T>;Filedist/types/composables/internal/treeChecking.d.ts
type GrTreeCheckState = typeof GR_TREE_CHECK_STATES[number];Filedist/types/components/GrTree/config.d.ts
const grTreeConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrTree">;Filedist/types/components/GrTree/defaults.d.ts
interface GrTreeConfigurableProps {
size: GrComponentSize;
}Filedist/types/components/GrTree/grTreeStyles.d.ts
type GrTreeSize = GrComponentSize;type GrTreeView = 'explorer' | 'rail' | 'outline' | 'picker';const grTreeViewVars: Record<GrTreeView, Record<string, string>>;Filedist/types/components/GrTree/safelist.d.ts
const grTreeSafelist: readonly [];Filedist/types/components/GrTree/GrTree.vue.d.ts
interface GrTreeEmits<T extends Record<string, any> = any> {
(e: 'nodeClick', data: T, node: GrTreeNode<T>): void;
(e: 'nodeExpand', data: T, node: GrTreeNode<T>): void;
(e: 'nodeCollapse', data: T, node: GrTreeNode<T>): void;
(e: 'nodeDrop', draggingNode: GrTreeNode<T>, dropNode: GrTreeNode<T>, dropType: GrTreeNodeDropType): void;
(e: 'nodeContextMenu', evt: MouseEvent, data: T, node: GrTreeNode<T>): void;
(e: 'update:currentKey', key: GrTreeKey | undefined): void;
(e: 'filter', info: {
value: string;
visibleCount: number;
matchedCount: number;
}): void;
(e: 'update:checkedKeys', keys: GrTreeKey[]): void;
(e: 'check', data: T, node: GrTreeNode<T>, info: {
checkedKeys: GrTreeKey[];
halfCheckedKeys: GrTreeKey[];
}): void;
}Accessibility
APG pattern
tree (roving tabindex)
Keys
↓ / ↑→←Home / End
Full contract of the package: docs/keyboard.md ↗
Usage from the library prose
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.
Ready-made views
A view is a set of values of the same --gr-tree-* that size sets, rather than a prop: a
second channel would argue with the size over the same variables. The sets are exported and spread
into style, so they compose with size and are overridden one line at a time.
<script setup lang="ts">
import { grTreeViewVars } from '@feugene/granularity'
import { GrTree } from '@feugene/granularity/components/GrTree'
</script>
<template>
<GrTree :data="nodes" node-key="id" branch-line="elbow" :style="grTreeViewVars.explorer" />
</template>| View | What for | What it changes |
|---|---|---|
explorer | files, namespaces, thousands of nodes | a 24px row and a type size to match, hairline neutral guides, elbows on the links, the label of a branch without bolding |
rail | navigation by sections, a side panel | a 30px row, the selection is a solid plate, rounded on the right only, with recoloured text |
outline | a table of contents beside text | neither surfaces nor guides: the selection is marked by a bar at the edge of the list and by colour, the weight is given to the level |
picker | picking nodes with ticks | a 38px row and an enlarged square: the target of the press is the square itself, not the label |
picker does not switch the ticks on — that is an orthogonal axis (showCheckbox), and it works
in any view. The set only sets the density for it.
The font family is not part of the set, but the type size is: the tree has no token of its
own for the family, while the height of a row is unreachable without the type size — it is held by
the text, not by min-height. The monospaced set of the explorer is mixed in alongside:
:style="{ ...grTreeViewVars.explorer, fontFamily: 'var(--gr-font-mono)' }".
The icons and the tail of a row are not a view either, but a slot. The explorer needs the
folder and file icon not for beauty: the elbow of the link runs up against the start of the
content, and without an icon the line breaks off in emptiness before the label. The content of a
row is stretched over the whole free width, so a counter or a label is pressed to the right with an
ordinary margin-left: auto.
The set is put on the tree itself, not on a wrapper. The tree declares the default values on
its own root, so a variable set by an ancestor does not reach the row — it is overridden by the
tree’s own declaration. GrTreeSections does the same with its view of the rows: it gives it to
the inner tree, not to the section.
rowClass overrides the type size and colour of the tree: both values are declared on the root
and are inherited by the row rather than set on it. There is no other way to make steps by level —
only the markup knows the level of a row.
If no view is enough — take a set as the base and override the variables one at a time; the list
is in ../tokens.md.
Level guides
branchLine is 'line' by default: without guides the tree reads as an indented list, and the
level is visible only by the shift of the label.
'elbow' adds a horizontal elbow to the row and cuts the vertical off at the middle of the last
child. The difference is not cosmetic: the line starts connecting the nodes rather than only
marking the level, and the cut-off shows which branches still continue. Only the guide of the
row’s own level gets the elbow — on the transit ancestors the line would point at a row they do
not belong to.
false is an explicit refusal.
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.
Height and virtualisation
maxHeight always bounds the tree: the root gets a max-height and its own scroll, and a long
tree does not grow to the full height of the page.
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 (i-lucide-* — the application sets up the rule for it: in the engine
factory or through a provider, otherwise a Vue component; 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.
A collapsed branch under the node expands by itself — if you aim inside it (the middle third of the row) and linger there. Otherwise putting a node inside a collapsed branch at the right place was not possible at all: the drag would have to be dropped, the branch expanded by hand and started over.
Only on “inside”: aiming at the neighbour above or below, the user did not ask to change the layout, and expanding would take the rows out from under the cursor together with the point they were aiming at.
With virtual the target is a rendered row, and that is a property of the pointer rather than an
omission: there is nothing to hover over what is not on the screen anyway. At the edge of the list
auto-scrolling works, and the hit is recomputed on each of its frames — a row that has reached the
window becomes the target at once. The keyboard drag does not depend on that at all: it walks the
model.
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.
Props in this example