GrTree

Package: @feugene/granularitycoreGroup: data

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 demandlazy with load instead of dumping the whole tree at once;
  • the tree is filteredfilterNodeMethod keeps the matched nodes together with their branches;
  • the nodes are moveddraggable works with the mouse, with a finger and from the keyboard.

When to take something else

NeedTake
The tree is needed in a selection panel rather than on the screenGrTreeSelect
There is one levelGrList
The data is tabularGrDataTable
The order in a flat listGrSortableList
Sections of which one is openGrCollapse

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 indent prop (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

PropWhat it does
defaultExpandedKeysthe starting set of expanded nodes
defaultExpandAllexpands a node at the moment it appears in the data
expandOnClickNodea click on a row expands a node rather than only selecting it
accordionat 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 context menu

nodeContextMenu gives the original event away as the first argument — calling preventDefault() and showing a menu of your own is decided by the consumer.

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…

Code
<GrTree />

Install

npm i @feugene/granularity

Import

import { GrTree } from '@feugene/granularity/components/GrTree'

API

Props

PropTypedefaultDescription
datarequiredT[]
propsGrTreePropsMap | undefined{ children: "children", label: "label", }
nodeKey"id" | Extract<keyof T, string> | undefined"id" as any
defaultExpandedKeysGrTreeKey[] | undefined[]
defaultExpandAllboolean | undefinedfalseExpand 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`.
filterNodeMethodGrTreeFilterNodeMethod<T> | undefined
filterValuestring | undefinedundefinedThe 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.
lazyboolean | undefinedfalseThe 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.
loadGrTreeLoad<T> | undefinedundefinedThe loader of a branch. `resolve` appends the children to the data of the node.
size"xs" | "sm" | "md" | "lg" | undefinedundefinedThe size of a row: the height, the paddings, the icons and the type size of the label.
indentnumber | undefined0The step of the indent of a level in pixels. `0` means the value from the theme (`--gr-tree-indent-step`).
virtualboolean | undefinedfalseVirtualisation: 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.
maxHeightstring | number | undefinedundefinedThe maximum height of the tree with a scroller of its own. A number means pixels.
highlightCurrentboolean | undefinedtrue
expandIconstring | Component | undefinedundefinedThe 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.
collapseIconstring | Component | undefinedundefinedThe icon of an expanded node. Unset — the same built-in arrow, rotated.
toggleIconRotateboolean | undefinedtrue
branchLineboolean | undefinedfalse
branchLineColorGrTreeBranchLineColor<T> | undefinedundefined
branchLineActiveColorGrTreeBranchLineColor<T> | undefinedundefined
rowClassGrTreeNodeClass<T>undefined
dragHandleClassGrTreeNodeClass<T>undefined
toggleClassGrTreeNodeClass<T>undefined
toggleIconClassGrTreeNodeClass<T>undefined
toggleSpacerClassGrTreeNodeClass<T>undefined
contentClassGrTreeNodeClass<T>undefined
dragLabelstring | undefinedundefinedThe i18n label of the "Drag" button (default: 'Drag').
expandLabelstring | undefinedundefinedThe i18n label of the "Expand" button (default: 'Expand').
collapseLabelstring | undefinedundefinedThe i18n label of the "Collapse" button (default: 'Collapse').
showCheckboxboolean | undefinedfalseCheckboxes at the nodes: a multiple choice on top of the tree.
currentKeyGrTreeKey | null | undefinedundefinedThe 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.
checkedKeysGrTreeKey[] | undefinedundefinedThe checked keys (`v-model:checked-keys`).
defaultCheckedKeysGrTreeKey[] | undefined[]
checkStrictlyboolean | undefinedfalseDo not link the parents and the children: every node is checked on its own.
expandOnClickNodeboolean | undefinedfalseA click on a row expands or collapses the node rather than merely selecting it.
accordionboolean | undefinedfalseAt every level at most one node is expanded.
draggableboolean | undefinedfalse
dragHandleIconstring | Component | undefinedundefinedThe 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

SlotTypeDescription
default{ node: GrTreeNode<T>; data: T; }

Events

EventTypeDescription
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: под курсором на устройстве с наведением и всегда там, где наведения не бывает. На тач-экране ручка «по наведению» недостижима, то есть перетаскивания там нет вовсе.

Controlled
<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

Operations
Escalations
Runbooks
Billing
Invoices
Disputes
Expanded: 1 Expanded: 2

Expanded State
<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

Matches: 3

Filtering
<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

Paymentscritical
Retrieswarning
Settlementhealthy
Identitywarning
Sessionshealthy
Recoverywarning
Drag a row handle to reorder or nest nodes

Drag And 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

size="xs"
src
components
composables
size="sm"
src
components
composables
size="md"
src
components
composables
size="lg"
src
components
composables

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

Billing
View invoices
Issue invoices
Refund payments
Team
View members
Invite members
Отмечено: 1billing.read

Checkboxes
<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

README.md

Lazy
<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

Billing
Invoices
Payouts
Catalog
Categories
Currencies
Delivery
Couriers
Warehouses
Выбрано:
  • Наберите «cur» — фокус уедет на Currencies.
  • Повторное нажатие одной буквы идёт по кругу.
  • * раскрывает все узлы уровня разом.

Keyboard
<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

Region 1
Site 1.1
Line 1.1.1
Line 1.1.2
Line 1.1.3
Line 1.1.4
Line 1.1.5
Line 1.1.6
Line 1.1.7
Line 1.1.8
Line 1.1.9
Line 1.1.10

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)

Full keyboard contract of the package

Component documentationAll components