Tree Table
tree-table ui 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
$ pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/tree-table.json $ npx shadcn-vue@latest add https://uipkge.dev/r/vue/tree-table.json $ yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/tree-table.json $ bunx 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