Skip to content

Tree

Experimental

A tree view displays hierarchical list of items.

vue
<script setup lang="ts">
import { type Ref, ref } from 'vue'
import { HTree } from '@holistics/design-system'
import type {
  TreeNodeBase,
  TreeContextMenuOptions,
  TreeNodeKey,
  DropdownOption,
} from '@holistics/design-system'
import { NODES } from './nodes'

const nodes = ref(NODES()) as Ref<TreeNodeBase[]>
const expandedKeys = ref<TreeNodeKey[]>([])

const contextMenuOptions: TreeContextMenuOptions<TreeNodeBase> = (node) => {
  const expandedIndex = expandedKeys.value.indexOf(node.key)

  if (node.children)
    return [
      expandedIndex === -1 && {
        key: 'Expand',
        label: 'Expand',
        icons: 'expand',
        action: () => { expandedKeys.value.push(node.key); node.icon = 'folder-open' },
      },
      expandedIndex !== -1 && {
        key: 'Collapse',
        label: 'Collapse',
        icons: 'collapse',
        action: () => { expandedKeys.value.splice(expandedIndex, 1); node.icon = 'folder-solid' },
      },

      {
        key: 'Edit', label: 'Edit', icons: 'edit', action: () => { alert(`Edit ${node.label}`) },
      },
      {
        key: 'Remove', label: 'Remove', icons: 'delete', class: 'text-red-500', action: () => { alert(`ARE YOU SURE? DELETING ${node.label}...!`) },
      },
    ].filter(Boolean) as DropdownOption[]

  return [
    {
      key: 'Edit', label: 'Edit', icons: 'edit', action: () => { alert(`Edit ${node.label}`) },
    },
    {
      key: 'Remove', label: 'Remove', icons: 'delete', class: 'text-red-500', action: () => { alert(`ARE YOU SURE? DELETING ${node.label}...!`) },
    },
  ]
}
</script>

<template>
  <HTree
    v-model:nodes="nodes"
    v-model:expanded-keys="expandedKeys"
    click-mode="expand"
    multiple
    draggable
    :context-menu-options
    class="w-full rounded border sm:w-80"
    @expanded-keys-change="(node, action) => {
      if (action === 'expand') {
        node.icon = 'folder-open'
      } else {
        node.icon = 'folder-solid'
      }
    }"
  />
</template>
ts
import type { TreeNodeBase } from '@holistics/design-system'

export function NODES (): TreeNodeBase[] {
  return [
    {
      key: 'First Disabled', label: 'First Disabled', icon: 'formula', disabled: true,
    },
    {
      key: 'New Folder',
      label: 'New Folder',
      icon: 'folder-solid',
      selectable: false,
      children: [
        {
          key: 'Empty',
          label: 'Empty',
          icon: 'folder-solid',
          children: [],
          disabled: true,
        },
        {
          key: 'Files',
          label: 'Files',
          icon: 'folder-solid',
          children: [
            { key: 'Tree.svg', label: 'Tree.svg', icon: 'file/image' },
            { key: 'Schedules.xlsx', label: 'Schedules.xlsx', icon: 'file/excel' },
            { key: 'Report.pdf', label: 'Report.pdf', icon: 'file/pdf' },
            {
              key: '[Inner] So long....',
              // eslint-disable-next-line max-len
              label: '[Inner] Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
              icon: 'folder-solid',
              children: [
                {
                  key: 'DISABLED', label: 'DISABLED', icon: 'cancel', disabled: true,
                },
                { key: 'Things', label: 'Things', icon: 'archived' },
                {
                  key: 'Check Disabled', label: 'Check Disabled', icon: 'cancel', checkDisabled: true,
                },
              ],
            },
          ],
        },
        { key: 'Markdown.md', label: 'Markdown.md', icon: 'markdown' },
      ],
    },
    {
      key: 'Dashboards',
      label: 'Dashboards',
      icon: 'folder-solid',
      children: [
        { key: 'ECommerce', label: 'ECommerce', icon: 'canvas' },
        { key: 'Charts', label: 'Charts', icon: 'canvas' },
        { key: 'Monitoring', label: 'Monitoring', icon: 'canvas' },
      ],
    },
    {
      key: 'DataSets',
      label: 'DataSets',
      icon: 'folder-solid',
      children: [
        { key: 'Calendar', label: 'Calendar', icon: 'data-set' },
        { key: 'Performance', label: 'Performance', icon: 'data-set' },
        { key: 'Metrics', label: 'Metrics', icon: 'data-set' },
      ],
    },
    { key: 'README.md', label: 'README.md', icon: 'markdown' },
    {
      key: 'Drag & Drop',
      label: 'Drag & Drop',
      icon: 'folder-solid',
      children: [
        {
          key: 'Inner',
          label: 'Inner',
          icon: 'folder-solid',
          children: [
            { key: '1. One', label: '1. One', icon: 'user' },
            { key: '2. Two', label: '2. Two', icon: 'group' },
            { key: '3. Three', label: '3. Three', icon: 'globe' },
            {
              key: '4. Four', label: '4. Four', icon: 'bell', disabled: true,
            },
            { key: '5. Five', label: '5. Five', icon: 'file/csv' },
          ],
        },
      ],
    },
    {
      key: 'So long....',
      // eslint-disable-next-line max-len
      label: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
      icon: 'database',
    },
    {
      key: 'Last Disabled', label: 'Last Disabled', icon: 'formula', disabled: true,
    },
  ]
}

Examples

🖱️ Click mode

By default, clicking a node only selects it (i.e. highlights it visually). To expand or check, you must click on the toggle icon or checkbox, respectively.

You can change this behavior via the clickMode prop. Currently, it's accepting 3 values:

  • undefined: default, only selects
  • expand: selects first, then toggles expand
  • check: selects first, then toggles check

Keyboard Interactions: the mode also applies to the Enter key!

vue
<script setup lang="ts">
import { computed, type Ref, ref } from 'vue'
import {
  HTree, type TreeClickMode,
  type TreeNodeBase,
  type TreeContextMenuOptions,
  type TreeNodeKey,
  type DropdownOption,
} from '@holistics/design-system'
import { NODES } from './nodes'

const nodes = ref(NODES()) as Ref<TreeNodeBase[]>
const expandedKeys = ref<TreeNodeKey[]>([])
const checkedKeys = ref<TreeNodeKey[]>([])

const clickMode = ref<TreeClickMode>('expand')
const checkable = computed(() => clickMode.value === 'check')

const contextMenuOptions: TreeContextMenuOptions<TreeNodeBase> = (node) => {
  const expandedIndex = expandedKeys.value.indexOf(node.key)

  const options: DropdownOption[] = []
  if (node.children)
    options.push(...[
      expandedIndex === -1 && {
        key: 'Expand',
        label: 'Expand',
        icons: 'expand',
        action: () => { expandedKeys.value.push(node.key); node.icon = 'folder-open' },
      },
      expandedIndex !== -1 && {
        key: 'Collapse',
        label: 'Collapse',
        icons: 'collapse',
        action: () => { expandedKeys.value.splice(expandedIndex, 1); node.icon = 'folder-solid' },
      },

      {
        key: 'Edit', label: 'Edit', icons: 'edit', action: () => { alert(`Edit ${node.label}`) },
      },
      {
        key: 'Remove', label: 'Remove', icons: 'delete', class: 'text-red-500', action: () => { alert(`ARE YOU SURE? DELETING ${node.label}...!`) },
      },
    ].filter(Boolean) as DropdownOption[])
  else
    options.push(
      {
        key: 'Edit', label: 'Edit', icons: 'edit', action: () => { alert(`Edit ${node.label}`) },
      },
      {
        key: 'Remove', label: 'Remove', icons: 'delete', class: 'text-red-500', action: () => { alert(`ARE YOU SURE? DELETING ${node.label}...!`) },
      },
    )

  options.push({ type: 'divider' })

  if (clickMode.value === 'check')
    options.push({
      key: 'Close Selection Mode',
      label: 'Close Selection Mode',
      icons: 'cancel',
      class: 'text-orange-500',
      action: () => { clickMode.value = 'expand' },
    })
  else
    options.push({
      key: 'Selection Mode',
      label: 'Selection Mode',
      icons: 'tasks',
      action: () => { clickMode.value = 'check' },
    })

  return options
}
</script>

<template>
  <HTree
    v-model:nodes="nodes"
    v-model:expanded-keys="expandedKeys"
    v-model:checked-keys="checkedKeys"
    show-line
    :click-mode
    :checkable
    check-cascade
    :context-menu-options
    class="w-full rounded border sm:w-80"
    @expanded-keys-change="(node, action) => {
      if (action === 'expand') {
        node.icon = 'folder-open'
      } else {
        node.icon = 'folder-solid'
      }
    }"
  />
</template>
ts
import type { TreeNodeBase } from '@holistics/design-system'

export function NODES (): TreeNodeBase[] {
  return [
    {
      key: 'First Disabled', label: 'First Disabled', icon: 'formula', disabled: true,
    },
    {
      key: 'New Folder',
      label: 'New Folder',
      icon: 'folder-solid',
      selectable: false,
      children: [
        {
          key: 'Empty',
          label: 'Empty',
          icon: 'folder-solid',
          children: [],
          disabled: true,
        },
        {
          key: 'Files',
          label: 'Files',
          icon: 'folder-solid',
          children: [
            { key: 'Tree.svg', label: 'Tree.svg', icon: 'file/image' },
            { key: 'Schedules.xlsx', label: 'Schedules.xlsx', icon: 'file/excel' },
            { key: 'Report.pdf', label: 'Report.pdf', icon: 'file/pdf' },
            {
              key: '[Inner] So long....',
              // eslint-disable-next-line max-len
              label: '[Inner] Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
              icon: 'folder-solid',
              children: [
                {
                  key: 'DISABLED', label: 'DISABLED', icon: 'cancel', disabled: true,
                },
                { key: 'Things', label: 'Things', icon: 'archived' },
                {
                  key: 'Check Disabled', label: 'Check Disabled', icon: 'cancel', checkDisabled: true,
                },
              ],
            },
          ],
        },
        { key: 'Markdown.md', label: 'Markdown.md', icon: 'markdown' },
      ],
    },
    {
      key: 'Dashboards',
      label: 'Dashboards',
      icon: 'folder-solid',
      children: [
        { key: 'ECommerce', label: 'ECommerce', icon: 'canvas' },
        { key: 'Charts', label: 'Charts', icon: 'canvas' },
        { key: 'Monitoring', label: 'Monitoring', icon: 'canvas' },
      ],
    },
    {
      key: 'DataSets',
      label: 'DataSets',
      icon: 'folder-solid',
      children: [
        { key: 'Calendar', label: 'Calendar', icon: 'data-set' },
        { key: 'Performance', label: 'Performance', icon: 'data-set' },
        { key: 'Metrics', label: 'Metrics', icon: 'data-set' },
      ],
    },
    { key: 'README.md', label: 'README.md', icon: 'markdown' },
    {
      key: 'Drag & Drop',
      label: 'Drag & Drop',
      icon: 'folder-solid',
      children: [
        {
          key: 'Inner',
          label: 'Inner',
          icon: 'folder-solid',
          children: [
            { key: '1. One', label: '1. One', icon: 'user' },
            { key: '2. Two', label: '2. Two', icon: 'group' },
            { key: '3. Three', label: '3. Three', icon: 'globe' },
            {
              key: '4. Four', label: '4. Four', icon: 'bell', disabled: true,
            },
            { key: '5. Five', label: '5. Five', icon: 'file/csv' },
          ],
        },
      ],
    },
    {
      key: 'So long....',
      // eslint-disable-next-line max-len
      label: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
      icon: 'database',
    },
    {
      key: 'Last Disabled', label: 'Last Disabled', icon: 'formula', disabled: true,
    },
  ]
}

✅ Check

Enable checkboxes in your tree for use cases like file selection, permission management, or multi-item actions by setting checkable: true. The checked states are managed through 2 v-model: checkedKeys and indeterminateKeys.

Cascading behavior

To enable hierarchical cascading, set checkCascade: true. With this enabled:

  • A parent node is checked only when all of its children are checked
  • A parent node is indeterminate when only some of its children are checked
  • A parent node is unchecked when none of its children are checked

Check strategies

When cascading is enabled, Tree provides 3 check strategies that control which nodes appear in checkedKeys:

  • all: Collects all checked nodes.
  • parent: Collects only the highest-level checked nodes.
  • child: Collects only the lowest-level checked nodes (leaf nodes).
Example

Given a tree where Folder A contains File 1 (checked) and File 2 (checked):

☑ Folder A          <- parent is auto-checked because all children are checked
   ☑ File 1         <- child
   ☑ File 2         <- child

The checkedKeys will be:

  • all: ['Folder A', 'File 1', 'File 2']
  • parent: ['Folder A']
  • child: ['File 1', 'File 2']

Advanced: moving nodes within a checkable Tree

Tree automatically sync the checked states for internal moving operations (e.g. Drag & Drop). For external moving operations (e.g. programmatic moves), use the exposed function syncCheckedStatesAfterMoved() to sync correctly.

Sync mechanism

Let's say a node N is moved from a source parent to a destination parent. The children of them (excluding N) are called Src Children and Dest Children respectively.

  • ✅: all nodes are checked
  • -: some nodes are unchecked
StrategyBeforeAfter
NSrc ChildrenDest ChildrenNSrc ChildrenDest Children
'all'23 = 8 statesPreserved
'child'23 = 8 statesPreserved
'parent'--Preserved
-✅ / -Preserved
5 other statesPreserved
vue
<script setup lang="ts">
import { type Ref, ref, useTemplateRef } from 'vue'
import type { CheckStrategy } from 'treemate'
import {
  type TreeNodeBase,
  type TreeNodeKey,
  HTree,
  HRadio,
  HRadioGroup,
  HButton,
  HCheckbox,
} from '@holistics/design-system'
import { NODES } from './nodes'

const tree = useTemplateRef('treeRef')

const nodes = ref(NODES()) as Ref<TreeNodeBase[]>

const defaultCheckedKeys = ['Check Disabled']
const checkedKeys = ref<TreeNodeKey[]>([])
const indeterminateKeys = ref<TreeNodeKey[]>([])
const checkCascade = ref(true)
const CHECK_STRATEGIES = ['all', 'child', 'parent'] as const satisfies CheckStrategy[]
const checkStrategy = ref<CheckStrategy>('all')

function manualCheck () {
  tree.value?.setCheckedStates(['Tree.svg', 'Schedules.xlsx', 'Things', 'Dashboards', 'Drag & Drop'])
}
function clear () {
  tree.value?.setCheckedStates(defaultCheckedKeys)
}
</script>

<template>
  <div class="space-y-4 text-xs">
    <div class="flex flex-wrap gap-x-8 gap-y-4">
      <HCheckbox
        v-model="checkCascade"
        label="Cascade"
      />

      <HRadioGroup
        class="flex gap-1"
        v-model="checkStrategy"
      >
        <div>
          Strategy
        </div>

        <HRadio
          v-for="strategy in CHECK_STRATEGIES"
          :key="strategy"
          :value="strategy"
        >
          {{ strategy }}
        </HRadio>
      </HRadioGroup>
    </div>

    <div class="flex gap-1">
      <HButton
        type="primary-highlight"
        label="Manual check"
        @click="manualCheck"
      />
      <HButton
        type="primary-danger"
        label="Clear"
        @click="clear"
      />
    </div>

    <HTree
      ref="treeRef"
      v-model:nodes="nodes"
      v-model:checked-keys="checkedKeys"
      v-model:indeterminate-keys="indeterminateKeys"
      show-line
      click-mode="check"
      default-expand-all
      :default-checked-keys
      checkable
      :check-cascade
      :check-strategy
      draggable
      class="max-h-[50vh] flex-shrink-0 overflow-auto rounded-sm border p-2"
      @expanded-keys-change="(node, action) => {
        if (action === 'expand') {
          node.icon = 'folder'
        } else {
          node.icon = 'folder-solid'
        }
      }"
    />

    <div class="flex gap-1">
      <div class="flex-shrink-0">
        Checked Keys
      </div>
      <code class="text-blue-700">{{ checkedKeys }}</code>
    </div>

    <div class="flex gap-1">
      <div class="flex-shrink-0">
        Indeterminate Keys
      </div>
      <code class="text-blue-700">{{ indeterminateKeys }}</code>
    </div>
  </div>
</template>
ts
import type { TreeNodeBase } from '@holistics/design-system'

export function NODES (): TreeNodeBase[] {
  return [
    {
      key: 'First Disabled', label: 'First Disabled', icon: 'formula', disabled: true,
    },
    {
      key: 'New Folder',
      label: 'New Folder',
      icon: 'folder-solid',
      selectable: false,
      children: [
        {
          key: 'Empty',
          label: 'Empty',
          icon: 'folder-solid',
          children: [],
          disabled: true,
        },
        {
          key: 'Files',
          label: 'Files',
          icon: 'folder-solid',
          children: [
            { key: 'Tree.svg', label: 'Tree.svg', icon: 'file/image' },
            { key: 'Schedules.xlsx', label: 'Schedules.xlsx', icon: 'file/excel' },
            { key: 'Report.pdf', label: 'Report.pdf', icon: 'file/pdf' },
            {
              key: '[Inner] So long....',
              // eslint-disable-next-line max-len
              label: '[Inner] Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
              icon: 'folder-solid',
              children: [
                {
                  key: 'DISABLED', label: 'DISABLED', icon: 'cancel', disabled: true,
                },
                { key: 'Things', label: 'Things', icon: 'archived' },
                {
                  key: 'Check Disabled', label: 'Check Disabled', icon: 'cancel', checkDisabled: true,
                },
              ],
            },
          ],
        },
        { key: 'Markdown.md', label: 'Markdown.md', icon: 'markdown' },
      ],
    },
    {
      key: 'Dashboards',
      label: 'Dashboards',
      icon: 'folder-solid',
      children: [
        { key: 'ECommerce', label: 'ECommerce', icon: 'canvas' },
        { key: 'Charts', label: 'Charts', icon: 'canvas' },
        { key: 'Monitoring', label: 'Monitoring', icon: 'canvas' },
      ],
    },
    {
      key: 'DataSets',
      label: 'DataSets',
      icon: 'folder-solid',
      children: [
        { key: 'Calendar', label: 'Calendar', icon: 'data-set' },
        { key: 'Performance', label: 'Performance', icon: 'data-set' },
        { key: 'Metrics', label: 'Metrics', icon: 'data-set' },
      ],
    },
    { key: 'README.md', label: 'README.md', icon: 'markdown' },
    {
      key: 'Drag & Drop',
      label: 'Drag & Drop',
      icon: 'folder-solid',
      children: [
        {
          key: 'Inner',
          label: 'Inner',
          icon: 'folder-solid',
          children: [
            { key: '1. One', label: '1. One', icon: 'user' },
            { key: '2. Two', label: '2. Two', icon: 'group' },
            { key: '3. Three', label: '3. Three', icon: 'globe' },
            {
              key: '4. Four', label: '4. Four', icon: 'bell', disabled: true,
            },
            { key: '5. Five', label: '5. Five', icon: 'file/csv' },
          ],
        },
      ],
    },
    {
      key: 'So long....',
      // eslint-disable-next-line max-len
      label: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
      icon: 'database',
    },
    {
      key: 'Last Disabled', label: 'Last Disabled', icon: 'formula', disabled: true,
    },
  ]
}

⌨️ Keyboard Interactions

Tree automatically handles keys in the following table. You can bind them to your desired elements via the keyboardTargets prop so that when they are focused, the keys will be handled. Tree also detects whether the passed targets are <input> and will ignore the unsupported keys.

🚧 Careful when passing the targets

Wrap keyboardTargets in a computed to prevent unnecessary re-creation on tree re-renders (expand/collapse). Direct array literals in templates will cause focus loss.

KeyDescription<input> supported?
ArrowDownWhen focus is on a node, moves focus to the next one.
ArrowUpWhen focus is on a node, moves focus to the previous one.
PageDownWhen focus is on a node, moves focus to the next 10 nodes.
PageUpWhen focus is on a node, moves focus to the previous 10 nodes.
HomeMoves focus to the first node.
EndMoves focus to the last node.
EscapeMoves focus to the container.
EnterWhen focus is on a node, toggles select on it and (optionally) trigger clickMode.
SpaceWhen focus is on a node, toggles check on it.
ArrowRightWhen focus is on a non-leaf node: if it's collapsed, expand the node; if it's opened, moves focus to the first child.
ArrowLeftWhen focus is on a non-leaf node and it's expanded, collapse the node. Otherwise, moves focus to its parent.

Following is an example to interact with Tree while typing on the input:

vue
<script setup lang="ts">
import {
  computed, type Ref, ref, useTemplateRef,
} from 'vue'
import { HTree } from '@holistics/design-system'
import type { TreeNodeBase } from '@holistics/design-system'
import { NODES } from './nodes'

const nodes = ref(NODES()) as Ref<TreeNodeBase[]>

const input = useTemplateRef('inputRef')
const keyboardTargets = computed(() => [input.value])
const inputText = ref('')
</script>

<template>
  <div>
    <input
      ref="inputRef"
      v-model="inputText"
      class="hui-input mb-4 w-full border-gray-400 text-xs sm:w-80"
    >
  </div>

  <HTree
    v-model:nodes="nodes"
    click-mode="expand"
    :keyboard-targets
    class="max-h-[50vh] w-full rounded border sm:w-80"
    @expanded-keys-change="(node, action) => {
      if (action === 'expand') {
        node.icon = 'folder-open'
      } else {
        node.icon = 'folder-solid'
      }
    }"
  />
</template>
ts
import type { TreeNodeBase } from '@holistics/design-system'

export function NODES (): TreeNodeBase[] {
  return [
    {
      key: 'First Disabled', label: 'First Disabled', icon: 'formula', disabled: true,
    },
    {
      key: 'New Folder',
      label: 'New Folder',
      icon: 'folder-solid',
      selectable: false,
      children: [
        {
          key: 'Empty',
          label: 'Empty',
          icon: 'folder-solid',
          children: [],
          disabled: true,
        },
        {
          key: 'Files',
          label: 'Files',
          icon: 'folder-solid',
          children: [
            { key: 'Tree.svg', label: 'Tree.svg', icon: 'file/image' },
            { key: 'Schedules.xlsx', label: 'Schedules.xlsx', icon: 'file/excel' },
            { key: 'Report.pdf', label: 'Report.pdf', icon: 'file/pdf' },
            {
              key: '[Inner] So long....',
              // eslint-disable-next-line max-len
              label: '[Inner] Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
              icon: 'folder-solid',
              children: [
                {
                  key: 'DISABLED', label: 'DISABLED', icon: 'cancel', disabled: true,
                },
                { key: 'Things', label: 'Things', icon: 'archived' },
                {
                  key: 'Check Disabled', label: 'Check Disabled', icon: 'cancel', checkDisabled: true,
                },
              ],
            },
          ],
        },
        { key: 'Markdown.md', label: 'Markdown.md', icon: 'markdown' },
      ],
    },
    {
      key: 'Dashboards',
      label: 'Dashboards',
      icon: 'folder-solid',
      children: [
        { key: 'ECommerce', label: 'ECommerce', icon: 'canvas' },
        { key: 'Charts', label: 'Charts', icon: 'canvas' },
        { key: 'Monitoring', label: 'Monitoring', icon: 'canvas' },
      ],
    },
    {
      key: 'DataSets',
      label: 'DataSets',
      icon: 'folder-solid',
      children: [
        { key: 'Calendar', label: 'Calendar', icon: 'data-set' },
        { key: 'Performance', label: 'Performance', icon: 'data-set' },
        { key: 'Metrics', label: 'Metrics', icon: 'data-set' },
      ],
    },
    { key: 'README.md', label: 'README.md', icon: 'markdown' },
    {
      key: 'Drag & Drop',
      label: 'Drag & Drop',
      icon: 'folder-solid',
      children: [
        {
          key: 'Inner',
          label: 'Inner',
          icon: 'folder-solid',
          children: [
            { key: '1. One', label: '1. One', icon: 'user' },
            { key: '2. Two', label: '2. Two', icon: 'group' },
            { key: '3. Three', label: '3. Three', icon: 'globe' },
            {
              key: '4. Four', label: '4. Four', icon: 'bell', disabled: true,
            },
            { key: '5. Five', label: '5. Five', icon: 'file/csv' },
          ],
        },
      ],
    },
    {
      key: 'So long....',
      // eslint-disable-next-line max-len
      label: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
      icon: 'database',
    },
    {
      key: 'Last Disabled', label: 'Last Disabled', icon: 'formula', disabled: true,
    },
  ]
}

Tree provides powerful search capabilities that automatically expand matching nodes, scroll to the first match, and focus it for easy keyboard interactions.

Simply provide the searchText prop to enable substring matching. By default, Tree searches against each node's label.

vue
<HTree
  v-model:nodes="nodes"
  :search-text
/>
Customize matching logic

Use the searchFilter prop to control how nodes are matched. This is useful when you want to search by custom properties or implement case-sensitive matching:

vue
<HTree
  v-model:nodes="nodes"
  :search-text
  :search-filter="(node, searchText) => {
    // Search by both label and description
    return node.label.includes(searchText) || node.description?.includes(searchText)
  }"
/>

Unmatched nodes visibility

By default, unmatched nodes remain visible but with their highest-level ancestors collapsed. This allows users to quickly jump to a match and then explore nearby nodes.

To completely hide unmatched nodes, set searchHideUnmatched: true:

vue
<HTree
  v-model:nodes="nodes"
  :search-text
  search-hide-unmatched
/>
What is stemming?

Stemming reduces words to their root form, allowing fuzzy matches. For example:

  • Searching "run" matches "running", "runner", "runs"
  • Searching "develop" matches "developer", "development", "developing"

This creates a more forgiving search experience, especially useful for large trees where users might not know the exact terminology.

When stemming is enabled, the search also becomes "hierarchical":

  • The search text is split into tokens
  • A node is matched only when it matches at least 1 search token
  • Remaining tokens will match against the node ancestors to:
    • Help jump to the most relevant match (when unmatched nodes are shown)
    • OR help narrow down results by filtering based on ancestors (when unmatched nodes are hidden)
vue
<HTree
  v-model:nodes="nodes"
  :search-text
  search-stem-index
/>

Tree will automatically generate a stem index from the passed nodes.

What is a stem index?

A stem index is an inverted index that maps word stems to their matching nodes. It improves search performance for large trees by avoiding the need to tokenize and stem all nodes on every search.

The index usually stores metadata about each match (like the original word forms and their positions) so that search results can be highlighted in the UI with the exact terms users searched for, not just the stems.

Custom stem index

You can pre-compute and provide your own stem index for greater controls:

vue
<script setup lang="ts">
import { ref } from 'vue'
import { collectStemTokenIndex } from '@holistics/design-system'

const nodes = ref([/* some nodes */])
const searchText = ref('')
const searchStemIndex = collectStemTokenIndex(nodes)
</script>

<template>
  <HTree
    v-model:nodes="nodes"
    :search-text
    :search-stem-index
  />
</template>

Advanced: Custom search algorithm

For extremely advanced use cases (e.g. regex search, multi-field weighted search, or integration with external search engines), you can provide a custom search function to the search prop. Your function must return a TreeSearchResult object.

TreeSearchResult
ts
export interface TreeSearchResult<N extends TreeNode<N>> {
  /**
   * Matched nodes.
   */
  nodes: N[]
  /**
   * Matched keys.
   */
  keys: TreeNodeKey[]
  /**
   * List of expanded keys after searching.
   */
  expandedKeys: TreeNodeKey[]
  /**
   * Map of stemming-matched node key to their original tokens (usually used for highlighting).
   */
  stemmingMatchedTokensMap?: Record<TreeNodeKey, string[]>
}
vue
<script setup lang="ts">
import {
  computed,
  type Ref,
  ref,
  useTemplateRef,
} from 'vue'
import { useDebounceFn } from '@vueuse/core'
import type { CheckStrategy } from 'treemate'
import {
  HTree,
  type TreeNodeBase,
  type TreeNodeKey,
  HCheckbox,
  HRadio,
  HRadioGroup,
} from '@holistics/design-system'
import { NODES } from './nodes'

const nodes = ref(NODES()) as Ref<TreeNodeBase[]>
const expandedKeys = ref<TreeNodeKey[]>([])
const checkedKeys = ref<TreeNodeKey[]>([])
const indeterminateKeys = ref<TreeNodeKey[]>([])
const checkable = ref(false)
const checkCascade = ref(true)
const CHECK_STRATEGIES = ['all', 'child', 'parent'] as const satisfies CheckStrategy[]
const checkStrategy = ref<CheckStrategy>('all')

const input = useTemplateRef('inputRef')
const keyboardTargets = computed(() => [input.value])

const searchText = ref('')
const onInput = useDebounceFn((e: Event) => {
  searchText.value = (e.target as HTMLInputElement).value
}, 200)
const searchHideUnmatched = ref(false)
const searchStemIndex = ref(false)
</script>

<template>
  <div class="space-y-4 text-xs">
    <input
      ref="inputRef"
      :value="searchText"
      class="hui-input"
      @input="onInput"
    >

    <div class="flex flex-wrap gap-x-8 gap-y-4">
      <HCheckbox
        v-model="searchStemIndex"
        label="Stemming with Hierarchical"
      />

      <HCheckbox
        v-model="searchHideUnmatched"
        label="Hide Unmatched Nodes"
      />
    </div>

    <div class="flex flex-wrap gap-x-8 gap-y-4">
      <HCheckbox
        v-model="checkable"
        label="Checkable"
      />
      <HCheckbox
        v-model="checkCascade"
        label="Check Cascade"
        :disabled="!checkable"
      />

      <HRadioGroup
        class="flex gap-1"
        v-model="checkStrategy"
      >
        <div>
          Check Strategy
        </div>

        <HRadio
          v-for="strategy in CHECK_STRATEGIES"
          :key="strategy"
          :value="strategy"
          :disabled="!checkable"
        >
          {{ strategy }}
        </HRadio>
      </HRadioGroup>
    </div>

    <HTree
      v-model:nodes="nodes"
      v-model:expanded-keys="expandedKeys"
      v-model:indeterminate-keys="indeterminateKeys"
      v-model:checked-keys="checkedKeys"
      show-line
      click-mode="expand"
      default-expand-all
      :checkable
      :check-cascade
      :check-strategy
      :search-text
      :search-stem-index
      :search-hide-unmatched
      :keyboard-targets
      class="max-h-[50vh] overflow-auto rounded border"
    />

    <div class="flex gap-1">
      <div class="flex-shrink-0">
        Checked Keys
      </div>
      <code class="text-blue-700">{{ checkedKeys }}</code>
    </div>

    <div class="flex gap-1">
      <div class="flex-shrink-0">
        Indeterminate Keys
      </div>
      <code class="text-blue-700">{{ indeterminateKeys }}</code>
    </div>
  </div>
</template>
ts
import type { TreeNodeBase } from '@holistics/design-system'

export function NODES (): TreeNodeBase[] {
  return [
    {
      key: 'First Disabled', label: 'First Disabled', icon: 'formula', disabled: true,
    },
    {
      key: 'New Folder',
      label: 'New Folder',
      icon: 'folder-solid',
      selectable: false,
      children: [
        {
          key: 'Empty',
          label: 'Empty',
          icon: 'folder-solid',
          children: [],
          disabled: true,
        },
        {
          key: 'Files',
          label: 'Files',
          icon: 'folder-solid',
          children: [
            { key: 'Tree.svg', label: 'Tree.svg', icon: 'file/image' },
            { key: 'Schedules.xlsx', label: 'Schedules.xlsx', icon: 'file/excel' },
            { key: 'Report.pdf', label: 'Report.pdf', icon: 'file/pdf' },
            {
              key: '[Inner] So long....',
              // eslint-disable-next-line max-len
              label: '[Inner] Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
              icon: 'folder-solid',
              children: [
                {
                  key: 'DISABLED', label: 'DISABLED', icon: 'cancel', disabled: true,
                },
                { key: 'Things', label: 'Things', icon: 'archived' },
                {
                  key: 'Check Disabled', label: 'Check Disabled', icon: 'cancel', checkDisabled: true,
                },
              ],
            },
          ],
        },
        { key: 'Markdown.md', label: 'Markdown.md', icon: 'markdown' },
      ],
    },
    {
      key: 'Dashboards',
      label: 'Dashboards',
      icon: 'folder-solid',
      children: [
        { key: 'ECommerce', label: 'ECommerce', icon: 'canvas' },
        { key: 'Charts', label: 'Charts', icon: 'canvas' },
        { key: 'Monitoring', label: 'Monitoring', icon: 'canvas' },
      ],
    },
    {
      key: 'DataSets',
      label: 'DataSets',
      icon: 'folder-solid',
      children: [
        { key: 'Calendar', label: 'Calendar', icon: 'data-set' },
        { key: 'Performance', label: 'Performance', icon: 'data-set' },
        { key: 'Metrics', label: 'Metrics', icon: 'data-set' },
      ],
    },
    { key: 'README.md', label: 'README.md', icon: 'markdown' },
    {
      key: 'Drag & Drop',
      label: 'Drag & Drop',
      icon: 'folder-solid',
      children: [
        {
          key: 'Inner',
          label: 'Inner',
          icon: 'folder-solid',
          children: [
            { key: '1. One', label: '1. One', icon: 'user' },
            { key: '2. Two', label: '2. Two', icon: 'group' },
            { key: '3. Three', label: '3. Three', icon: 'globe' },
            {
              key: '4. Four', label: '4. Four', icon: 'bell', disabled: true,
            },
            { key: '5. Five', label: '5. Five', icon: 'file/csv' },
          ],
        },
      ],
    },
    {
      key: 'So long....',
      // eslint-disable-next-line max-len
      label: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
      icon: 'database',
    },
    {
      key: 'Last Disabled', label: 'Last Disabled', icon: 'formula', disabled: true,
    },
  ]
}

↕️ Drag & Drop

Enable drag & drop by setting the draggable prop:

vue
<HTree
  v-model:nodes="nodes"
  draggable
  @node-move="handleMove"
/>

Drag Image

Put the DragImageProvider component anywhere above Tree to display a better image while dragging! You can even change it to your likings, too!

vue
<script setup lang="ts">
import { Ref, ref } from 'vue'
import { HTree, type TreeNodeBase, HDragImageProvider } from '@holistics/design-system'

const nodes = ref(Array.from({ length: 100 }, (_, i) => ({
  key: `${i}. File`,
  label: `${i}. File`,
}))) as Ref<TreeNodeBase[]>
</script>

<template>
  <HDragImageProvider>
    <HTree
      v-model:nodes="nodes"
      draggable
    />
  </HDragImageProvider>

</template>

Auto Scroll

Tree supports auto scroll when dragging near the top/bottom (aka. "edges") of the container. By default, the edges' size is 3 default node size = 28 * 3 = 84px. You will want to customize this number (via the dragAutoScrollEdgeSize prop) when the node size is different than 28px.

There is also a "dampening" mechanism to mimic natural acceleration when auto-scrolling. There are 2 types of dampening combined:

  • ⌛️ Time dampening: Auto-scrolling speed increases with the dragging duration.
  • 🛣️ Distance dampening: As the user moves the mouse toward the edges, the proximity to the container rectangle increases the auto-scrolling speed. Upon reaching a specific point (known as the "buffer") the auto-scroll accelerates to its maximum speed. You can change this buffer via the dragAutoScrollDampeningBuffer prop (default is 1 default node size = 28px).
vue
<script setup lang="ts">
import { type Ref, ref } from 'vue'
import { HTree, type TreeNodeBase } from '@holistics/design-system'

const nodes = ref(Array.from({ length: 100 }, (_, i) => ({
  key: `${i}. File`,
  label: `${i}. File`,
  icon: 'canvas',
}))) as Ref<TreeNodeBase[]>
</script>

<template>
  <HTree
    v-model:nodes="nodes"
    show-line
    draggable
    class="max-h-[40vh] w-full overflow-auto rounded border sm:w-80"
  />
</template>

Interact with other components

The Drag & Drop implementation for Tree is based on HTML5 Drag & Drop API. Therefore you could easily leverage them to interact with other external components!

Following is an example of building a Text Editor with the abilities to drag a node from Tree to the editor to split them in half, or to re-order the tabs.

vue
<script setup lang="ts">
import {
  type Ref, ref, shallowRef,
} from 'vue'
import { HTree, isLeaf } from '@holistics/design-system'
import type { TreeNode, TreeNodeKey } from '@holistics/design-system'
import type {
  EditorDroppingPosition, EditorPanesGrid, TreeNodeEditor,
} from './types'
import { eventBus } from './eventBus'
import { canDropSplit } from './utils'
import Pane from './Pane.vue'
import { NODES } from '../nodes'

interface TreeNodeFileExplorer extends TreeNode<TreeNodeFileExplorer> {
  content?: string
}

const nodes = ref(NODES()) as Ref<TreeNodeFileExplorer[]>

const expandedKeys = ref<TreeNodeKey[]>([])
const selectedKeys = ref<TreeNodeKey[]>([])

/* ----------------------------------------------------------------
Right
---------------------------------------------------------------- */
const panesGrid = ref([
  [{ idx: [0, 0], tabs: [], active: undefined }, { tabs: [], active: undefined }],
  [{ tabs: [], active: undefined }, { tabs: [], active: undefined }],
]) as Ref<EditorPanesGrid>
const draggingFile = shallowRef<TreeNodeEditor>()

function resetDroppingStates () {
  eventBus.emit({ name: 'resetDroppingStates' })
}

function onEditorDrop ([idx, idxInner]: [number, number], file: TreeNodeEditor, pos: EditorDroppingPosition) {
  const currPane = panesGrid.value[idx][idxInner]
  let newIdx: [number, number] | undefined
  if (canDropSplit((panesGrid.value), [idx, idxInner], pos)) {
    if (pos === 'left') {
      panesGrid.value[idx][idxInner + 1] = currPane
      newIdx = [idx, idxInner]
    } else if (pos === 'right')
      newIdx = [idx, idxInner + 1]
    else if (pos === 'top') {
      panesGrid.value[idx + 1][idxInner] = currPane
      newIdx = [idx, idxInner]
    } else if (pos === 'bottom')
      newIdx = [idx + 1, idxInner]

    if (newIdx)
      panesGrid.value[newIdx[0]][newIdx[1]] = {
        ...panesGrid.value[newIdx[0]][newIdx[1]],
        active: file,
        idx: [newIdx[0], newIdx[1]],
      }
  }
}
</script>

<template>
  <div
    class="flex"
    style="height: 50vh"
  >
    <HTree
      v-model:nodes="nodes"
      v-model:expanded-keys="expandedKeys"
      v-model:selected-keys="selectedKeys"
      show-line
      click-mode="expand"
      draggable
      class="w-32 overflow-auto rounded-l border-y border-l sm:w-56"
      @node-drag-start="(_, node) => {
        if (isLeaf(node)) draggingFile = node
      }"
      @node-drag-end="() => {
        draggingFile = undefined
        resetDroppingStates()
      }"
      @expanded-keys-change="(node, action) => {
        if (action === 'expand') {
          node.icon = 'folder'
        } else {
          node.icon = 'folder-solid'
        }
      }"
    />

    <div
      class="grid min-w-0 flex-1 rounded-r border text-xs"
      :style="{
        gridTemplateColumns: `${panesGrid[0].filter((p) => p.idx).map(() => '1fr').join(' ')}`
      }"
    >
      <template v-for="(panesHorizontal, idxHorizontal) in panesGrid">
        <template v-for="(pane, idx) in panesHorizontal">
          <Pane
            v-if="pane.idx"
            :key="`${idxHorizontal}-${idx}`"
            :dragging-file
            :pane
            :panes-grid
            class="overflow-hidden"
            :class="[
              idx !== 0 && 'border-l'
            ]"
            @activate-file="(file) => selectedKeys = [file.key]"
            @editor-drop="(file, pos) => onEditorDrop([idxHorizontal, idx], file, pos)"
          />
        </template>
      </template>
    </div>
  </div>
</template>
vue
<script setup lang="ts">
/* eslint-disable no-use-before-define */
/* eslint-disable vue/no-mutating-props */
import {
  ref, shallowRef, useTemplateRef, watchEffect,
} from 'vue'
import { HIcon } from '@holistics/design-system'
import type{ EditorDroppingPosition, EditorPane, TreeNodeEditor } from './types'
import { eventBus } from './eventBus'
import { canDropSplit } from './utils'

const props = defineProps<{
  draggingFile: TreeNodeEditor | undefined
  pane: EditorPane
  panesGrid: [
    [EditorPane, EditorPane],
    [EditorPane, EditorPane]
  ]
}>()

const emit = defineEmits<{
  activateFile: [file: TreeNodeEditor]
  editorDrop: [file: TreeNodeEditor, position: EditorDroppingPosition]
}>()

function setActive (file: TreeNodeEditor | undefined) {
  props.pane.active = file
  if (file) emit('activateFile', file)
}

const unsubcribe = eventBus.on(({ name }) => {
  if (name === 'resetDroppingStates') {
    resetDroppingTabState()
    resetEditorDroppingState()
  }
})

watchEffect((onCleanup) => {
  onCleanup(() => unsubcribe())
})

/* ----------------------------------------------------------------
Tabs
---------------------------------------------------------------- */
const tabElements = ref<(HTMLDivElement | null)[]>([])

const droppingTab = shallowRef<TreeNodeEditor>()
const droppingTabIdx = ref<number>()
function resetDroppingTabState () {
  droppingTab.value = undefined
  droppingTabIdx.value = undefined
}

function onTabDragOver (e: DragEvent, tab: TreeNodeEditor, index: number) {
  const tabEl = tabElements.value[index]
  if (!tabEl) return

  const { left, width } = tabEl.getBoundingClientRect()
  let idx: number
  if (e.clientX - left < width / 2) idx = index
  else idx = index + 1

  droppingTab.value = tab
  droppingTabIdx.value = idx
}
function onTabDrop () {
  if (!props.draggingFile || !droppingTab.value || droppingTabIdx.value === undefined) return

  const idx = props.pane.tabs!.findIndex((t) => t.key === props.draggingFile!.key)
  if (idx !== -1) props.pane.tabs!.splice(idx, 1)

  props.pane.tabs.splice(droppingTabIdx.value, 0, props.draggingFile)

  resetDroppingTabState()
  setActive(props.draggingFile)
}

/* ----------------------------------------------------------------
Editor
---------------------------------------------------------------- */
const editorDroppingFile = shallowRef<TreeNodeEditor>()
const editorDroppingPos = ref<EditorDroppingPosition>()
function resetEditorDroppingState () {
  editorDroppingFile.value = undefined
  editorDroppingPos.value = undefined
}

const editorEl = useTemplateRef('editorRef')
function onEditorDragOver (e: DragEvent) {
  if (!editorEl.value || !props.draggingFile || !props.pane.idx) return

  const {
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    left, right, width, top, bottom, height,
  } = editorEl.value.getBoundingClientRect()
  let position: EditorDroppingPosition
  if (e.clientX - left < width / 4)
    position = 'left'
  else if (right - e.clientX < width / 4)
    position = 'right'
  // } else if (e.clientY - top < height / 4) {
  //   position = 'top'
  // } else if (bottom - e.clientY < height / 4) {
  //   position = 'bottom'
  else
    position = 'center'

  if (!canDropSplit(props.panesGrid, props.pane.idx, position))
    position = 'center'

  editorDroppingFile.value = props.draggingFile
  editorDroppingPos.value = position
}
function onEditorDrop () {
  if (!props.draggingFile || !editorDroppingFile.value || !editorDroppingPos.value) return

  if (editorDroppingPos.value === 'center') {
    setActive(props.draggingFile)
    const index = props.pane.tabs.findIndex((t) => t.key === props.pane.active?.key)
    if (index === -1)
      props.pane.tabs = [...props.pane.tabs, props.pane.active!]
  }

  emit('editorDrop', props.draggingFile, editorDroppingPos.value)
}
</script>

<template>
  <div class="flex flex-col">
    <div
      v-if="pane.tabs.length"
      class="flex overflow-auto"
    >
      <template
        v-for="(tab, idx) in pane.tabs"
        :key="tab.key"
      >
        <div
          v-if="idx !== 0"
          class="relative w-px flex-shrink-0 self-stretch bg-gray-300"
        >
          <div
            v-if="droppingTabIdx !== undefined && droppingTabIdx === idx"
            class="absolute -inset-x-px inset-y-0 bg-purple-300"
          />
        </div>

        <div
          :ref="(el) => tabElements[idx] = el as HTMLDivElement"
          class="group relative flex min-w-32 max-w-32 cursor-pointer items-center overflow-hidden border-b py-1 pl-4 pr-5"
          :class="[
            pane.active?.key === tab.key
              ? 'border-b-transparent bg-blue-50'
              : 'bg-gray-50 text-gray-700 hover:bg-gray-200 hover:text-gray-900'
          ]"
          @dragenter.prevent
          @dragover.prevent="onTabDragOver($event, tab, idx)"
          @drop.prevent="onTabDrop()"
          @click="setActive(tab)"
        >
          <HIcon
            v-if="tab.icon"
            :name="tab.icon"
            class="mr-1"
          />
          <div class="truncate">
            {{ tab.label }}
          </div>

          <HIcon
            size="sm"
            name="cancel"
            class="absolute right-0.5 hidden text-gray-700 hover:text-red-600 group-hover:block"
            @click.stop="() => {
              const nextTab = pane.tabs[idx + 1]
              if (nextTab) setActive(nextTab)
              else setActive(pane.tabs[idx - 1])
              pane.tabs.splice(idx, 1)
            }"
          />

          <div
            v-if="(idx === 0 && droppingTabIdx === idx)
              || (idx === pane.tabs.length - 1 && droppingTabIdx === pane.tabs.length)
            "
            class="absolute inset-y-0 w-0.5 bg-purple-300"
            :class="[
              idx === 0 && 'left-0',
              idx === pane.tabs.length - 1 && 'right-0',
            ]"
          />
        </div>
      </template>

      <div class="min-w-0 flex-1 border-b border-l" />
    </div>

    <div
      ref="editorRef"
      class="relative flex min-h-0 flex-1 flex-col"
      @dragleave="() => { resetEditorDroppingState() }"
      @dragover.prevent="onEditorDragOver"
      @drop.prevent="onEditorDrop"
    >
      <div
        v-if="pane.active"
        class="flex items-start border-b p-2 font-medium text-green-600"
      >
        <HIcon
          v-if="pane.active.icon"
          :name="pane.active.icon"
          class="mr-1"
        />
        <div class="min-w-0 flex-1 truncate">
          {{ pane.active.label }}
        </div>
      </div>

      <div class="min-h-0 flex-1 overflow-auto p-2">
        <pre v-if="pane.active">{{ pane.active.content }}</pre>
      </div>

      <div
        v-if="editorDroppingFile && editorDroppingPos"
        class="absolute -z-10 bg-purple-200/40 ring-2 ring-inset ring-purple-300"
        :class="[
          editorDroppingPos === 'center' && 'inset-0',
          editorDroppingPos === 'left' && 'inset-y-0 left-0 right-1/2',
          editorDroppingPos === 'right' && 'inset-y-0 left-1/2 right-0',
          editorDroppingPos === 'top' && 'inset-x-0 bottom-1/2 top-0',
          editorDroppingPos === 'bottom' && 'inset-x-0 bottom-0 top-1/2',
        ]"
      />
    </div>
  </div>
</template>
ts
import type { TreeNode } from '@holistics/design-system'

export interface TreeNodeEditor extends TreeNode<TreeNodeEditor> {
  content?: string
}

export interface EditorPane {
  idx?: [number, number]
  tabs: TreeNodeEditor[]
  active: TreeNodeEditor | undefined
}

export type EditorPanesGrid = [
  [EditorPane, EditorPane],
  [EditorPane, EditorPane]
]

export type EditorDroppingPosition = 'center' | 'left' | 'right' | 'top' | 'bottom'
ts
import type { EditorDroppingPosition, EditorPanesGrid } from './types'

export function canDropSplit (panesGrid: EditorPanesGrid, [idx, idxInner]: [number, number], pos: EditorDroppingPosition) {
  if (pos === 'left' || pos === 'right')
    if (panesGrid[idx].filter((p) => !!p.idx).length === 1) return true

  if (pos === 'top' || pos === 'bottom') {
    const panes = [
      panesGrid[idx - 1]?.[idxInner],
      panesGrid[idx][idxInner],
      panesGrid[idx + 1]?.[idxInner],
    ]
    if (panes.filter((p) => p?.idx).length === 1)
      return true
  }

  return false
}
ts
/* eslint-disable import/first */
import { useEventBus } from '@vueuse/core'

export const eventBus = useEventBus<{ name: 'resetDroppingStates' }>('treeEditorStory')

⏳️ Async Loading

Load tree nodes on-demand to improve performance for large datasets or when fetching data from APIs.

To mark a node as "unloaded", set isLeaf: false and leave children as undefined. When the user expands the node, the @node-load event fires, allowing you to fetch and populate its children.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { promiseTimeout } from '@vueuse/core'
import { HTree, type TreeLoadAction, type TreeNodeBase } from '@holistics/design-system'

const nodes = ref<TreeNodeBase[]>([
  {
    key: 'Dashboards',
    label: 'Dashboards',
    icon: 'folder-solid',
    isLeaf: false,
  },
  {
    key: 'DataSets',
    label: 'DataSets',
    icon: 'folder-solid',
    isLeaf: false,
  },
  { key: 'README.md', label: 'README.md', icon: 'markdown' },
  {
    key: 'So long....',
    // eslint-disable-next-line max-len
    label: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
    icon: 'database',
  },
])

let i = 1
async function onNodeLoad (node: TreeNodeBase, action: TreeLoadAction) {
  await promiseTimeout(2000)

  if ((node.key as string).includes('3.'))
    throw Error('[500] Something went wrong... 😢')
  else {
    let newChildren: TreeNodeBase[] = [
      {
        key: `[${i}] 1. Something else`, label: '1. Something else', icon: 'folder-solid', isLeaf: false,
      },
      {
        key: `[${i}] 2. Worth the effort`, label: '2. Worth the effort', icon: 'folder-solid', isLeaf: false,
      },
      {
        key: `[${i}] 3. But this is failing...!`, label: '3. But this is failing...!', icon: 'folder-solid', isLeaf: false,
      },
    ]

    if (node.children)
      if (action === 'load')
        // First load: existing `node.children` came from synchronous operations (e.g. Drag & Drop)
        // => Put fetched children at last
        newChildren = node.children.concat(newChildren)
      else
        // Reload: existing `node.children` was loaded before, but `newChildren` could have new and/or removed nodes
        // => Preserve the same-`key` nodes to avoid wiping out their descandants.
        newChildren = newChildren.map((newChild) => {
          const child = node.children!.find((c) => c.key === newChild.key)
          return child ?? newChild
        })

    node.children = newChildren

    i++
  }
}
</script>

<template>
  <HTree
    v-model:nodes="nodes"
    show-line
    click-mode="expand"
    draggable
    class="w-full rounded border sm:w-80"
    @expanded-keys-change="(node, action) => {
      if (action === 'expand') {
        node.icon = 'folder-open'
      } else {
        node.icon = 'folder-solid'
      }
    }"
    @node-load="onNodeLoad"
  />
</template>

🔢 Sorting

You can provide a sorting function to the sort prop to keep nodes sorted alphabetically, by type, or any custom order. When enabled, all children under the same parent will be sorted according to your function.

👉️ Automatic vs. Manual Sorting

Automatic sorting applies to internal operations that happen inside the Tree component:

  • Drag & Drop: When you drag a node to a new location, it's automatically inserted in the correct sorted position
  • Other internal mutations within the component

Manual sorting required for external operations that happen outside the Tree component:

  • Initial data: You must sort your nodes array before passing it to the Tree
  • Async loading: When fetching children from an API, sort them before assigning to node.children
  • External updates: Any changes you make to the nodes array
Why this distinction?

Automatically sorting all nodes recursively on every change would be extremely expensive since any node could contain deeply nested children.

The Tree component only sorts children of the same parent when an internal operation affects them. For external changes, you have better control over when and what to sort, avoiding unnecessary re-sorting of unchanged branches.

Utilities for Async Loading

Design System provides utility functions to help working with async loading on a sorted Tree:

ts
import { TreeNodeBase, TreeLoadAction, insertSorted, mapReplaceSorted, } from '@holistics/design-system'

async function onNodeLoad(node: TreeNodeBase, action: TreeLoadAction) {
  const newChildren = await callApi(node)

  newChildren.sort(sortFn)

  if (node.children) {
    if (action === 'load') {
      // First load: existing `node.children` came from synchronous operations (e.g. Drag & Drop)
      // => Insert fetched children in a sorted order
      insertSorted(newChildren, node.children, sortFn, true)
    } else {
      // Reload: existing `node.children` was loaded before, but `newChildren` could have new and/or removed nodes
      // => Preserve the same-`key` nodes to avoid wiping out their descendants.
      mapReplaceSorted(newChildren, node.children, (a, b) => a.key === b.key)
    }
  }

  node.children = newChildren
}

Drag & Drop behavior

When sorting is enabled, nodes are expected to be in a particular order. The drag & drop visual guides (highlights and drop indicators) reflect this by showing only valid sorted insertion points.

vue
<script setup lang="ts">
import { type Ref, ref } from 'vue'
import { HTree, type TreeNodeBase } from '@holistics/design-system'
import { NODES } from './nodes'

const nodes = ref(NODES()) as Ref<TreeNodeBase[]>

function sortFn (a: TreeNodeBase, b: TreeNodeBase) {
  if (a.children && !b.children) return -1
  if (!a.children && b.children) return 1

  return a.label > b.label ? 1 : -1
}

// eslint-disable-next-line @typescript-eslint/no-shadow
function sortNodes (nodes: TreeNodeBase[]) {
  nodes.forEach((node) => {
    if (node.children) sortNodes(node.children)
  })

  nodes.sort(sortFn)
}
sortNodes(nodes.value)
</script>

<template>
  <HTree
    v-model:nodes="nodes"
    show-line
    click-mode="expand"
    draggable
    :sort="sortFn"
    class="w-full rounded border sm:w-80"
    @expanded-keys-change="(node, action) => {
      if (action === 'expand') {
        node.icon = 'folder-open'
      } else {
        node.icon = 'folder-solid'
      }
    }"
  />
</template>
ts
import type { TreeNodeBase } from '@holistics/design-system'

export function NODES (): TreeNodeBase[] {
  return [
    {
      key: 'First Disabled', label: 'First Disabled', icon: 'formula', disabled: true,
    },
    {
      key: 'New Folder',
      label: 'New Folder',
      icon: 'folder-solid',
      selectable: false,
      children: [
        {
          key: 'Empty',
          label: 'Empty',
          icon: 'folder-solid',
          children: [],
          disabled: true,
        },
        {
          key: 'Files',
          label: 'Files',
          icon: 'folder-solid',
          children: [
            { key: 'Tree.svg', label: 'Tree.svg', icon: 'file/image' },
            { key: 'Schedules.xlsx', label: 'Schedules.xlsx', icon: 'file/excel' },
            { key: 'Report.pdf', label: 'Report.pdf', icon: 'file/pdf' },
            {
              key: '[Inner] So long....',
              // eslint-disable-next-line max-len
              label: '[Inner] Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
              icon: 'folder-solid',
              children: [
                {
                  key: 'DISABLED', label: 'DISABLED', icon: 'cancel', disabled: true,
                },
                { key: 'Things', label: 'Things', icon: 'archived' },
                {
                  key: 'Check Disabled', label: 'Check Disabled', icon: 'cancel', checkDisabled: true,
                },
              ],
            },
          ],
        },
        { key: 'Markdown.md', label: 'Markdown.md', icon: 'markdown' },
      ],
    },
    {
      key: 'Dashboards',
      label: 'Dashboards',
      icon: 'folder-solid',
      children: [
        { key: 'ECommerce', label: 'ECommerce', icon: 'canvas' },
        { key: 'Charts', label: 'Charts', icon: 'canvas' },
        { key: 'Monitoring', label: 'Monitoring', icon: 'canvas' },
      ],
    },
    {
      key: 'DataSets',
      label: 'DataSets',
      icon: 'folder-solid',
      children: [
        { key: 'Calendar', label: 'Calendar', icon: 'data-set' },
        { key: 'Performance', label: 'Performance', icon: 'data-set' },
        { key: 'Metrics', label: 'Metrics', icon: 'data-set' },
      ],
    },
    { key: 'README.md', label: 'README.md', icon: 'markdown' },
    {
      key: 'Drag & Drop',
      label: 'Drag & Drop',
      icon: 'folder-solid',
      children: [
        {
          key: 'Inner',
          label: 'Inner',
          icon: 'folder-solid',
          children: [
            { key: '1. One', label: '1. One', icon: 'user' },
            { key: '2. Two', label: '2. Two', icon: 'group' },
            { key: '3. Three', label: '3. Three', icon: 'globe' },
            {
              key: '4. Four', label: '4. Four', icon: 'bell', disabled: true,
            },
            { key: '5. Five', label: '5. Five', icon: 'file/csv' },
          ],
        },
      ],
    },
    {
      key: 'So long....',
      // eslint-disable-next-line max-len
      label: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
      icon: 'database',
    },
    {
      key: 'Last Disabled', label: 'Last Disabled', icon: 'formula', disabled: true,
    },
  ]
}

⚡ Virtual List

Enable virtualization for trees with 1000+ nodes to improve rendering performance. Only visible nodes are rendered in the DOM.

Simply set a fixed/max height to the Tree container and pass the virtualList prop:

vue
<script setup lang="ts">
import { type Ref, ref } from 'vue'
import { HTree, type TreeNodeBase, type DropdownOption } from '@holistics/design-system'

const nodes = ref(Array.from({ length: 10 }, (_, i) => {
  const folderNum = i + 1

  return {
    key: `Folder ${folderNum}`,
    label: `Folder ${folderNum}`,
    icon: 'folder-solid',
    children: Array.from({ length: 1000 }, (__, j) => {
      const fileNum = j + 1

      return {
        key: `File ${folderNum}.${fileNum}`,
        label: `File ${folderNum}.${fileNum}`,
        icon: 'file/report',
      }
    }),
  }
})) as Ref<TreeNodeBase[]>

const contextMenuOptions: DropdownOption[] = [
  {
    key: 'Add', label: 'Add', icons: 'add', action: () => { alert('Add') },
  },
  {
    key: 'Remove', label: 'Remove', icons: 'delete', class: 'text-red-500', action: () => { alert('Remove') },
  },
]
</script>

<template>
  <HTree
    v-model:nodes="nodes"
    show-line
    click-mode="expand"
    draggable
    virtual-list
    :context-menu-options
    class="max-h-[40vh] w-full rounded border sm:w-80"
  />
</template>

Legacy Virtual List

By default, Tree will use an optimized version of Virtual List when virtualListEstimateSize is a static number and virtualListDynamicSize is false. That version will eventually support all the features like the old version. For now, if you want to force using the old version, set virtualList to 'legacy'.

Tree supports Vue Router out of the box so you can render individual nodes as <RouterLink>.

First, make sure vue-router is installed:

shell
npm add vue-router
shell
pnpm add vue-router
shell
yarn add vue-router

Then, specify the to prop for nodes that you want to be links.

External links

If to is a string starting with http, the node will be treated as an external link and will automatically have target="_blank" alongside rel="noopener noreferrer" set on the <a> element.

vue
<script setup lang="ts">
import { computed, type Ref, ref } from 'vue'
import { useRoute } from 'vue-router'
import { HBadge, HTree, type TreeNodeBase } from '@holistics/design-system'

const route = useRoute()
const routeInfo = computed(() => {
  const {
    path, fullPath, name, params, hash, query, meta,
  } = route

  return {
    path, fullPath, name, params, hash, query, meta,
  }
})

const nodes = ref([
  {
    key: 'First Disabled',
    label: 'First Disabled',
    icon: 'formula',
    disabled: true,
    to: '/',
  },
  {
    key: 'Link but not selectable',
    label: 'Link but not selectable',
    icon: 'folder-solid',
    selectable: false,
    to: '/',
    children: [
      {
        key: 'Empty',
        label: 'Empty',
        icon: 'folder-solid',
        children: [],
        disabled: true,
      },
      {
        key: 'No route',
        label: 'No route',
        icon: 'folder-solid',
        children: [
          {
            key: 'To /basic',
            label: 'To /basic',
            icon: 'file/image',
            to: '/basic',
          },
          {
            key: "To { name: 'dnd' }",
            label: "To { name: 'dnd' }",
            icon: 'file/excel',
            to: { name: 'dnd' },
          },
          {
            key: "To { name: 'async', query: { title: 'cool' } }",
            label: "To { name: 'async', query: { title: 'cool' } }",
            icon: 'file/pdf',
            to: {
              name: 'async',
              query: {
                title: 'cool',
              },
            },
          },
          {
            key: '[Inner] So long....',
            // eslint-disable-next-line max-len
            label: '[Inner] Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
            icon: 'folder-solid',
            to: '/',
            children: [
              {
                key: "To { name: 'root' } (disabled)",
                label: "To { name: 'root' } (disabled)",
                disabled: true,
                to: '/',
              },
            ],
          },
        ],
      },
      {
        key: 'Markdown.md (external)',
        label: 'Markdown.md (external)',
        icon: 'markdown',
        to: 'https://docs.holistics.io',
      },
    ],
  },
  {
    key: 'So long....',
    // eslint-disable-next-line max-len
    label: '[target="_self"] Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
    icon: 'database',
    to: 'https://docs.holistics.io',
    target: '_self',
  },
  {
    key: 'Last Disabled (external)',
    label: 'Last Disabled (external)',
    icon: 'formula',
    disabled: true,
    to: 'https://docs.holistics.io',
  },
]) as Ref<TreeNodeBase[]>
</script>

<template>
  <HTree
    v-model:nodes="nodes"
    show-line
    click-mode="expand"
    multiple
    checkable
    check-cascade
    draggable
    class="max-h-[50vh] w-full rounded border sm:w-80"
    @expanded-keys-change="(node, action) => {
      if (action === 'expand') {
        node.icon = 'folder'
      } else {
        node.icon = 'folder-solid'
      }
    }"
  />

  <div class="mt-4 text-center">
    <HBadge
      label="Current route"
      size="md"
      as-div
    />
    <pre class="mt-1 text-left">{{ routeInfo }}</pre>
  </div>
</template>

🎨 Styling

Quickly set your preferred styles for different parts and states by using the nodeStyles props. You can also use the CSS class names to customize different parts, but it doesn't contain any state information (for now).

Default node styles

You can access the default node styles via DEFAULT_NODE_STYLES from @holistics/design-system. Here is the default value:

json
{
  "container": {
    "base": "flex select-none items-start rounded py-1.5 pr-2 focus:outline-none",
    "hovered": "hover:bg-gray-200",
    "selected": "bg-blue-50",
    "multiSelectRangedCursor": "ring-2 ring-blue-200 ring-inset",
    "dragging": "bg-gray-300 opacity-40",
    "focused": "bg-gray-200",
    "contextMenuOpened": "bg-gray-200",
    "disabled": {
      "true": "cursor-not-allowed",
      "false": "cursor-pointer"
    }
  },
  "toggle": {
    "base": "flex items-center justify-center h-4",
    "errorTooltip": "!text-red-400",
    "error": "text-red-600",
    "loading": "cursor-wait text-gray-700",
    "hovered": "hover:text-blue-600",
    "expanded": "rotate-90",
    "disabled": "opacity-40"
  },
  "checkbox": {
    "base": "flex size-3 items-center justify-center rounded-sm border text-white",
    "hovered": "hover:border-blue-600",
    "unchecked": "border-gray-400 bg-white",
    "checked": "border-blue-600 bg-blue-600",
    "indeterminate": "border-blue-600 bg-blue-600",
    "disabled": "opacity-40 cursor-not-allowed"
  },
  "content": {
    "base": "flex items-start overflow-hidden",
    "disabled": "opacity-40"
  },
  "dragOverGuide": {
    "parent": "absolute inset-0 bg-blue-50/40",
    "inside": "absolute right-0 inset-y-0 rounded border-2 border-blue-300",
    "before": "absolute right-0 -top-px rounded h-0.5 bg-blue-300",
    "after": "absolute right-0 -bottom-px rounded h-0.5 bg-blue-300",
    "siblings": "absolute right-0 inset-y-0 bg-blue-50/40"
  }
}

Customize connection line colors

Currently, all parts and their states can be customized via the nodeStyles prop, except the lines (i.e. from the showLine prop). For performance reason, you must use CSS vars to customize their colors. Here are the vars and their default values:

css
:root {
  --hui-tree-node-line-color: theme(colors.gray.300);
  --hui-tree-node-line-color-hovered: theme(colors.blue.500);
  --hui-tree-node-line-color-active: theme(colors.gray.500);
}
vue
<script setup lang="ts">
import { type Ref, ref } from 'vue'
import {
  HTree,
  type TreeNodeBase,
  type TreeNodeStyles,
  type TreeNodeKey,
} from '@holistics/design-system'
import { NODES } from './nodes'

const nodes = ref(NODES()) as Ref<TreeNodeBase[]>
const expandedKeys = ref<TreeNodeKey[]>([])

const nodeStyles: TreeNodeStyles = {
  container: {
    hovered: 'hover:bg-green-50',
    selected: 'bg-red-50',
    multiSelectRangedCursor: 'ring-2 ring-purple-200',
    dragging: 'bg-orange-100',
    focused: 'bg-green-50',
    contextMenuOpened: 'bg-blue-50',
  },
  toggle: {
    hovered: 'hover:text-red-600',
    error: 'text-orange-600',
    errorTooltip: '!text-orange-400',
    loading: 'text-blue-600',
  },
  checkbox: {
    unchecked: 'border-green-300 bg-white hover:border-purple-400',
    checked: 'border-purple-600 bg-purple-600',
    indeterminate: 'border-purple-600 bg-purple-600',
  },
  dragOverGuide: {
    parent: 'absolute inset-0 bg-blue-50/40',
    inside: 'absolute right-0 inset-y-0 rounded border-2 border-blue-300',
    before: 'absolute right-0 -top-px rounded h-0.5 bg-blue-300',
    after: 'absolute right-0 -bottom-px rounded h-0.5 bg-blue-300',
    siblings: 'absolute right-0 inset-y-0 bg-blue-50/40',
  },
}
</script>

<template>
  <HTree
    v-model:nodes="nodes"
    v-model:expanded-keys="expandedKeys"
    show-line
    click-mode="expand"
    multiple
    checkable
    check-cascade
    draggable
    :node-styles
    class="tree-styling w-full rounded border sm:w-80"
    @expanded-keys-change="(node, action) => {
      if (action === 'expand') {
        node.icon = 'folder-open'
      } else {
        node.icon = 'folder-solid'
      }
    }"
  />
</template>

<style lang="postcss">
.tree-styling {
  --hui-tree-node-line-color: theme(colors.orange.200);
  --hui-tree-node-line-color-hovered: theme(colors.purple.500);
  --hui-tree-node-line-color-active: theme(colors.orange.400);
}
</style>
ts
import type { TreeNodeBase } from '@holistics/design-system'

export function NODES (): TreeNodeBase[] {
  return [
    {
      key: 'First Disabled', label: 'First Disabled', icon: 'formula', disabled: true,
    },
    {
      key: 'New Folder',
      label: 'New Folder',
      icon: 'folder-solid',
      selectable: false,
      children: [
        {
          key: 'Empty',
          label: 'Empty',
          icon: 'folder-solid',
          children: [],
          disabled: true,
        },
        {
          key: 'Files',
          label: 'Files',
          icon: 'folder-solid',
          children: [
            { key: 'Tree.svg', label: 'Tree.svg', icon: 'file/image' },
            { key: 'Schedules.xlsx', label: 'Schedules.xlsx', icon: 'file/excel' },
            { key: 'Report.pdf', label: 'Report.pdf', icon: 'file/pdf' },
            {
              key: '[Inner] So long....',
              // eslint-disable-next-line max-len
              label: '[Inner] Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
              icon: 'folder-solid',
              children: [
                {
                  key: 'DISABLED', label: 'DISABLED', icon: 'cancel', disabled: true,
                },
                { key: 'Things', label: 'Things', icon: 'archived' },
                {
                  key: 'Check Disabled', label: 'Check Disabled', icon: 'cancel', checkDisabled: true,
                },
              ],
            },
          ],
        },
        { key: 'Markdown.md', label: 'Markdown.md', icon: 'markdown' },
      ],
    },
    {
      key: 'Dashboards',
      label: 'Dashboards',
      icon: 'folder-solid',
      children: [
        { key: 'ECommerce', label: 'ECommerce', icon: 'canvas' },
        { key: 'Charts', label: 'Charts', icon: 'canvas' },
        { key: 'Monitoring', label: 'Monitoring', icon: 'canvas' },
      ],
    },
    {
      key: 'DataSets',
      label: 'DataSets',
      icon: 'folder-solid',
      children: [
        { key: 'Calendar', label: 'Calendar', icon: 'data-set' },
        { key: 'Performance', label: 'Performance', icon: 'data-set' },
        { key: 'Metrics', label: 'Metrics', icon: 'data-set' },
      ],
    },
    { key: 'README.md', label: 'README.md', icon: 'markdown' },
    {
      key: 'Drag & Drop',
      label: 'Drag & Drop',
      icon: 'folder-solid',
      children: [
        {
          key: 'Inner',
          label: 'Inner',
          icon: 'folder-solid',
          children: [
            { key: '1. One', label: '1. One', icon: 'user' },
            { key: '2. Two', label: '2. Two', icon: 'group' },
            { key: '3. Three', label: '3. Three', icon: 'globe' },
            {
              key: '4. Four', label: '4. Four', icon: 'bell', disabled: true,
            },
            { key: '5. Five', label: '5. Five', icon: 'file/csv' },
          ],
        },
      ],
    },
    {
      key: 'So long....',
      // eslint-disable-next-line max-len
      label: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
      icon: 'database',
    },
    {
      key: 'Last Disabled', label: 'Last Disabled', icon: 'formula', disabled: true,
    },
  ]
}

🧩 Slots

You have many options for slot customizing, including global slots (i.e. applying for all nodes) & individual slots.

Following is an example of using individual slot to have a rename input in-place where the user hit the "Rename" option in the context menu.

vue
<script setup lang="ts">
/* eslint-disable no-alert */
import { ref, useTemplateRef, watch } from 'vue'
import { HTree, HIcon, isLeaf } from '@holistics/design-system'
import type {
  TreeNodeBase,
  TreeNodeKey,
  TreeContextMenuOptions,
} from '@holistics/design-system'
import { NODES } from './nodes'

const nodes = ref<TreeNodeBase[]>(NODES())

const expandedKeys = ref<TreeNodeKey[]>([])

const input = useTemplateRef('inputRef')
let prevRange = [0, 0]
const rename = ref('')
function resetRename (node: TreeNodeBase) {
  node.slotContent = undefined
  node.disableDraggable = undefined
  prevRange = [0, 0]
  rename.value = ''
}
watch(input, () => {
  if (!input.value)
    return

  input.value.focus({ preventScroll: true })
  input.value.setSelectionRange(prevRange[0], prevRange[1])
})

function onBlur (event: FocusEvent, node: TreeNodeBase) {
  // TODO: use index to reliably check whether the node is not rendered in virtual list
  if (input.value) {
    resetRename(node)
    return
  }

  const { selectionStart, selectionEnd } = (event.target as HTMLInputElement)
  if (selectionStart !== null && selectionEnd !== null)
    prevRange = [selectionStart, selectionEnd]
}

const contextMenuOptions: TreeContextMenuOptions<TreeNodeBase> = (node) => {
  return [{
    key: 'Rename',
    label: 'Rename',
    icons: 'rename',
    action: () => {
      let endIdx = node.label.length
      if (isLeaf(node)) {
        const dotIdx = node.label.lastIndexOf('.')
        if (dotIdx !== -1) endIdx = dotIdx
      }
      prevRange = [0, endIdx]

      rename.value = node.label
      node.disableDraggable = true
      node.slotContent = 'node-rename'
    },
  }]
}
</script>

<template>
  <HTree
    v-model:nodes="nodes"
    v-model:expanded-keys="expandedKeys"
    show-line
    click-mode="expand"
    draggable
    virtual-list
    :virtual-list-overscan="0"
    :context-menu-options
    class="max-h-[40vh] w-full rounded border sm:w-80"
    @expanded-keys-change="(node, action) => {
      if (action === 'expand') {
        node.icon = 'folder-open'
      } else {
        node.icon = 'folder-solid'
      }
    }"
  >
    <template #node-rename="{ node }">
      <div class="flex min-w-0 flex-1 items-center">
        <HIcon
          v-if="node.icon"
          :name="node.icon"
          class="px-0.5"
        />
        <div class="-my-0.5 -ml-px flex h-5 min-w-0 flex-1 items-center rounded px-0.5 pl-[3px] ring-1 ring-blue-300">
          <input
            ref="inputRef"
            v-model="rename"
            class="min-w-0 flex-1 bg-transparent focus:outline-none"
            @keydown.enter="node.label = rename; resetRename(node)"
            @keydown.esc="resetRename(node)"
            @keydown.stop
            @blur="onBlur($event, node)"
            @click.stop
          >
        </div>
      </div>
    </template>
  </HTree>
</template>
ts
import type { TreeNodeBase } from '@holistics/design-system'

export function NODES (): TreeNodeBase[] {
  return [
    {
      key: 'First Disabled', label: 'First Disabled', icon: 'formula', disabled: true,
    },
    {
      key: 'New Folder',
      label: 'New Folder',
      icon: 'folder-solid',
      selectable: false,
      children: [
        {
          key: 'Empty',
          label: 'Empty',
          icon: 'folder-solid',
          children: [],
          disabled: true,
        },
        {
          key: 'Files',
          label: 'Files',
          icon: 'folder-solid',
          children: [
            { key: 'Tree.svg', label: 'Tree.svg', icon: 'file/image' },
            { key: 'Schedules.xlsx', label: 'Schedules.xlsx', icon: 'file/excel' },
            { key: 'Report.pdf', label: 'Report.pdf', icon: 'file/pdf' },
            {
              key: '[Inner] So long....',
              // eslint-disable-next-line max-len
              label: '[Inner] Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
              icon: 'folder-solid',
              children: [
                {
                  key: 'DISABLED', label: 'DISABLED', icon: 'cancel', disabled: true,
                },
                { key: 'Things', label: 'Things', icon: 'archived' },
                {
                  key: 'Check Disabled', label: 'Check Disabled', icon: 'cancel', checkDisabled: true,
                },
              ],
            },
          ],
        },
        { key: 'Markdown.md', label: 'Markdown.md', icon: 'markdown' },
      ],
    },
    {
      key: 'Dashboards',
      label: 'Dashboards',
      icon: 'folder-solid',
      children: [
        { key: 'ECommerce', label: 'ECommerce', icon: 'canvas' },
        { key: 'Charts', label: 'Charts', icon: 'canvas' },
        { key: 'Monitoring', label: 'Monitoring', icon: 'canvas' },
      ],
    },
    {
      key: 'DataSets',
      label: 'DataSets',
      icon: 'folder-solid',
      children: [
        { key: 'Calendar', label: 'Calendar', icon: 'data-set' },
        { key: 'Performance', label: 'Performance', icon: 'data-set' },
        { key: 'Metrics', label: 'Metrics', icon: 'data-set' },
      ],
    },
    { key: 'README.md', label: 'README.md', icon: 'markdown' },
    {
      key: 'Drag & Drop',
      label: 'Drag & Drop',
      icon: 'folder-solid',
      children: [
        {
          key: 'Inner',
          label: 'Inner',
          icon: 'folder-solid',
          children: [
            { key: '1. One', label: '1. One', icon: 'user' },
            { key: '2. Two', label: '2. Two', icon: 'group' },
            { key: '3. Three', label: '3. Three', icon: 'globe' },
            {
              key: '4. Four', label: '4. Four', icon: 'bell', disabled: true,
            },
            { key: '5. Five', label: '5. Five', icon: 'file/csv' },
          ],
        },
      ],
    },
    {
      key: 'So long....',
      // eslint-disable-next-line max-len
      label: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
      icon: 'database',
    },
    {
      key: 'Last Disabled', label: 'Last Disabled', icon: 'formula', disabled: true,
    },
  ]
}

WARNING

Be careful when using slots with Virtual List as the slots could be unmounted when not in scroll view. The example also demonstrate a way (not the best tho) to control the states when this happens.

🏷️ Generics

Tree supports generic types by looking at the nodes prop so that all the emitted events and other props referencing nodes will have the passed type.

vue
<script setup lang="ts">
import { ref, type Ref } from 'vue'
import { HTree, type TreeNode } from '@holistics/design-system'

interface MyTreeNode extends TreeNode<MyTreeNode> {
//        ^ Your node                 ^ `children` of your node
  canDelete?: boolean
}

const nodes = ref([]) as Ref<MyTreeNode[]>
</script>

<template>
  <HTree
    v-model:nodes="nodes"
    :sort="(nodeA, nodeB) => {
      // `nodeA` & `nodeB` will have type `MyTreeNode`
    }"
    @node-load="(node) => {
      // `node` will have type `MyTreeNode`
      if (node.canDelete) {
        // ...
      }
    }"
  />
</template>

Fixed depth via recursive generic type

You can describe your tree structure via recursive generic type to get the best type-safety and convenience.

Example
ts
// A Dataset can only contain Data Models & is always be a parent node
interface DatasetNode extends TreeNode<DataModelNode> {
  type: 'dataset'
  icon: 'data-set'
  children: DataModelNode[]
}
// A Data Model can only contain Fields & is always be a parent node
interface DataModelNode extends TreeNode<Field> {
  type: 'data-model'
  icon: 'data-model'
  children: Field[]
}
// A Field is always a leaf node
interface FieldNode extends TreeNode<never> {
  type: 'field'
  icon: 'type/string' | 'type/number' | 'type/boolean' | 'type/date' | 'type/timestamp' | 'type/unknown'
  children?: never
}

Forcing generic type

Since Vue components are just functions, you can force a generic type when using Tree.

Example
vue
<script setup lang="ts">
import { _HTree, type TreeNode } from '@holistics/design-system'

interface ForcedTreeNode extends TreeNode<ForcedTreeNode> {
  someKey?: boolean
}

const HTree = _HTree<ForcedTreeNode>
</script>

<template>
  <HTree
    @node-load="(node) => {
      // `node` will have type `ForcedTreeNode` even though `nodes` prop is not specified`
    }"
  />
</template>

API

Pass-through: <div>

What does this mean?

All props, events, and attrs that are not specified in the tables below will be passed to the element/component described above.

Props

NameTypeDescription
nodes *
TreeNode<any>[]
expandedKeys 
TreeNodeKey[]
= []
defaultExpandedKeys 
TreeNodeKey[]
selectedKeys 
TreeNodeKey[]
= []
defaultSelectedKeys 
TreeNodeKey[]
checkedKeys 
TreeNodeKey[]
= []
defaultCheckedKeys 
TreeNodeKey[]
indeterminateKeys 
TreeNodeKey[]
= []
clickMode 
TreeClickMode

Operation to do when single-clicking on a node.

toggleAtAppend 
boolean

When true, toggle icons will be placed at append position.

expandOnClick 
boolean

Whether to expand on single click instead of clicking the arrow icon.

defaultExpandAll 
boolean

Whether to expand all non-leaf nodes on first render.

firstSelectedAutoExpand 
boolean

Whether to automatically expand ancestors and scroll to the first selected node whenever it changes.

NOTE: Since selectedKeys is 2-way bound which means this auto behavior could be triggered inside Tree (e.g. when clicking on a node). Therefore, it's encouraged to manually expand ancestors and scroll when needed from outside (e.g. when route changes) to avoid unncessary processing and possible race conditions.

For now, all operations mutating selectedKeys inside Tree are tested to make the auto behavior works as expected. However, there could be more operations in the future which might break this behavior and the call side would need to be adjusted into manual handling.

multiple 
boolean

Whether to allow multiple select.

multipleOnClick 
boolean

By default, multi-selecting requires holding Ctrl-click (individual) or Ctrl+Shift-click (range). Set this to true to allow multi-selecting without holding Ctrl.

checkable 
boolean

Whether to display the selection checkbox.

checkCascade 
boolean

Whether to cascade checkboxes.

checkStrategy 
CheckStrategy
= "all"
showLine 
boolean
renderedNodesFilter 
TreeRenderedNodesFilterFn<TreeNode<any>>

A function used to filter which nodes are rendered in the view (i.e. the flatten nodes). The original hierarchical structure of the nodes remains unchanged, so cascading operations such as checking will still include nodes that are filtered out from the view.

searchText 
string

Text used to search nodes.

search 
TreeSearchFn<TreeNode<any>>
= treeSearch

Custom low-level function used to search.

Tree already provided a good default algorithm capable for both simple search & stemming with hierarchical search. But if your use-case is more advanced, you can provide your algorithm here.

searchFilter 
TreeSearchFilterFn<TreeNode<any>>

Custom filter function used to check whether a node is matched.

NOTE: This won't apply to stemming with hierarchical search.

searchStemIndex 
boolean | TreeSearchStemIndex

Whether to enable stemming with hierarchical search.

Stem index is an inverted index map from a stem to node keys, then for each key map to its list of original tokens.

  • When true, the index is collected automatically via collectStemTokenIndex()
  • Can provide a custom index from outside
searchHideUnmatched 
boolean

By default, all unmatched nodes are shown but with their ancestors collapsed. To only show matched nodes, set this to true.

draggable 
boolean
droppable 
DroppableFn<TreeNode<any>>
dragImage 
DragImage<TreeNode<any>>
disableExpandOnDragOver 
boolean
dragAutoScrollEdgeSize 
number
= (DEFAULT_NODE_CONTAINER_HEIGHT + (p.nodeDistance ?? 4)) * 3
dragAutoScrollDampeningBuffer 
number
= DEFAULT_NODE_CONTAINER_HEIGHT + (p.nodeDistance ?? 4)
dragAutoScrollTimeDampening 
number
= 400
loadingKeys 
TreeNodeKey[]
= []
keyboardTargets 
(HTMLElement | null)[]

Targets for handling keyboard interactions.

keyboardLoop 
boolean

When true, pressing Up at the first item will focus the last item & pressing Down at the last item will focus the first item.

sort 
SortFn<TreeNode<any>>

Function to sort the nodes within the same parent. The mechanism is the same as the Array.sort() function.

IMPORTANT: As nodes is a Vue model, this component assumes that the nodes set from outside (e.g. initial nodes, async loaded nodes,...) are already sorted and only execute this function for moving operations such as drag-and-drop.

{@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort}

virtualList 
boolean | "legacy"

When true, render nodes as virtual list to enhance performance when the number of nodes is large. You need to set a proper height/max-height style of the container to make it work.

By default, if virtualListEstimateSize is a static number (i.e. no dynamic size), this will use a highly-optimized virtual list mechanism to offer the best performance, even when the nodes have many complex states and UI. To force it to use the old mechanism (having more features), set this to 'legacy'.

virtualListEstimateSize 
number | ((index: number, node: TreeNode<any>) => number)

A static size (px) of every node or a function that returns the actual size of each node. It's typically the hovering size of the node + nodeDistance.

If dynamicSize == true, this should be the largest possible size of the nodes. This will ensure features like smooth-scrolling having a better chance at working correctly.

{@link https://tanstack.com/virtual/latest/docs/api/virtualizer#estimatesize}

virtualListDynamicSize 
boolean

When true, indicate the virtualizer that each node size can only be correctly calculated after rendering (e.g. multi-lined texts) or can be changed afterward.

virtualListOverscan 
number
= 24

The number of nodes to render before and after the visible area. Increasing this number will increase the amount of time it takes to render the virtualizer, but might decrease the likelihood of seeing slow-rendering blank nodes at the top and bottom of the virtualizer when scrolling.

{@link https://tanstack.com/virtual/latest/docs/api/virtualizer#overscan}

virtualListPaddingStart 
number

The padding (px) to apply to the start of the virtualizer.

{@link https://tanstack.com/virtual/latest/docs/api/virtualizer#paddingstart}

virtualListPaddingEnd 
number

The padding (px) to apply to the end of the virtualizer.

{@link https://tanstack.com/virtual/latest/docs/api/virtualizer#paddingend}

virtualListScrollPaddingStart 
number

The padding (px) to apply to the start of the virtualizer when scrolling to an element.

{@link https://tanstack.com/virtual/latest/docs/api/virtualizer#scrollpaddingstart}

virtualListScrollPaddingEnd 
number

The padding (px) to apply to the end of the virtualizer when scrolling to an element.

{@link https://tanstack.com/virtual/latest/docs/api/virtualizer#scrollpaddingend}

contextMenuOptions 
TreeContextMenuOptions<TreeNode<any>>

Options for context menu. Could be a function to allow return different options based on the right-clicked node.

contextMenuProps 
TreeContextMenuProps

Extra props bound to the <Dropdown> component used to render context menu.

nodeDistance 
number
= 4

The distance (in px) between 2 nodes.

nodeIndent 
number
= 20

The distance (in px) between nodes of 2 continuous level. Usually, this is equal to the minimal height of a node subtract nodeDistance.

nodeStyles 
TreeNodeStyles
= {}
nodeHighlights 
((node: TreeNode<any>) => string | string[])
nodeProps 
((node: TreeNode<any>) => HTMLAttributes)

The getter to bound additional props to the root element of the node. This can be very useful when you want to add some props without having to directly mutate node.props or replicate the entire #node slot.

nodeContainerProps 
((node: TreeNode<any>) => HTMLAttributes)

The getter to bound additional props to the container element of the node. This can be very useful when you want to add some props without having to directly mutate node.containerProps or replicate the entire #node-container slot.

nodeContentProps 
((node: TreeNode<any>) => HTMLAttributes)

The getter to bound additional props to the content element of the node. This can be very useful when you want to add some props without having to directly mutate node.contentProps or replicate the entire #node-content slot.

router 
Router

Custom Router to replace the default useRouter().

disabled 
boolean

When set to true, the entire tree is disabled.

Events

NameParametersDescription
@keydown
[event: KeyboardEvent, node?: TreeNode<any> | undefined]
@update:indeterminateKeys
[keys: TreeNodeKey[]]
@update:nodes
[nodes: TreeNode<any>[]]
@update:expandedKeys
[keys: TreeNodeKey[]]
@expandedKeysChange
[node: TreeNode<any>, action: TreeExpandAction]
@update:selectedKeys
[keys: TreeNodeKey[]]
@selectedKeysChange
[node: TreeNode<any> | undefined, action: TreeSelectAction]
@update:checkedKeys
[keys: TreeNodeKey[]]
@checkedKeysChange
[node: TreeNode<any>, action: TreeCheckAction]
@indeterminateKeysChange
[node: TreeNode<any>]
@nodeClick
[event: TreeNodeClickEvent, node: TreeNode<any>]
@nodeSelect
[event: TreeNodeSelectEvent, node: TreeNode<any> | undefined]
@nodeDragStart
[event: DragEvent, node: TreeNode<any>]
@nodeDragEnd
[event: DragEvent, node: TreeNode<any>]
@nodeDragEnter
[event: DragEvent, node: TreeNode<any>]
@nodeDragOver
[event: DragEvent, node: TreeNode<any>]
@nodeDrop
[event: DragEvent, node: TreeNode<any>, dragNode: TreeNode<any>, dropNode: TreeNode<any>, dropPosition: DroppingPosition]
@update:loadingKeys
[keys: TreeNodeKey[]]
@loadingKeysChange
[node: TreeNode<any>, action: TreeLoadAction, loading: boolean]
@nodeFocus
[key?: TreeNodeKey | undefined, node?: TreeNode<any> | undefined]
@contextMenu
[event: MouseEvent, node: TreeNode<any>]
@contextMenuOptionSelect
[option: DropdownOption, node: TreeNode<any>]

Slots

NameScopedDescription
#node
TreeSlotNodeProps<TreeNode<any>>
#node-toggle
TreeSlotNodeToggleProps<TreeNode<any>>
#node-prepend
TreeSlotNodeContentProps<TreeNode<any>>
#node-content
TreeSlotNodeContentProps<TreeNode<any>>
#node-append
TreeSlotNodeContentProps<TreeNode<any>>
#context-menu
{ options: DropdownOption[]; anchor?: PopperAnchorRect | undefined; open: boolean; resetState: () => void; }