Skip to content

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.

vue
<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:

vue
<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:

vue
<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>
vue
<!-- 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.

vue
<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.

vue
<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.

vue
<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.

Fruits
Apple
Banana
Cherry
Date
vue
<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:

PropTypeDescription
itemFinderItemDataThe selected item from the last level (with all custom fields)
selectionsFinderSelection[]Full chain of selections from each level
ts
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 ​

PropTypeDefaultDescription
levelsFinderLevel[][]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
heightstring'100%'Container height
gapnumber6Gap between columns
collapsiblebooleantrueAllow columns to collapse
resizablebooleantrueAllow columns to resize
minWidthnumber150Min column width
maxWidthnumber500Max column width
detailWidthnumber | string360Width of the detail panel

VxFinder Events ​

EventPayloadDescription
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 ​

SlotPropsDescription
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 ​

NameTypeDescription
refresh(key?)methodReload a column or all
selectionsFinderSelection[]Current selection chain

VxFinderColumn Props ​

PropTypeDefaultDescription
titlestring''Header title
collapsiblebooleantrueAllow collapse
collapsedbooleanfalseCollapsed state (v-model)
resizablebooleantrueAllow resize
widthnumber260Initial width (v-model)
minWidthnumber150Min width
maxWidthnumber500Max width
showAddbooleanfalseShow add button
loadingbooleanfalseShow spinner
filterablebooleanfalseShow search input at top of column
filterPlaceholderstring'Search...'Placeholder text for the filter input

VxFinderItem Props ​

PropTypeDefaultDescription
activebooleanfalseSelected state
disabledbooleanfalseDisabled state
status'default' | 'success' | 'warning' | 'danger'—Status dot
editablebooleanfalseShow edit action
deletablebooleanfalseShow delete action