UIPackage
Menu

Tree Table

tree-table ui
Edit on GitHub

Hierarchical data table with expandable parent/child rows. Supports tree-structured data, column configuration, expand/collapse with per-level indent, row selection checkboxes, a loading overlay, and an empty state. Built on the existing table primitives.

Also available for React ->

Installation

$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/tree-table.json
Named registry: npx shadcn-vue@latest add @uipkge/tree-table Installs to: app/components/ui/tree-table/

Examples

Props

Name Type / Values Default Required
data

Tree-structured row data.

TreeTableRow<T>[] required
columns

Column configuration.

TreeTableColumn<T>[] required
indent

Indent per nesting level in pixels. Default 24.

number 24 optional
defaultExpanded

Expand all rows on mount. Default false.

boolean false optional
selectable

Show row selection checkboxes. Default false.

boolean false optional
loading

Loading state — shows a spinner overlay. Default false.

boolean false optional
emptyText

Empty state message. Default 'No data.'.

string 'No data.' optional
class HTMLAttributes['class'] optional

Schema

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

FlatRow
interface FlatRow {
  row: TreeTableRow<T>
  depth: number
  hasChildren: boolean
}

npm dependencies

Files installed (3)

  • app/components/ui/tree-table/TreeTable.vue 6.1 kB
    <script setup lang="ts" generic="T extends TreeTableRow">
    import { computed, onMounted, ref, watch, type HTMLAttributes } from 'vue'
    import { ChevronRight, FileBox } from 'lucide-vue-next'
    import { cn } from '@/lib/utils'
    import { Checkbox } from '@/components/ui/checkbox'
    import { Spinner } from '@/components/ui/spinner'
    import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
    import type { TreeTableColumn, TreeTableRow } from './types'
    
    interface Props {
      /** Tree-structured row data. */
      data: TreeTableRow<T>[]
      /** Column configuration. */
      columns: TreeTableColumn<T>[]
      /** Indent per nesting level in pixels. Default 24. */
      indent?: number
      /** Expand all rows on mount. Default false. */
      defaultExpanded?: boolean
      /** Show row selection checkboxes. Default false. */
      selectable?: boolean
      /** Loading state — shows a spinner overlay. Default false. */
      loading?: boolean
      /** Empty state message. Default 'No data.'. */
      emptyText?: string
      class?: HTMLAttributes['class']
    }
    
    const props = withDefaults(defineProps<Props>(), {
      indent: 24,
      defaultExpanded: false,
      selectable: false,
      loading: false,
      emptyText: 'No data.',
    })
    
    const emit = defineEmits<{
      'update:selected': [ids: string[]]
      select: [ids: string[]]
      expand: [id: string, expanded: boolean]
    }>()
    
    const expanded = ref<Set<string>>(new Set())
    const selected = ref<Set<string>>(new Set())
    
    interface FlatRow {
      row: TreeTableRow<T>
      depth: number
      hasChildren: boolean
    }
    
    const flatRows = computed<FlatRow[]>(() => {
      const out: FlatRow[] = []
      const walk = (rows: TreeTableRow<T>[], depth: number) => {
        for (const row of rows) {
          const hasChildren = !!row.children?.length
          out.push({ row, depth, hasChildren })
          if (hasChildren && expanded.value.has(row.id)) {
            walk(row.children as TreeTableRow<T>[], depth + 1)
          }
        }
      }
      walk(props.data, 0)
      return out
    })
    
    function toggleExpand(row: TreeTableRow<T>) {
      const next = new Set(expanded.value)
      if (next.has(row.id)) next.delete(row.id)
      else next.add(row.id)
      expanded.value = next
      emit('expand', row.id, next.has(row.id))
    }
    
    function toggleSelect(row: TreeTableRow<T>) {
      const next = new Set(selected.value)
      if (next.has(row.id)) next.delete(row.id)
      else next.add(row.id)
      selected.value = next
      const ids = [...next]
      emit('update:selected', ids)
      emit('select', ids)
    }
    
    function isSelected(id: string): boolean {
      return selected.value.has(id)
    }
    
    function isExpanded(id: string): boolean {
      return expanded.value.has(id)
    }
    
    function expandAll() {
      const next = new Set<string>()
      const walk = (rows: TreeTableRow<T>[]) => {
        for (const row of rows) {
          if (row.children?.length) {
            next.add(row.id)
            walk(row.children as TreeTableRow<T>[])
          }
        }
      }
      walk(props.data)
      expanded.value = next
    }
    
    function collapseAll() {
      expanded.value = new Set()
    }
    
    onMounted(() => {
      if (props.defaultExpanded) expandAll()
    })
    
    // Reset expanded/selected state when data identity changes.
    watch(
      () => props.data,
      () => {
        if (props.defaultExpanded) expandAll()
      },
      { deep: false },
    )
    
    const isEmpty = computed(() => flatRows.value.length === 0)
    </script>
    
    <template>
      <div data-uipkge data-slot="tree-table" :class="cn('relative w-full', props.class)">
        <Table>
          <TableHeader>
            <TableRow>
              <TableHead v-if="selectable" class="w-10">
                <span class="sr-only">Select</span>
              </TableHead>
              <TableHead v-for="col in columns" :key="col.key" :class="cn(col.headerClass)">
                {{ col.label }}
              </TableHead>
            </TableRow>
          </TableHeader>
          <TableBody>
            <TableRow
              v-for="fr in flatRows"
              :key="fr.row.id"
              :data-depth="fr.depth"
              :data-expanded="fr.hasChildren ? isExpanded(fr.row.id) : undefined"
              :data-selected="isSelected(fr.row.id) ? '' : undefined"
            >
              <!-- Selection checkbox -->
              <TableCell v-if="selectable" class="w-10">
                <Checkbox :model-value="isSelected(fr.row.id)" @update:model-value="toggleSelect(fr.row)" />
              </TableCell>
    
              <!-- Data cells -->
              <TableCell v-for="(col, ci) in columns" :key="col.key" :class="cn(ci === 0 && 'font-medium', col.cellClass)">
                <div class="flex items-center" :style="ci === 0 ? { paddingLeft: `${fr.depth * indent}px` } : {}">
                  <!-- Expand toggle on the first column -->
                  <button
                    v-if="ci === 0 && fr.hasChildren"
                    type="button"
                    class="text-muted-foreground hover:bg-muted hover:text-foreground mr-1.5 flex size-5 shrink-0 items-center justify-center rounded"
                    :aria-label="isExpanded(fr.row.id) ? 'Collapse' : 'Expand'"
                    :aria-expanded="isExpanded(fr.row.id)"
                    @click="toggleExpand(fr.row)"
                  >
                    <slot name="expand-icon" :expanded="isExpanded(fr.row.id)">
                      <ChevronRight
                        class="size-4 transition-transform duration-150"
                        :class="isExpanded(fr.row.id) ? 'rotate-90' : ''"
                      />
                    </slot>
                  </button>
                  <span v-else-if="ci === 0" class="mr-1.5 w-5 shrink-0" />
    
                  <slot :name="`cell-${col.key}`" :row="fr.row" :depth="fr.depth">
                    {{ col.render ? col.render(fr.row as T) : fr.row[col.key] }}
                  </slot>
                </div>
              </TableCell>
            </TableRow>
    
            <!-- Empty state -->
            <TableRow v-if="isEmpty && !loading">
              <TableCell :colspan="columns.length + (selectable ? 1 : 0)" class="h-24 text-center">
                <div class="text-muted-foreground flex flex-col items-center gap-2">
                  <FileBox class="size-8" />
                  <span class="text-sm">{{ emptyText }}</span>
                </div>
              </TableCell>
            </TableRow>
          </TableBody>
        </Table>
    
        <!-- Loading overlay -->
        <div v-if="loading" class="bg-background/60 absolute inset-0 flex items-center justify-center backdrop-blur-sm">
          <Spinner size="lg" />
        </div>
      </div>
    </template>
  • app/components/ui/tree-table/types.ts 0.6 kB
    export interface TreeTableColumn<T = any> {
      /** Unique key matching a field on the row data. */
      key: string
      /** Header label. */
      label: string
      /** Optional class for the header cell. */
      headerClass?: string
      /** Optional class for body cells in this column. */
      cellClass?: string
      /** Custom cell renderer: receives the row and returns a string or VNode-friendly value. */
      render?: (row: T) => any
    }
    
    export interface TreeTableRow<T = any> {
      /** Unique id for the row. */
      id: string
      /** Row data fields keyed by column key. */
      [key: string]: any
      /** Child rows. */
      children?: TreeTableRow<T>[]
    }
  • app/components/ui/tree-table/index.ts 0.1 kB
    export { default as TreeTable } from './TreeTable.vue'
    export type { TreeTableColumn, TreeTableRow } from './types'

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