reui

Tree

Tree — кастомный компонент, портированный из ReUI (keenthemes/reui, MIT).

Загрузка превью…

src/reui/tree/Tree.vue

<script setup lang="ts">
/**
 * Порт ReUI Tree (registry-reui/bases/radix/reui/tree.tsx, MIT).
 *
 * `tree` — инстанс `@headless-tree/core`, созданный композаблом `useTree`
 * (см. useTree.ts) — Vue-адаптацией `@headless-tree/react`. Сам компонент
 * ниже переносит оригинал буква в букву: он лишь читает `tree.getContainerProps()`
 * и мержит стили/атрибуты, не завязываясь на React.
 */
import type { CSSProperties, HTMLAttributes } from "vue"
import { computed, provide, ref, useAttrs } from "vue"
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
import { TreeContextKey, type ToggleIconType } from "./context"
import { useHeadlessTreeRef } from "./useHeadlessTreeRef"

defineOptions({ inheritAttrs: false })

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes["class"]
    indent?: number
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    tree?: any
    toggleIconType?: ToggleIconType
    asChild?: boolean
  }>(),
  {
    indent: 20,
    toggleIconType: "chevron",
    asChild: false,
  }
)

const attrs = useAttrs()

const containerProps = computed<Record<string, unknown>>(() =>
  props.tree && typeof props.tree.getContainerProps === "function"
    ? props.tree.getContainerProps()
    : {}
)

// mergedProps = { ...props(остальные атрибуты), ...containerProps }
const mergedProps = computed(() => ({ ...attrs, ...containerProps.value }))

const propStyle = computed(
  () => (mergedProps.value as { style?: CSSProperties }).style
)

const otherProps = computed(() => {
  const { style: _style, ref: _ref, ...rest } = mergedProps.value as Record<
    string,
    unknown
  >
  return rest
})

const mergedStyle = computed<CSSProperties>(() => ({
  ...propStyle.value,
  "--tree-indent": `${props.indent}px`,
}))

provide(TreeContextKey, {
  indent: props.indent,
  tree: props.tree,
  toggleIconType: props.toggleIconType,
})

// См. useHeadlessTreeRef.ts: колбэк-реф ядра подключаем к реальному
// DOM-узлу вручную, а не через v-bind.
const elRef = ref<HTMLElement | null>(null)
useHeadlessTreeRef(elRef, () => (mergedProps.value as { ref?: unknown }).ref)
</script>

<template>
  <div
    v-if="!asChild"
    ref="elRef"
    data-slot="tree"
    :style="mergedStyle"
    :class="cn('flex flex-col', props.class)"
    v-bind="otherProps"
  >
    <slot />
  </div>
  <Primitive
    v-else
    data-slot="tree"
    as="div"
    as-child
    :style="mergedStyle"
    :class="cn('flex flex-col', props.class)"
    v-bind="otherProps"
  >
    <slot />
  </Primitive>
</template>

src/reui/tree/TreeDragLine.vue

<script setup lang="ts">
/**
 * Порт ReUI TreeDragLine (registry-reui/bases/radix/reui/tree.tsx, MIT).
 */
import type { HTMLAttributes } from "vue"
import { computed, useAttrs, watchEffect } from "vue"
import { cn } from "@/lib/utils"
import { useTreeContextInject } from "./useTreeContextInject"

defineOptions({ inheritAttrs: false })

const props = defineProps<{
  class?: HTMLAttributes["class"]
}>()

const attrs = useAttrs()
const { tree } = useTreeContextInject()

const hasDragLine = computed(
  () => !!tree && typeof tree.getDragLineStyle === "function"
)

watchEffect(() => {
  if (!hasDragLine.value) {
    // eslint-disable-next-line no-console
    console.warn(
      "TreeDragLine: No tree provided via context or tree does not have getDragLineStyle method"
    )
  }
})

const dragLine = computed(() =>
  hasDragLine.value ? tree.getDragLineStyle() : undefined
)
</script>

<template>
  <div
    v-if="hasDragLine"
    :style="dragLine"
    :class="
      cn(
        'bg-primary before:bg-background before:border-primary absolute z-30 -mt-px h-0.5 w-[unset] before:absolute before:-top-[3px] before:left-0 before:size-2 before:border-2',
        'before:rounded-full',
        props.class
      )
    "
    v-bind="attrs"
  />
</template>

src/reui/tree/TreeItem.vue

<script setup lang="ts">
/**
 * Порт ReUI TreeItem (registry-reui/bases/radix/reui/tree.tsx, MIT).
 *
 * `item` — `ItemInstance` из `@headless-tree/core`, framework-agnostic:
 * `item.getProps()`, `item.isFocused()` и т.д. читают состояние напрямую
 * из ядра, без React-специфичной обвязки, поэтому переносятся как есть.
 */
import type { CSSProperties, HTMLAttributes } from "vue"
import { computed, provide, ref, useAttrs } from "vue"
import { Primitive } from "reka-ui"
import type { ItemInstance } from "@headless-tree/core"
import { cn } from "@/lib/utils"
import { TreeContextKey } from "./context"
import { useTreeContextInject } from "./useTreeContextInject"
import { useHeadlessTreeRef } from "./useHeadlessTreeRef"

defineOptions({ inheritAttrs: false })

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes["class"]
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    item: ItemInstance<any>
    asChild?: boolean
  }>(),
  {
    asChild: false,
  }
)

const attrs = useAttrs()
const parentContext = useTreeContextInject()
const indent = computed(() => parentContext.indent)

const itemProps = computed<Record<string, unknown>>(() =>
  typeof props.item.getProps === "function" ? props.item.getProps() : {}
)

// mergedProps = { ...props(остальные атрибуты), children, ...itemProps }
const mergedProps = computed(() => ({ ...attrs, ...itemProps.value }))

const propStyle = computed(
  () => (mergedProps.value as { style?: CSSProperties }).style
)

const otherProps = computed(() => {
  const { style: _style, ref: _ref, ...rest } = mergedProps.value as Record<
    string,
    unknown
  >
  return rest
})

const mergedStyle = computed<CSSProperties>(() => ({
  ...propStyle.value,
  "--tree-padding": `${props.item.getItemMeta().level * indent.value}px`,
}))

const defaultProps = computed(() => ({
  "data-slot": "tree-item",
  style: mergedStyle.value,
  class: cn(
    "z-10 ps-(--tree-padding) outline-hidden select-none not-last:pb-0.5 focus:z-20 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
    props.class
  ),
  "data-focus":
    typeof props.item.isFocused === "function"
      ? props.item.isFocused() || false
      : undefined,
  "data-folder":
    typeof props.item.isFolder === "function"
      ? props.item.isFolder() || false
      : undefined,
  "data-selected":
    typeof props.item.isSelected === "function"
      ? props.item.isSelected() || false
      : undefined,
  "data-drag-target":
    typeof props.item.isDragTarget === "function"
      ? props.item.isDragTarget() || false
      : undefined,
  "data-search-match":
    typeof props.item.isMatchingSearch === "function"
      ? props.item.isMatchingSearch() || false
      : undefined,
  "aria-expanded": props.item.isExpanded(),
}))

const bindings = computed(() => ({
  ...defaultProps.value,
  ...otherProps.value,
}))

provide(TreeContextKey, {
  ...parentContext,
  currentItem: props.item,
})

// См. useHeadlessTreeRef.ts: колбэк-реф ядра подключаем к реальному
// DOM-узлу вручную, а не через v-bind.
const elRef = ref<HTMLElement | null>(null)
useHeadlessTreeRef(elRef, () => (mergedProps.value as { ref?: unknown }).ref)
</script>

<template>
  <button v-if="!asChild" ref="elRef" v-bind="bindings">
    <slot />
  </button>
  <Primitive v-else as="button" as-child v-bind="bindings">
    <slot />
  </Primitive>
</template>

src/reui/tree/TreeItemLabel.vue

<script setup lang="ts">
/**
 * Порт ReUI TreeItemLabel (registry-reui/bases/radix/reui/tree.tsx, MIT).
 *
 * Отличие от оригинала (осознанное, тот же приём, что и в Rating.vue):
 * апстрим рисует переключатель через `IconPlaceholder` — dev-инструмент
 * сайта ReUI, читающий выбранную библиотеку иконок из localStorage и
 * лениво подгружающий её на клиенте. Это чисто демо-обвязка апстрима,
 * а не часть переносимой логики компонента, и у неё нет Vue-аналога.
 * Здесь она заменена на инлайновые `<svg>` (пути lucide "minus"/"plus"/
 * "chevron-down"), зафиксированные как есть — те же SVG используются и
 * в `react.tsx`-эталоне кейса, поэтому визуальный диф сравнивает сам
 * компонент, а не выбор библиотеки иконок.
 */
import type { HTMLAttributes } from "vue"
import { computed, watchEffect } from "vue"
import { Primitive } from "reka-ui"
import type { ItemInstance } from "@headless-tree/core"
import { cn } from "@/lib/utils"
import { useTreeContextInject } from "./useTreeContextInject"

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes["class"]
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    item?: ItemInstance<any>
    asChild?: boolean
  }>(),
  {
    asChild: false,
  }
)

const context = useTreeContextInject()
const item = computed(() => props.item ?? context.currentItem)

watchEffect(() => {
  if (!item.value) {
    // eslint-disable-next-line no-console
    console.warn("TreeItemLabel: No item provided via props or context")
  }
})

const toggleIconType = computed(() => context.toggleIconType)
const itemName = computed(() =>
  item.value && typeof item.value.getItemName === "function"
    ? item.value.getItemName()
    : null
)
</script>

<template>
  <Primitive
    v-if="item"
    data-slot="tree-item-label"
    as="span"
    :as-child="asChild"
    :class="
      cn(
        'in-focus-visible:ring-ring/50 bg-background hover:bg-accent in-data-[selected=true]:bg-accent in-data-[selected=true]:text-accent-foreground in-data-[drag-target=true]:bg-accent flex items-center gap-1 transition-colors not-in-data-[folder=true]:ps-7 in-focus-visible:ring-[3px] in-data-[search-match=true]:bg-blue-50! [&_svg]:pointer-events-none [&_svg]:shrink-0',
        'style-vega:rounded-sm style-nova:rounded-md style-maia:rounded-xl style-lyra:rounded-none style-mira:rounded-md style-luma:rounded-2xl style-rhea:rounded-2xl style-sera:rounded-none',
        'py-1.5',
        'px-2',
        'text-sm',
        props.class
      )
    "
  >
    <template v-if="item.isFolder()">
      <template v-if="toggleIconType === 'plus-minus'">
        <svg
          v-if="item.isExpanded()"
          class="text-muted-foreground size-3.5"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          stroke-width="1"
          stroke-linecap="round"
          stroke-linejoin="round"
        ><path d="M5 12h14" /></svg>
        <svg
          v-else
          class="text-muted-foreground size-3.5"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          stroke-width="1"
          stroke-linecap="round"
          stroke-linejoin="round"
        ><path d="M5 12h14" /><path d="M12 5v14" /></svg>
      </template>
      <svg
        v-else
        class="text-muted-foreground size-4 in-aria-[expanded=false]:-rotate-90"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        stroke-width="2"
        stroke-linecap="round"
        stroke-linejoin="round"
      ><path d="m6 9 6 6 6-6" /></svg>
    </template><slot>{{ itemName }}</slot>
  </Primitive>
</template>

src/reui/tree/context.ts

import type { InjectionKey } from "vue"
import type { ItemInstance } from "@headless-tree/core"

export type ToggleIconType = "chevron" | "plus-minus"

export interface TreeContextValue<T = unknown> {
  indent: number
  currentItem?: ItemInstance<T>
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  tree?: any
  toggleIconType?: ToggleIconType
}

/** Порт React `createContext(...)` — те же дефолты, что и в оригинале. */
export const defaultTreeContext: TreeContextValue = {
  indent: 20,
  currentItem: undefined,
  tree: undefined,
  toggleIconType: "plus-minus",
}

export const TreeContextKey: InjectionKey<TreeContextValue> =
  Symbol("TreeContext")

src/reui/tree/index.ts

export { default as Tree } from "./Tree.vue"
export { default as TreeItem } from "./TreeItem.vue"
export { default as TreeItemLabel } from "./TreeItemLabel.vue"
export { default as TreeDragLine } from "./TreeDragLine.vue"
export { useTree } from "./useTree"
export type { ToggleIconType, TreeContextValue } from "./context"

src/reui/tree/useHeadlessTreeRef.ts

import { type Ref, watchEffect } from "vue"

/**
 * `tree.getContainerProps()` / `item.getProps()` из `@headless-tree/core`
 * возвращают React-style колбэк-реф (`element => void`), которым ядро само
 * регистрирует DOM-узел — на нём висят обработчики клавиатуры/drag&drop
 * (`tree.registerElement` / `item.registerElement`). Это часть ядра,
 * фреймворк-агностична и не заменяется.
 *
 * Проблема — способ его подключения: если положить такой колбэк в объект
 * и передать его через `v-bind` на Vue-компонент (`<Primitive v-bind="...">`),
 * Vue трактует ключ `ref` из объекта как императивный template ref и
 * привязывает его к ПРОКСИ ИНСТАНСА компонента, а не к реальному DOM-узлу
 * (в отличие от React, где ref всегда долетает до DOM-элемента). Колбэк
 * ядра затем падает на `element.addEventListener(...)`, т.к. получает
 * не элемент. Поэтому здесь `ref` из объекта headless-tree исключается
 * из общего v-bind и подключается вручную к настоящему DOM-узлу через
 * template ref этого компонента.
 */
export function useHeadlessTreeRef(
  elRef: Ref<HTMLElement | null>,
  getRefFn: () => unknown
): void {
  watchEffect((onCleanup) => {
    const element = elRef.value
    const refFn = getRefFn()
    if (element && typeof refFn === "function") {
      refFn(element)
      onCleanup(() => refFn(null))
    }
  })
}

src/reui/tree/useTree.ts

import { onBeforeUnmount, onMounted, shallowRef, triggerRef } from "vue"
import { createTree } from "@headless-tree/core"
import type { TreeConfig, TreeInstance } from "@headless-tree/core"

/**
 * Vue-адаптация `useTree` из `@headless-tree/react`.
 *
 * `@headless-tree/core` — framework-agnostic: он несёт всю модель дерева
 * (данные, expand/select/dnd/hotkeys), а React-обёртка `@headless-tree/react`
 * лишь синхронизирует его внутренний `state` с `useState`, дергая
 * `setMounted(true)` + `rebuildTree()` в `useEffect`. Именно эта обвязка —
 * единственное, что не переносится дословно (она жёстко на React-хуках);
 * сама модель дерева (`Tree`/`TreeItem`/`TreeItemLabel`/`TreeDragLine` из
 * `tree.tsx`) переносится один в один, т.к. читает состояние только через
 * методы `tree`/`item`, а не через React-специфичные подписки.
 *
 * Логика здесь зеркалит `@headless-tree/react` `useTree` 1:1 (см.
 * node_modules/@headless-tree/react/dist/index.js): дерево создаётся один
 * раз через `createTree`, `state` живёт снаружи ядра и на каждое изменение
 * реконфигурируется через `tree.setConfig`. Вместо `useState` — `shallowRef`
 * + `triggerRef`, вместо `useEffect` — `onMounted`/`onBeforeUnmount`.
 */
export function useTree<T = unknown>(config: TreeConfig<T>) {
  let state: TreeConfig<T>["state"] = config.state

  function buildConfig(prev: Partial<TreeConfig<T>>): TreeConfig<T> {
    return {
      ...prev,
      ...config,
      state: { ...state, ...config.state },
      setState,
    } as TreeConfig<T>
  }

  function setState(newState: NonNullable<TreeConfig<T>["state"]>): void {
    state = newState
    config.setState?.(newState)
    tree.setConfig((prev) => buildConfig(prev))
    triggerRef(treeRef)
  }

  const tree: TreeInstance<T> = createTree<T>(buildConfig(config))
  const treeRef = shallowRef(tree)

  onMounted(() => {
    tree.setMounted(true)
    tree.rebuildTree()
    triggerRef(treeRef)
  })

  onBeforeUnmount(() => {
    tree.setMounted(false)
  })

  return treeRef
}

src/reui/tree/useTreeContextInject.ts

import { inject } from "vue"
import { TreeContextKey, defaultTreeContext, type TreeContextValue } from "./context"

/** Порт React `useTreeContext()` (`useContext(TreeContext)`). */
export function useTreeContextInject<T = unknown>(): TreeContextValue<T> {
  return inject(TreeContextKey, defaultTreeContext) as TreeContextValue<T>
}

Установка

npx shadcn-vue@latest add https://revueui.rootapi.dev/r/tree.json

Зависимости реестра

npm-зависимости

  • @headless-tree/core
  • reka-ui

Источник: порт из ReUI (Keenthemes, MIT)