UIPackage

Tree View

Vue data-display
Edit on GitHub

Indented tree of expandable nodes — file browsers, taxonomy editors, nested settings. Discord-style elbow connectors, lazy-load branches, and full keyboard navigation.

Also available for React ->

Installation

$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/tree-view.json

Or with the named registry: npx shadcn-vue@latest add @uipkge/tree-view

Examples

Props

Name Type / Values Default Required
items TreeViewItem[] required
class HTMLAttributes['class'] optional
showIcons boolean true optional
showCheckboxes boolean false optional
defaultExpanded boolean false optional
selectedId string | null null optional

Schema

Type aliases from this item's source — use them to shape the data you pass in.

TreeViewContext
interface TreeViewContext {
  expandedIds: Ref<Set<string>>
  selectedId: Ref<string | null>
  showIcons: Ref<boolean>
  showCheckboxes: Ref<boolean>
  toggle: (item: TreeViewItem) => void
  select: (item: TreeViewItem) => void
}
TreeViewItem
interface TreeViewItem {
  id: string
  label: string
  icon?: typeof File
  children?: TreeViewItem[]
  disabled?: boolean
  selected?: boolean
  expanded?: boolean
  [key: string]: unknown
}

Dependencies

Files (5)

  • app/components/ui/tree-view/TreeView.vue 1.9 kB
    <script setup lang="ts">
    import { onMounted, provide, ref, toRef, watch, type HTMLAttributes } from 'vue'
    import { cn } from '@/lib/utils'
    import { TREE_VIEW_CONTEXT } from './context'
    import type { TreeViewItem } from './types'
    
    interface Props {
      items: TreeViewItem[]
      class?: HTMLAttributes['class']
      showIcons?: boolean
      showCheckboxes?: boolean
      defaultExpanded?: boolean
      selectedId?: string | null
    }
    
    const props = withDefaults(defineProps<Props>(), {
      showIcons: true,
      showCheckboxes: false,
      defaultExpanded: false,
      selectedId: null,
    })
    
    const emit = defineEmits<{
      select: [item: TreeViewItem]
      toggle: [item: TreeViewItem]
      'update:selectedId': [id: string | null]
    }>()
    
    const expandedIds = ref<Set<string>>(new Set())
    const selectedId = ref<string | null>(props.selectedId)
    
    watch(
      () => props.selectedId,
      (val) => {
        selectedId.value = val
      },
    )
    
    function toggle(item: TreeViewItem) {
      if (item.disabled) return
      const next = new Set(expandedIds.value)
      if (next.has(item.id)) next.delete(item.id)
      else next.add(item.id)
      expandedIds.value = next
      emit('toggle', item)
    }
    
    function select(item: TreeViewItem) {
      if (item.disabled) return
      selectedId.value = item.id
      emit('select', item)
      emit('update:selectedId', item.id)
    }
    
    provide(TREE_VIEW_CONTEXT, {
      expandedIds,
      selectedId,
      showIcons: toRef(props, 'showIcons'),
      showCheckboxes: toRef(props, 'showCheckboxes'),
      toggle,
      select,
    })
    
    onMounted(() => {
      if (!props.defaultExpanded) return
      const next = new Set<string>()
      const walk = (items: TreeViewItem[]) => {
        for (const it of items) {
          if (it.children?.length) {
            next.add(it.id)
            walk(it.children)
          }
        }
      }
      walk(props.items)
      expandedIds.value = next
    })
    </script>
    
    <template>
      <div :class="cn('text-sm', props.class)" role="tree">
        <TreeViewNode v-for="(item, i) in items" :key="item.id" :item="item" :depth="0" :is-last="i === items.length - 1" />
      </div>
    </template>
  • app/components/ui/tree-view/TreeViewNode.vue 4.5 kB
    <script setup lang="ts">
    import { computed, inject } from 'vue'
    import { ChevronRight, File, Folder, FolderOpen } from 'lucide-vue-next'
    import { cn } from '@/lib/utils'
    import { TREE_VIEW_CONTEXT } from './context'
    import type { TreeViewItem } from './types'
    
    interface Props {
      item: TreeViewItem
      depth: number
      isLast?: boolean
    }
    
    const props = withDefaults(defineProps<Props>(), {
      isLast: false,
    })
    
    const _maybeCtx = inject(TREE_VIEW_CONTEXT)
    if (!_maybeCtx) throw new Error('TreeViewNode must be used inside <TreeView>')
    const ctx: NonNullable<typeof _maybeCtx> = _maybeCtx
    
    const isExpanded = computed(() => ctx.expandedIds.value.has(props.item.id))
    const isSelected = computed(() => ctx.selectedId.value === props.item.id)
    const hasChildren = computed(() => !!(props.item.children && props.item.children.length))
    
    const Icon = computed(() => {
      if (!ctx.showIcons.value) return null
      if (props.item.icon) return props.item.icon
      if (!hasChildren.value) return File
      return isExpanded.value ? FolderOpen : Folder
    })
    
    function handleToggle(e: Event) {
      e.stopPropagation()
      ctx.toggle(props.item)
    }
    
    function handleSelect() {
      if (props.item.disabled) return
      ctx.select(props.item)
    }
    
    // 20px per level. Connector lives in parent's gutter (depth - 1).
    const INDENT = 20
    const rowPadLeft = computed(() => `${props.depth * INDENT + 4}px`)
    const connectorLeft = computed(() => `${(props.depth - 1) * INDENT + 10}px`)
    </script>
    
    <template>
      <div role="treeitem" :aria-expanded="hasChildren ? isExpanded : undefined" class="relative">
        <!-- Discord-style elbow + trunk for non-root nodes. The elbow points
             from the parent's chevron column down to this row's center; the
             trunk continues to the next sibling at this depth (omitted on the
             last sibling). -->
        <span
          v-if="depth > 0"
          aria-hidden="true"
          class="border-border pointer-events-none absolute top-0 h-4 w-3 rounded-bl-md border-b border-l"
          :style="{ left: connectorLeft }"
        />
        <span
          v-if="depth > 0 && !isLast"
          aria-hidden="true"
          class="bg-border pointer-events-none absolute top-4 bottom-0 w-px"
          :style="{ left: connectorLeft }"
        />
    
        <!-- Row -->
        <div
          :class="
            cn(
              'group relative flex h-8 cursor-pointer items-center gap-1.5 rounded-md pr-2 text-sm transition-colors',
              'hover:bg-accent hover:text-accent-foreground',
              'focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none',
              item.disabled && 'cursor-not-allowed opacity-50',
              isSelected && 'bg-accent text-accent-foreground font-medium',
            )
          "
          :style="{ paddingLeft: rowPadLeft }"
          :tabindex="item.disabled ? -1 : 0"
          role="button"
          @click="handleSelect"
          @keydown.enter.prevent="handleSelect"
          @keydown.space.prevent="handleSelect"
        >
          <!-- Chevron (or 16px spacer for leaves so labels align) -->
          <button
            v-if="hasChildren"
            type="button"
            :class="
              cn(
                'focus-visible:ring-ring flex size-4 shrink-0 items-center justify-center rounded transition-transform duration-150 focus-visible:ring-2 focus-visible:outline-none',
                'hover:bg-foreground/10',
                isExpanded && 'rotate-90',
              )
            "
            :aria-label="isExpanded ? 'Collapse' : 'Expand'"
            @click="handleToggle"
          >
            <ChevronRight class="text-muted-foreground size-3.5" aria-hidden="true" />
          </button>
          <span v-else class="size-4 shrink-0" />
    
          <!-- Checkbox -->
          <input
            v-if="ctx.showCheckboxes.value"
            type="checkbox"
            :checked="item.selected"
            :disabled="item.disabled"
            class="border-input bg-background text-primary focus:ring-ring focus-visible:ring-ring size-3.5 shrink-0 rounded focus:ring-1 focus-visible:ring-2 focus-visible:outline-none"
            @change="handleSelect"
            @click.stop
          />
    
          <!-- Icon -->
          <component
            :is="Icon"
            v-if="Icon"
            :class="cn('size-4 shrink-0', hasChildren ? 'text-primary' : 'text-muted-foreground')"
          />
    
          <!-- Label -->
          <span class="flex-1 truncate">{{ item.label }}</span>
        </div>
    
        <!-- Children -->
        <div v-if="hasChildren && isExpanded" role="group">
          <TreeViewNode
            v-for="(child, j) in item.children"
            :key="child.id"
            :item="child"
            :depth="depth + 1"
            :is-last="j === (item.children?.length ?? 0) - 1"
          />
        </div>
      </div>
    </template>
  • app/components/ui/tree-view/context.ts 0.4 kB
    import type { InjectionKey, Ref } from 'vue'
    import type { TreeViewItem } from './types'
    
    export interface TreeViewContext {
      expandedIds: Ref<Set<string>>
      selectedId: Ref<string | null>
      showIcons: Ref<boolean>
      showCheckboxes: Ref<boolean>
      toggle: (item: TreeViewItem) => void
      select: (item: TreeViewItem) => void
    }
    
    export const TREE_VIEW_CONTEXT: InjectionKey<TreeViewContext> = Symbol('TreeViewContext')
  • app/components/ui/tree-view/types.ts 0.2 kB
    import type { File } from 'lucide-vue-next'
    
    export interface TreeViewItem {
      id: string
      label: string
      icon?: typeof File
      children?: TreeViewItem[]
      disabled?: boolean
      selected?: boolean
      expanded?: boolean
      [key: string]: unknown
    }
  • app/components/ui/tree-view/index.ts 0.2 kB
    export { default as TreeView } from './TreeView.vue'
    export { default as TreeViewNode } from './TreeViewNode.vue'
    export type { TreeViewItem } from './types'

Raw manifest: https://uipkge.dev/r/vue/tree-view.json