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)