Finder
A column-based hierarchical navigator inspired by macOS Finder. Two modes: levels (fixed depth with levels prop) or dynamic (recursive/unknown depth with load prop). Selecting an item automatically loads and reveals the next column. Leaf nodes open a detail panel with the full selection chain.
Detail Panel
When you select an item in the last level, a #detail slot opens to the right. The slot receives the selected item and the full selections chain — so you can access any parent key to fetch details or open another component.
Drill down: Departments → Teams → Members then click a member to see their profile card.
<template>
<VxFinder
:levels="levels"
height="400px"
:detail-width="320"
>
<template #detail="{ item, selections }">
<!-- item = the last-level selected item (with all your custom fields) -->
<!-- selections = full chain: [{ levelKey, key, item }, ...] -->
<MemberProfile
:id="item.key"
:department-id="selections[0]?.key"
:team-id="selections[1]?.key"
/>
</template>
</VxFinder>
</template>
<script setup>
const levels = [
{
key: 'departments',
title: 'Departments',
showAdd: true,
load: () => fetchDepartments(),
},
{
key: 'teams',
title: 'Teams',
showAdd: true,
load: (deptId) => fetchTeams(deptId),
},
{
key: 'members',
title: 'Members',
// This is the last level — selecting opens #detail
load: (teamId) => fetchMembers(teamId),
},
]
</script>Custom Item Templates
Customize how items render per-level using scoped slots or a component in the level config. Priority: #item-{levelKey} slot → #item slot → component field → default label.
Scoped slots — use #item-{levelKey} for a specific level, or #item for all levels:
<template>
<VxFinder :levels="levels">
<!-- Custom render for 'members' level only -->
<template #item-members="{ item, active }">
<div class="member-row">
<Avatar :name="item.label" />
<span>{{ item.label }}</span>
<VxBadge>{{ item.role }}</VxBadge>
</div>
</template>
<!-- Fallback for all other levels -->
<template #item="{ item, levelKey }">
<span>{{ item.label }}</span>
</template>
</VxFinder>
</template>Component in level config — pass a Vue component directly in the JSON:
<script setup>
import MemberItem from './MemberItem.vue'
const levels = [
{
key: 'departments',
title: 'Departments',
load: () => fetchDepartments(),
},
{
key: 'members',
title: 'Members',
// Custom component receives { item, active, levelKey } props
component: MemberItem,
load: (deptId) => fetchMembers(deptId),
},
]
</script><!-- MemberItem.vue -->
<script setup>
defineProps({ item: Object, active: Boolean })
</script>
<template>
<div class="member-row">
<img :src="item.avatar" class="member-avatar" />
<div>
<div>{{ item.label }}</div>
<small>{{ item.role }}</small>
</div>
</div>
</template>Async Loading with Detail
Region → City → District hierarchy. Selecting a district opens a detail card showing the full path and all parent IDs.
<template>
<VxFinder :levels="geoLevels" :detail-width="280">
<template #detail="{ item, selections }">
<!-- Use selections to get parent IDs -->
<DistrictDetail
:id="item.key"
:region-id="selections[0]?.key"
:city-id="selections[1]?.key"
/>
</template>
</VxFinder>
</template>File Browser (Sync + Detail)
Selecting a file in the last column opens a preview with file info and the full folder path.
Recursive / Dynamic Depth (load prop)
For hierarchies with unknown depth — like an HR system where branches can nest indefinitely — use the load prop instead of levels. The load function receives the clicked item (or null for root) and the full selection chain. Return a FinderColumnResult to add a column, or null for a leaf node (opens detail panel).
Drill down: Branch → Branch → Department → Employee → User Card. Branches can contain sub-branches or departments at any depth.
<template>
<VxFinder :load="hrLoad" height="420px" :detail-width="300">
<template #detail="{ item, selections }">
<EmployeeCard :employee="item" :path="selections" />
</template>
</VxFinder>
</template>
<script setup>
async function hrLoad(item, selections) {
if (!item) {
// Root: fetch top-level branches
const branches = await api.getBranches()
return { key: 'branches', title: 'Branches', items: branches, filterable: true }
}
if (item.type === 'branch') {
// Branch: may contain sub-branches + departments
const children = await api.getBranchChildren(item.key)
return { key: `sub-${item.key}`, title: item.label, items: children, filterable: true }
}
if (item.type === 'department') {
// Department: show employees
const employees = await api.getEmployees(item.key)
return { key: `staff-${item.key}`, title: `${item.label} Staff`, items: employees }
}
// Employee: leaf node → opens #detail panel
return null
}
</script>Add / Edit / Delete
Enable showAdd, editable, and deletable on levels to get built-in action buttons. Listen to @add, @edit, @delete events, mutate your data, then call ref.refresh(levelKey) to reload that column.
Click + in the header to add, hover an item for the ... menu with Edit and Delete.
<template>
<VxFinder
ref="finderRef"
:levels="levels"
@add="onAdd"
@edit="onEdit"
@delete="onDelete"
/>
</template>
<script setup>
import { ref, computed } from 'vue'
const finderRef = ref(null)
const categories = ref([
{ key: 'cat-1', label: 'Frontend' },
{ key: 'cat-2', label: 'Backend' },
])
const items = ref({
'cat-1': [
{ key: 'item-1', label: 'Vue.js', status: 'success' },
{ key: 'item-2', label: 'React', status: 'success' },
],
'cat-2': [
{ key: 'item-3', label: 'Node.js', status: 'success' },
],
})
let nextId = 100
const levels = computed(() => [
{
key: 'categories',
title: 'Categories',
showAdd: true, // "+" button in header
editable: true, // edit action on hover menu
deletable: true, // delete action on hover menu
filterable: true,
load: () => categories.value,
},
{
key: 'items',
title: 'Items',
showAdd: true,
editable: true,
deletable: true,
filterable: true,
load: (parentKey) => items.value[parentKey] || [],
},
])
function onAdd(levelKey) {
const name = prompt(`New ${levelKey}:`)
if (!name) return
const id = `new-${++nextId}`
if (levelKey === 'categories') {
categories.value.push({ key: id, label: name })
} else {
const parentKey = finderRef.value?.selections[0]?.key
if (!parentKey) return
if (!items.value[parentKey]) items.value[parentKey] = []
items.value[parentKey].push({ key: id, label: name, status: 'success' })
}
// Refresh the column to show new data
finderRef.value?.refresh(levelKey)
}
function onEdit(levelKey, item) {
const name = prompt(`Rename "${item.label}":`, item.label)
if (!name || name === item.label) return
if (levelKey === 'categories') {
const cat = categories.value.find(c => c.key === item.key)
if (cat) cat.label = name
} else {
for (const arr of Object.values(items.value)) {
const found = arr.find(i => i.key === item.key)
if (found) { found.label = name; break }
}
}
finderRef.value?.refresh(levelKey)
}
function onDelete(levelKey, item) {
if (!confirm(`Delete "${item.label}"?`)) return
if (levelKey === 'categories') {
categories.value = categories.value.filter(c => c.key !== item.key)
delete items.value[item.key]
} else {
for (const arr of Object.values(items.value)) {
const idx = arr.findIndex(i => i.key === item.key)
if (idx !== -1) { arr.splice(idx, 1); break }
}
}
finderRef.value?.refresh(levelKey)
}
</script>Slot Mode (Manual)
If no levels prop is given, use the slot to compose VxFinderColumn and VxFinderItem manually.
<template>
<VxFinder height="250px">
<VxFinderColumn title="Fruits" :width="200">
<VxFinderItem
v-for="f in fruits"
:key="f.id"
:active="selected === f.id"
@click="selected = f.id"
>{{ f.name }}</VxFinderItem>
</VxFinderColumn>
<VxFinderColumn v-if="children.length" title="Colors">
<VxFinderItem v-for="c in children" :key="c.id" :status="c.status">
{{ c.name }}
</VxFinderItem>
</VxFinderColumn>
</VxFinder>
</template>Detail Slot API
The #detail slot receives two props:
| Prop | Type | Description |
|---|---|---|
item | FinderItemData | The selected item from the last level (with all custom fields) |
selections | FinderSelection[] | Full chain of selections from each level |
interface FinderSelection {
levelKey: string // Level key (e.g. 'departments')
key: string | number // Selected item's key
item: FinderItemData // Full item data
}
// Access any parent key:
selections[0]?.key // First level's selected key
selections[1]?.key // Second level's selected key
// ...VxFinder Props
| Prop | Type | Default | Description |
|---|---|---|---|
| levels | FinderLevel[] | [] | Level definitions with fixed depth. If empty, uses slot mode |
| load | (item, selections) => FinderColumnResult | null | — | Dynamic load function for recursive/unknown depth. Return column config or null for leaf |
| height | string | '100%' | Container height |
| gap | number | 6 | Gap between columns |
| collapsible | boolean | true | Allow columns to collapse |
| resizable | boolean | true | Allow columns to resize |
| minWidth | number | 150 | Min column width |
| maxWidth | number | 500 | Max column width |
| detailWidth | number | string | 360 | Width of the detail panel |
VxFinder Events
| Event | Payload | Description |
|---|---|---|
| select | (levelKey, item) | Item selected |
| add | (levelKey) | Add button clicked |
| edit | (levelKey, item) | Edit action on item |
| delete | (levelKey, item) | Delete action on item |
VxFinder Slots
| Slot | Props | Description |
|---|---|---|
| default | — | Manual mode content |
| item | { item, active, levelKey } | Custom template for all level items |
| item- | { item, active, levelKey } | Custom template for a specific level's items |
| detail | { item, selections } | Panel shown when last level item is selected |
VxFinder Exposed
| Name | Type | Description |
|---|---|---|
refresh(key?) | method | Reload a column or all |
selections | FinderSelection[] | Current selection chain |
VxFinderColumn Props
| Prop | Type | Default | Description |
|---|---|---|---|
| title | string | '' | Header title |
| collapsible | boolean | true | Allow collapse |
| collapsed | boolean | false | Collapsed state (v-model) |
| resizable | boolean | true | Allow resize |
| width | number | 260 | Initial width (v-model) |
| minWidth | number | 150 | Min width |
| maxWidth | number | 500 | Max width |
| showAdd | boolean | false | Show add button |
| loading | boolean | false | Show spinner |
| filterable | boolean | false | Show search input at top of column |
| filterPlaceholder | string | 'Search...' | Placeholder text for the filter input |
VxFinderItem Props
| Prop | Type | Default | Description |
|---|---|---|---|
| active | boolean | false | Selected state |
| disabled | boolean | false | Disabled state |
| status | 'default' | 'success' | 'warning' | 'danger' | — | Status dot |
| editable | boolean | false | Show edit action |
| deletable | boolean | false | Show delete action |