PXD

Tree

Tree view driven by the data prop, with expandable parents, single or cascading multiple selection, disabled nodes, built-in search and virtualized rows for large trees.

Default

Use v-model to bind the selected value, click a parent row or its arrow to expand it.

docs
<script setup>
import { ref } from 'vue'

const value = ref(null)

const data = [
  {
    label: 'src',
    value: 'src',
    children: [
      { label: 'components', value: 'components', children: [{ label: 'tree', value: 'tree' }] },
      { label: 'composables', value: 'composables' },
    ],
  },
  { label: 'docs', value: 'docs' },
]
</script>

<template>
  <PTree v-model="value" class="w-72 max-h-64" :data="data" />
</template>

Multiple

Set multiple to show a checkbox on every row. Checking a parent checks its whole subtree, a partially checked parent stays out of the model.

docs
<script setup>
import { ref } from 'vue'

const value = ref([])

const data = [
  {
    label: 'src',
    value: 'src',
    children: [
      { label: 'components', value: 'components' },
      { label: 'composables', value: 'composables' },
    ],
  },
  { label: 'docs', value: 'docs' },
]
</script>

<template>
  <PTree v-model="value" class="w-72 max-h-64" multiple :data="data" />
</template>

Independent selection

Checking a parent normally cascades through its subtree. Set check-strictly to keep every node on its own: the model holds exactly the values that were picked, a parent never lights up because its children are checked, and no row shows the partial state.

src
components
composables
docs
<script setup>
import { ref } from 'vue'

const value = ref(['composables'])

const data = [
  {
    label: 'src',
    value: 'src',
    children: [
      { label: 'components', value: 'components' },
      { label: 'composables', value: 'composables' },
    ],
  },
  { label: 'docs', value: 'docs' },
]
</script>

<template>
<PTree
  v-model="value"
  class="w-72 max-h-64"
  multiple
  check-strictly
  :data="data"
  :default-expanded-keys="['src']"
/>
</template>

Expand on click

expand-on-click is true by default, a parent row expands its children. Set it to false to let the row toggle the selection instead and keep the arrow as the only expand control.

docs
<script setup>
const data = [
  { label: 'src', value: 'src', children: [{ label: 'tree', value: 'tree' }] },
  { label: 'docs', value: 'docs' },
]
</script>

<template>
  <PTree class="w-72 max-h-64" :data="data" expand-on-click />
</template>

Disabled

A disabled node cannot be selected and is skipped while navigating, its arrow still expands it.

src
components
locked
<script setup>
const data = [
  {
    label: 'src',
    value: 'src',
    children: [
      { label: 'components', value: 'components' },
      { label: 'locked', value: 'locked', disabled: true },
    ],
  },
]
</script>

<template>
  <PTree class="w-72 max-h-64" multiple :data="data" :default-expanded-keys="['src']" />
</template>

Set filterable to render a search field. Matching is fuzzy, every matched node keeps its ancestor chain visible, highlight-match marks the matched substring. A fuzzy hit that is not one unbroken run of characters still shows its row, just without the highlight.

Bind v-model:search-value to clear the query from the outside; left unbound the field keeps its own state.

readme.md
<script setup>
const data = [
  {
    label: 'src',
    value: 'src',
    children: [
      { label: 'components', value: 'components', children: [{ label: 'tree', value: 'tree' }] },
      { label: 'composables', value: 'composables' },
    ],
  },
  { label: 'readme.md', value: 'readme' },
]
</script>

<template>
  <PTree class="w-72 max-h-64" filterable highlight-match :data="data">
    <template #empty>No match.</template>
  </PTree>
</template>

Virtual

Set virtual to render only the visible rows. The container needs a height, item-size is the row height in pixels and over-scan the extra rows kept above and below the viewport.

<script setup>
const data = Array.from({ length: 2000 }, (_, index) => ({
  label: 'Folder ' + index,
  value: 'folder-' + index,
  children: [{ label: 'File ' + index, value: 'file-' + index }],
}))
</script>

<template>
  <PTree class="w-72" virtual :data="data" :height="240" :item-size="32" />
</template>

Controlled expansion

Bind expanded-keys to drive the expansion, the tree emits the next set on every change.

0 expanded

<script setup>
import { ref } from 'vue'

const expandedKeys = ref([])

const data = [
  { label: 'src', value: 'src', children: [{ label: 'tree', value: 'tree' }] },
]
</script>

<template>
  <PStack gap="2">
    <PTree
      :data="data"
      :expanded-keys="expandedKeys"
      class="w-72 max-h-64"
      @update:expanded-keys="expandedKeys = $event"
    />
    <PText size="sm">{{ expandedKeys.length }} expanded</PText>
  </PStack>
</template>

Keyboard

The tree handles its own keyboard: ArrowUp and ArrowDown move the active row, ArrowRight expands or enters a subtree, ArrowLeft collapses or jumps to the parent, Home and End jump to both ends, Enter and Space toggle the selection.

docs
<script setup>
const data = [
  { label: 'src', value: 'src', children: [{ label: 'tree', value: 'tree' }] },
  { label: 'docs', value: 'docs' },
]
</script>

<template>
  <PTree class="w-72 max-h-64" :data="data" />
</template>

Custom icons

Every icon is a slot, the built-in graphic is only the fallback. The switcher keeps its own click target and aria-expanded, a custom arrow stays clickable.

docs
<script setup>
const data = [
  { label: 'src', value: 'src', children: [{ label: 'tree', value: 'tree' }] },
  { label: 'docs', value: 'docs' },
]
</script>

<template>
  <PTree class="w-72 max-h-64" :data="data">
    <template #node-switcher="{ expanded }">
      <span class="text-xs">{{ expanded ? '-' : '+' }}</span>
    </template>

    <template #node-icon="{ node, expanded }">
      <span class="text-xs">{{ node.value === 'docs' ? '#' : expanded ? 'v' : '>' }}</span>
    </template>
  </PTree>
</template>

While searching, the tree keeps every hit and the ancestors it sits under, so a match deep in the tree stays reachable. Set search-matches-only when the ancestors are noise: the rows become the hits alone, flattened to the top level.

docs
<script setup>
const data = [
  {
    label: 'src',
    value: 'src',
    children: [
      { label: 'components', value: 'components' },
      { label: 'composables', value: 'composables' },
    ],
  },
  { label: 'docs', value: 'docs' },
]
</script>

<template>
<PTree
  class="w-72 max-h-64"
  filterable
  highlight-match
  search-matches-only
  :data="data"
/>
</template>

Restricting where a node may land

allow-drop replaces the built-in policy. The structural rules always hold - a node never lands on itself or inside its own subtree - but everything else is yours: the callback sees the dragged node, the row under the pointer and the position, and a false means no indicator is drawn.

docs
lock
<script setup>
import { ref } from 'vue'

const data = ref([
  { label: 'src', value: 'src', children: [{ label: 'tree', value: 'tree' }] },
  { label: 'docs', value: 'docs' },
  { label: 'lock', value: 'lock', disabled: true },
])

function allowDrop({ targetValue, position }) {
  if (targetValue === 'lock') {
    return false
  }

  return position !== 'after' || targetValue !== 'docs'
}
</script>

<template>
<PTree
  v-model:data="data"
  allow-drop="allowDrop"
  class="w-72 max-h-64"
  default-expanded-keys="['src']"
  draggable
  :data="data"
/>
</template>

Add node-drag-preview to replace the card that follows the pointer.

docs
<script setup>
import { ref } from 'vue'

const data = ref([
  { label: 'src', value: 'src', children: [{ label: 'tree', value: 'tree' }] },
  { label: 'docs', value: 'docs' },
])
</script>

<template>
<PTree v-model:data="data" class="w-72 max-h-64" draggable :data="data">
  <template #node-drag-preview="{ node }">
    <span class="text-foreground-secondary">{{ node.label }}</span>
  </template>
</PTree>
</template>

Drag and drop

Set draggable to reorder the tree. A drop never changes the tree on its own: it emits update:data with the next shape, so bind it with v-model:data (or .sync / a manual @update:data listener on Vue 2). Hovering a collapsed parent opens it after a moment, so a node can be dropped into a subtree that was not visible yet. Dragging is off while a search is active, because a query hides whole branches and a position inside the result would not mean the same thing in the full tree.

docs
<script setup>
import { ref } from 'vue'

const data = ref([
  {
    label: 'src',
    value: 'src',
    children: [
      { label: 'components', value: 'components' },
      { label: 'composables', value: 'composables' },
    ],
  },
  { label: 'docs', value: 'docs' },
])
</script>

<template>
  <PTree v-model:data="data" draggable class="w-72 max-h-64" />
</template>

Add the node-drag-handle slot to drag from a grip instead of the whole row. That is what keeps the gesture usable on touch, where a vertical drag and a scroll are the same movement.

::docs
<script setup>
import { ref } from 'vue'

const data = ref([
  { label: 'src', value: 'src', children: [{ label: 'tree', value: 'tree' }] },
  { label: 'docs', value: 'docs' },
])
</script>

<template>
<PTree v-model:data="data" draggable class="w-72 max-h-64">
  <template #node-drag-handle>
    <span class="text-xs text-foreground-secondary">::</span>
  </template>
</PTree>
</template>

Methods

A template ref reaches the expand and collapse helpers.

docs
<script setup>
import { ref } from 'vue'

const tree = ref()

const data = [
  { label: 'src', value: 'src', children: [{ label: 'tree', value: 'tree' }] },
  { label: 'docs', value: 'docs' },
]
</script>

<template>
  <PStack gap="2">
    <PTree ref="tree" class="w-72 max-h-64" :data="data" />

    <PButton size="sm" @click="tree?.expandAll()"> Expand all </PButton>
    <PButton size="sm" @click="tree?.collapseAll()"> Collapse all </PButton>
  </PStack>
</template>

Props

NameTypeDefaultDescription
dataTreeOptions() => []Tree data, see TreeOption
model-valueTreeModelValuenullSelected value, an array when multiple is set
multiplebooleanfalseShow a checkbox and cascade the selection through the parents
check-strictlybooleanfalseKeep every node independently selectable, without cascading and without the partial state
disabledbooleanfalseDisable the selection and the keyboard of the whole tree
expand-on-clickbooleantrueExpand a parent when its row is clicked, set it to false to select instead
draggablebooleanfalseAllow a row to be dragged to another position, off while searching or when disabled is set
allow-drop(info: TreeDropInfo) => booleandisabled nodesDecide whether a drop may land, overriding the built-in policy
show-iconbooleantrueShow the built-in folder icon on every row
search-matches-onlybooleanfalseWhile searching, render the hits alone instead of the hits plus their ancestors
virtualbooleanfalseEnable virtualized rendering for large trees
item-sizenumber32Row height in px when virtual is enabled
over-scannumber4Extra rows rendered outside the viewport when virtual is enabled
heightnumber | string-Height of the tree, required by virtual
indentnumber16Indentation added per depth level in px
expanded-keysComponentValue[]-Controlled expanded nodes
default-expanded-keysComponentValue[][]Expanded nodes before the user changes them
children-fieldstring'children'Field of a node holding its children
filterablebooleanfalseRender the search field
filter(node: TreeOption, query: string) => booleanfuzzy matchPredicate deciding whether a node matches
highlight-matchbooleanfalseMark the matched substring of the label. Only a contiguous run of characters is marked: a fuzzy hit that is not one still shows its row, just without the highlight
search-placeholderstring'Search'Placeholder of the search field
search-valuestring-Controlled search query
default-search-valuestring''Search query before the user types
item-classComponentClass-Class merged into every rendered row

TreeOption

NameTypeDescription
valuestring | numberIdentifier of the node, unique in the whole tree
labelstring | number | nullText of the node
childrenTreeOption[]Child nodes, read from children-field
disabledbooleanIgnore selection and skip the node while navigating
keywordsstring[]Extra terms matched by the built-in filter

Events

NameTypeDescription
update:modelValue(value: TreeModelValue) => voidEmitted when the selection changes.
update:data(data: TreeOptions) => voidEmitted when a drag is dropped, carrying the whole next tree.
move(detail: TreeMoveDetail) => voidEmitted when a drag is dropped, carrying where the node came from and landed.
change(detail: TreeChangeDetail) => voidEmitted when a node is checked or unchecked.
update:expandedKeys(keys: ComponentValue[]) => voidEmitted when a node is expanded or collapsed.
expand(detail: TreeNodeDetail) => voidEmitted when a node is expanded.
collapse(detail: TreeNodeDetail) => voidEmitted when a node is collapsed.
update:searchValue(query: string) => voidEmitted when the search field changes. Write it back through search-value to control the field.
interface TreeNodeDetail {
  value: string | number
  node: TreeOption
}

interface TreeChangeDetail extends TreeNodeDetail {
  checked: boolean
  checkedValues: (string | number)[]
  halfCheckedValues: (string | number)[]
}

interface TreeMoveLocation {
  parentValue?: string | number
  index: number
}

interface TreeMoveDetail {
  value: string | number
  node: TreeOption
  from: TreeMoveLocation
  to: TreeMoveLocation & {
    targetValue: string | number
    position: 'before' | 'inside' | 'after'
  }
}

Slots

NameDescription
node-switcherExpand arrow. Slot props: node, depth, expanded.
node-iconNode icon. Slot props: node, depth, expanded.
node-contentNode text. Slot props: node, depth.
node-suffixContent appended to the row. Slot props: node.
node-drag-handleGrip that starts a drag. Rendering it restricts dragging to the grip, leaving the row to click. Slot props: node, depth.
node-drag-previewCard that follows the pointer while dragging. Slot props: node.
searchReplaces the search field.
emptyContent shown when the tree has no row, or the search matched nothing.

Methods

NameTypeDescription
focus() => voidFocus the tree so it receives the keyboard.
scrollToKey(value: ComponentValue) => voidScroll the row of the node into view.
setActiveKey(value: ComponentValue) => voidMove the active row to the node.
expandKey(value: ComponentValue) => voidExpand the node.
collapseKey(value: ComponentValue) => voidCollapse the node.
expandAll() => voidExpand every parent node.
collapseAll() => voidCollapse every node.
getCheckedKeys(includeIndeterminate?: boolean) => ComponentValue[]Return the checked values in document order, optionally including the partially checked ones. Covers collapsed subtrees, the same set change reports.
getVisibleKeys() => ComponentValue[]Return the values of the rendered rows.
getData(value: ComponentValue) => TreeOption | undefinedReturn the source node of a key, collapsed rows included.
activeIndexnumberIndex of the active row, -1 when there is none.

Source

Source