Skip to content

Finder ​

Иерархический навигатор по колонкам, вдохновлённый Finder на macOS. Два режима: levels (фиксированная глубина через проп levels) и dynamic (рекурсивная/неизвестная глубина через проп load). Выбор элемента автоматически загружает и раскрывает следующую колонку. Листовые узлы открывают панель деталей с полной цепочкой выбора.

Detail Panel ​

При выборе элемента в последнем уровне справа открывается слот #detail. Слот получает выбранный item и полную цепочку selections — так что можно обратиться к любому родительскому ключу, чтобы подгрузить детали или открыть другой компонент.

Пройдите по иерархии: Departments → Teams → Members, затем кликните по участнику, чтобы увидеть его карточку профиля.

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 ​

Настраивайте отрисовку элементов для каждого уровня с помощью скопированных слотов или поля component в конфиге уровня. Приоритет: слот #item-{levelKey} → слот #item → поле component → метка по умолчанию.

Слоты со слот-пропсами — используйте #item-{levelKey} для конкретного уровня или #item для всех уровней:

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>

Компонент в конфиге уровня — передавайте Vue-компонент прямо в JSON:

vue
<script setup>
import MemberItem from '../../components/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 ​

Иерархия Регион → Город → Район. Выбор района открывает карточку с полным путём и идентификаторами всех родителей.

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

Выбор файла в последней колонке открывает предпросмотр с информацией о файле и полным путём к папке.

Recursive / Dynamic Depth (load prop) ​

Для иерархий с неизвестной глубиной — например, HR-системы, где филиалы могут быть вложены на любую глубину — используйте проп load вместо levels. Функция load получает кликнутый элемент (или null для корня) и полную цепочку выбора. Верните FinderColumnResult, чтобы добавить колонку, или null для листового узла (откроется панель деталей).

Пройдите по иерархии: Филиал → Филиал → Департамент → Сотрудник → Карточка пользователя. Филиалы могут содержать вложенные филиалы или департаменты на любой глубине.

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 ​

Включите showAdd, editable и deletable на уровнях, чтобы получить встроенные кнопки действий. Слушайте события @add, @edit, @delete, изменяйте свои данные и вызывайте ref.refresh(levelKey), чтобы перезагрузить колонку.

Нажмите + в заголовке, чтобы добавить элемент; при наведении на элемент появляется меню ... с действиями «Редактировать» и «Удалить».

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

Если проп levels не задан, используйте слот, чтобы вручную собрать VxFinderColumn и VxFinderItem.

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 ​

Слот #detail принимает два пропа:

PropTypeDescription
itemFinderItemDataВыбранный элемент последнего уровня (со всеми пользовательскими полями)
selectionsFinderSelection[]Полная цепочка выбора по всем уровням
ts
interface FinderSelection {
  levelKey: string        // Ключ уровня (например, 'departments')
  key: string | number    // Ключ выбранного элемента
  item: FinderItemData    // Полные данные элемента
}

// Доступ к любому родительскому ключу:
selections[0]?.key   // Выбранный ключ первого уровня
selections[1]?.key   // Выбранный ключ второго уровня
// ...

VxFinder Props ​

PropTypeDefaultDescription
levelsFinderLevel[][]Описания уровней с фиксированной глубиной. Если пусто, используется слотовый режим
load(item, selections) => FinderColumnResult | null—Функция динамической загрузки для рекурсивной/неизвестной глубины. Верните конфиг колонки или null для листа
heightstring'100%'Высота контейнера
gapnumber6Промежуток между колонками
collapsiblebooleantrueРазрешить сворачивать колонки
resizablebooleantrueРазрешить изменять размер колонок
minWidthnumber150Минимальная ширина колонки
maxWidthnumber500Максимальная ширина колонки
detailWidthnumber | string360Ширина панели деталей

VxFinder Events ​

EventPayloadDescription
select(levelKey, item)Выбран элемент
add(levelKey)Нажата кнопка добавления
edit(levelKey, item)Действие редактирования элемента
delete(levelKey, item)Действие удаления элемента

VxFinder Slots ​

SlotPropsDescription
default—Содержимое ручного режима
item{ item, active, levelKey }Пользовательский шаблон для элементов всех уровней
item-{ item, active, levelKey }Пользовательский шаблон для элементов конкретного уровня
detail{ item, selections }Панель, показываемая при выборе элемента последнего уровня

VxFinder Exposed ​

NameTypeDescription
refresh(key?)methodПерезагрузить одну или все колонки
selectionsFinderSelection[]Текущая цепочка выбора

VxFinderColumn Props ​

PropTypeDefaultDescription
titlestring''Заголовок колонки
collapsiblebooleantrueРазрешить сворачивание
collapsedbooleanfalseСостояние свёрнутости (v-model)
resizablebooleantrueРазрешить изменение размера
widthnumber260Начальная ширина (v-model)
minWidthnumber150Минимальная ширина
maxWidthnumber500Максимальная ширина
showAddbooleanfalseПоказывать кнопку добавления
loadingbooleanfalseПоказывать спиннер
filterablebooleanfalseПоказать поле поиска вверху колонки
filterPlaceholderstring'Search...'Плейсхолдер для поля фильтрации

VxFinderItem Props ​

PropTypeDefaultDescription
activebooleanfalseСостояние «выбрано»
disabledbooleanfalseСостояние «отключено»
status'default' | 'success' | 'warning' | 'danger'—Индикатор статуса
editablebooleanfalseПоказывать действие редактирования
deletablebooleanfalseПоказывать действие удаления