Skip to content
Open
147 changes: 89 additions & 58 deletions src/runtime/components/Tree.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ export type TreeItem = {
onToggle?(e: Event): void
onSelect?(e?: Event): void
class?: any
ui?: Pick<Tree['slots'], 'item' | 'itemWithChildren' | 'link' | 'linkLeadingIcon' | 'linkLabel' | 'linkTrailing' | 'linkTrailingIcon' | 'listWithChildren'>
ui?: Pick<Tree['slots'], 'item' | 'itemWithChildren' | 'link' | 'linkLeadingIcon' | 'linkLabel' | 'linkTrailing' | 'linkTrailingIcon'>
[key: string]: any
}

export interface TreeProps<T extends TreeItem[] = TreeItem[], VK extends GetItemKeys<T> = 'value', M extends boolean = false> extends Pick<TreeRootProps<T>, 'expanded' | 'defaultExpanded' | 'selectionBehavior' | 'propagateSelect' | 'disabled' | 'bubbleSelect'> {
export interface TreeProps<T extends TreeItem = TreeItem, VK extends GetItemKeys<T> = 'value', M extends boolean = false> extends Pick<TreeRootProps<T>, 'expanded' | 'defaultExpanded' | 'selectionBehavior' | 'propagateSelect' | 'disabled' | 'bubbleSelect'> {
/**
* The element or component this component should render as.
* @defaultValue 'ul'
Expand All @@ -52,7 +52,7 @@ export interface TreeProps<T extends TreeItem[] = TreeItem[], VK extends GetItem
* The key used to get the label from the item.
* @defaultValue 'label'
*/
labelKey?: keyof NestedItem<T>
labelKey?: keyof T
/**
* The icon displayed on the right side of a parent node.
* @defaultValue appConfig.ui.icons.chevronDown
Expand All @@ -71,24 +71,26 @@ export interface TreeProps<T extends TreeItem[] = TreeItem[], VK extends GetItem
* @IconifyIcon
*/
collapsedIcon?: string
items?: T
items?: T[]
/** The controlled value of the Tree. Can be bind as `v-model`. */
modelValue?: GetModelValue<T, VK, M>
/** The value of the Tree when initially rendered. Use when you do not need to control the state of the Tree. */
defaultValue?: GetModelValue<T, VK, M>
/** Whether multiple options can be selected or not. */
multiple?: M & boolean
class?: any
lineOffset?: Partial<Record<Tree['variants']['size'], `${string}px`>>
indent?: Partial<Record<Tree['variants']['size'], `${string}px`>>
ui?: Tree['slots']
}

export type TreeEmits<A extends TreeItem[], VK extends GetItemKeys<A> | undefined, M extends boolean> = Omit<TreeRootEmits, 'update:modelValue'> & GetModelValueEmits<A, VK, M>
export type TreeEmits<T extends TreeItem, VK extends GetItemKeys<T[]> | undefined, M extends boolean> = Omit<TreeRootEmits, 'update:modelValue'> & GetModelValueEmits<T[], VK, M>

type SlotProps<T extends TreeItem> = (props: { item: T, index: number, level: number, expanded: boolean, selected: boolean }) => any
type SlotProps<T extends TreeItem> = (props: { item: NestedItem<T>, index: number, level: number, expanded: boolean, selected: boolean }) => any

export type TreeSlots<
A extends TreeItem[] = TreeItem[],
T extends NestedItem<A> = NestedItem<A>
A extends TreeItem = TreeItem,
T extends NestedItem<A[]> = NestedItem<A[]>
> = {
'item-wrapper': SlotProps<T>
'item': SlotProps<T>
Expand All @@ -99,10 +101,10 @@ export type TreeSlots<

</script>

<script setup lang="ts" generic="T extends TreeItem[], VK extends GetItemKeys<T> = 'value', M extends boolean = false">
<script setup lang="ts" generic="T extends TreeItem, VK extends GetItemKeys<T> = 'value', M extends boolean = false">
import { computed } from 'vue'
import { TreeRoot, TreeItem, useForwardPropsEmits } from 'reka-ui'
import { reactivePick, createReusableTemplate } from '@vueuse/core'
import { TreeRoot, useForwardPropsEmits, TreeItem as TreeItemReka } from 'reka-ui'
import { reactivePick } from '@vueuse/core'
import { useAppConfig } from '#imports'
import { get } from '../utils'
import { tv } from '../utils/tv'
Expand All @@ -121,24 +123,55 @@ const appConfig = useAppConfig() as Tree['AppConfig']

const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'items', 'multiple', 'expanded', 'disabled', 'propagateSelect', 'bubbleSelect'), emits)

const [DefineTreeTemplate, ReuseTreeTemplate] = createReusableTemplate<{ items?: TreeItem[], level: number }, TreeSlots<T>>()

const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.tree || {}) })({
color: props.color,
size: props.size
}))

function getItemLabel<Item extends TreeItem = NestedItem<T>>(item: Item): string {
const lineOffset = computed(() => {
switch (props.size) {
case 'xs':
return props.lineOffset?.xs || '6px'
case 'sm':
return props.lineOffset?.sm || '6px'
case 'md':
return props.lineOffset?.md || '8px'
case 'lg':
return props.lineOffset?.lg || '8px'
case 'xl':
return props.lineOffset?.xl || '10px'
default:
return props.lineOffset?.md || '8px'
}
})
const indent = computed(() => {
switch (props.size) {
case 'xs':
return props.indent?.xs || '16px'
case 'sm':
return props.indent?.sm || '20px'
case 'md':
return props.indent?.md || '20px'
case 'lg':
return props.indent?.lg || '24px'
case 'xl':
return props.indent?.xl || '24px'
default:
return props.indent?.md || '20px'
}
})

function getItemLabel(item: T): string {
return get(item, props.labelKey as string)
}

function getItemValue(item: NestedItem<T>): string {
function getItemValue(item: T): string {
return get(item, props.valueKey as string) ?? get(item, props.labelKey as string)
}

function getDefaultOpenedItems(item: NestedItem<T>): string[] {
function getDefaultOpenedItems(item: T): string[] {
const currentItem = item.defaultExpanded ? getItemValue(item) : null
const childItems = item.children?.flatMap((child: TreeItem) => getDefaultOpenedItems(child as NestedItem<T>)) ?? []
const childItems = item.children?.flatMap(value => getDefaultOpenedItems(value as unknown as T)) ?? []

return [currentItem, ...childItems].filter(Boolean) as string[]
}
Expand All @@ -148,68 +181,66 @@ const defaultExpanded = computed(() =>
)
</script>

<!-- eslint-disable vue/no-template-shadow -->
<template>
<DefineTreeTemplate v-slot="{ items, level }">
<TreeRoot
v-slot="{ flattenItems }"
v-bind="{ ...(rootProps as unknown as TreeRootProps<T>), ...$attrs }"
:class="ui.root({ class: [props.ui?.root, props.class] })"
:get-key="getItemValue"
:get-children="(v) => (v.children as unknown as T[])"
:items="items"
:default-expanded="defaultExpanded"
:selection-behavior="selectionBehavior"
>
<li
v-for="(item, index) in items"
:key="`${level}-${index}`"
:class="level > 0 ? ui.itemWithChildren({ class: [props.ui?.itemWithChildren, item.ui?.itemWithChildren] }) : ui.item({ class: [props.ui?.item, item.ui?.item] })"
v-for="(item, index) in flattenItems"
:key="item._id"
:class="[ui.connector(), item.level > 0 ? [ui.itemWithChildren({ class: [props.ui?.itemWithChildren, item.value.ui?.itemWithChildren] })] : ui.item({ class: [props.ui?.item, item.value.ui?.item] })]"
:style="{
'--level': item.level - 1,
'--line-offset': lineOffset,
'--indent': indent
}"
>
<TreeItem
<TreeItemReka
v-slot="{ isExpanded, isSelected }"
as-child
:level="level"
:value="item"
@toggle="item.onToggle"
@select="item.onSelect"
v-bind="item.bind"
@toggle="item.value.onToggle"
@select="item.value.onSelect"
>
<slot :name="((item.slot ? `${item.slot}-wrapper` : 'item-wrapper') as keyof TreeSlots<T>)" v-bind="{ item, index, level, expanded: isExpanded, selected: isSelected }" :item="(item as Extract<NestedItem<T>, { slot: string; }>)">
<button type="button" :disabled="item.disabled || disabled" :class="ui.link({ class: [props.ui?.link, item.ui?.link, item.class], selected: isSelected, disabled: item.disabled || disabled })">
<slot :name="((item.slot || 'item') as keyof TreeSlots<T>)" v-bind="{ index, level, expanded: isExpanded, selected: isSelected }" :item="(item as Extract<NestedItem<T>, { slot: string; }>)">
<slot :name="((item.slot ? `${item.slot}-leading`: 'item-leading') as keyof TreeSlots<T>)" v-bind="{ index, level, expanded: isExpanded, selected: isSelected }" :item="(item as Extract<NestedItem<T>, { slot: string; }>)">
<slot :name="((item.value.slot ? `${item.value.slot}-wrapper` : 'item-wrapper') as keyof TreeSlots<T>)" v-bind="{ item, index, level: item.level, expanded: isExpanded, selected: isSelected }" :item="(item as Extract<NestedItem<T>, { slot: string; }>)">
<button type="button" :disabled="item.value.disabled || disabled" :class="ui.link({ class: [props.ui?.link, item.value.ui?.link, item.value.class], selected: isSelected, disabled: item.value.disabled || disabled })">
<slot :name="((item.value.slot || 'item') as keyof TreeSlots<T>)" v-bind="{ index, level: item.level, expanded: isExpanded, selected: isSelected }" :item="(item.value as Extract<T, { slot: string; }>)">
<slot :name="((item.value.slot ? `${item.value.slot}-leading`: 'item-leading') as keyof TreeSlots<T>)" v-bind="{ index, level: item.level, expanded: isExpanded, selected: isSelected }" :item="(item.value as Extract<T, { slot: string; }>)">
<UIcon
v-if="item.icon"
:name="item.icon"
:class="ui.linkLeadingIcon({ class: [props.ui?.linkLeadingIcon, item.ui?.linkLeadingIcon] })"
v-if="item.value.icon"
:name="item.value.icon"
:class="ui.linkLeadingIcon({ class: [props.ui?.linkLeadingIcon, item.value.ui?.linkLeadingIcon] })"
/>
<UIcon
v-else-if="item.children?.length"
v-else-if="item.value.children?.length"
:name="isExpanded ? (expandedIcon ?? appConfig.ui.icons.folderOpen) : (collapsedIcon ?? appConfig.ui.icons.folder)"
:class="ui.linkLeadingIcon({ class: [props.ui?.linkLeadingIcon, item.ui?.linkLeadingIcon] })"
:class="ui.linkLeadingIcon({ class: [props.ui?.linkLeadingIcon, item.value.ui?.linkLeadingIcon] })"
/>
</slot>

<span v-if="getItemLabel(item) || !!slots[(item.slot ? `${item.slot}-label`: 'item-label') as keyof TreeSlots<T>]" :class="ui.linkLabel({ class: [props.ui?.linkLabel, item.ui?.linkLabel] })">
<slot :name="((item.slot ? `${item.slot}-label`: 'item-label') as keyof TreeSlots<T>)" v-bind="{ item, index, level, expanded: isExpanded, selected: isSelected }" :item="(item as Extract<NestedItem<T>, { slot: string; }>)">
{{ getItemLabel(item) }}
<span v-if="getItemLabel(item.value) || !!slots[(item.value.slot ? `${item.value.slot}-label`: 'item-label') as keyof TreeSlots<T>]" :class="ui.linkLabel({ class: [props.ui?.linkLabel, item.value.ui?.linkLabel] })">
<slot :name="((item.value.slot ? `${item.value.slot}-label`: 'item-label') as keyof TreeSlots<T>)" v-bind="{ item, index, level: item.level, expanded: isExpanded, selected: isSelected }" :item="(item.value as Extract<T, { slot: string; }>)">
{{ getItemLabel(item.value) }}
</slot>
</span>

<span v-if="item.trailingIcon || item.children?.length || !!slots[(item.slot ? `${item.slot}-trailing`: 'item-trailing') as keyof TreeSlots<T>]" :class="ui.linkTrailing({ class: [props.ui?.linkTrailing, item.ui?.linkTrailing] })">
<slot :name="((item.slot ? `${item.slot}-trailing`: 'item-trailing') as keyof TreeSlots<T>)" v-bind="{ item, index, level, expanded: isExpanded, selected: isSelected }" :item="(item as Extract<NestedItem<T>, { slot: string; }>)">
<UIcon v-if="item.trailingIcon" :name="item.trailingIcon" :class="ui.linkTrailingIcon({ class: [props.ui?.linkTrailingIcon, item.ui?.linkTrailingIcon] })" />
<UIcon v-else-if="item.children?.length" :name="trailingIcon ?? appConfig.ui.icons.chevronDown" :class="ui.linkTrailingIcon({ class: [props.ui?.linkTrailingIcon, item.ui?.linkTrailingIcon] })" />
<span v-if="item.value.trailingIcon || item.value.children?.length || !!slots[(item.value.slot ? `${item.value.slot}-trailing`: 'item-trailing') as keyof TreeSlots<T>]" :class="ui.linkTrailing({ class: [props.ui?.linkTrailing, item.value.ui?.linkTrailing] })">
<slot :name="((item.value.slot ? `${item.value.slot}-trailing`: 'item-trailing') as keyof TreeSlots<T>)" v-bind="{ item, index, level: item.level, expanded: isExpanded, selected: isSelected }" :item="(item.value as Extract<T, { slot: string; }>)">
<UIcon v-if="item.value.trailingIcon" :name="item.value.trailingIcon" :class="ui.linkTrailingIcon({ class: [props.ui?.linkTrailingIcon, item.value.ui?.linkTrailingIcon] })" />
<UIcon v-else-if="item.value.children?.length" :name="trailingIcon ?? appConfig.ui.icons.chevronDown" :class="ui.linkTrailingIcon({ class: [props.ui?.linkTrailingIcon, item.value.ui?.linkTrailingIcon] })" />
</slot>
</span>
</slot>
</button>
</slot>

<ul v-if="item.children?.length && isExpanded" :class="ui.listWithChildren({ class: [props.ui?.listWithChildren, item.ui?.listWithChildren] })">
<ReuseTreeTemplate :items="item.children" :level="level + 1" />
</ul>
</TreeItem>
</TreeItemReka>
</li>
</DefineTreeTemplate>

<TreeRoot
v-bind="{ ...(rootProps as unknown as TreeRootProps<NestedItem<T>>), ...$attrs }"
:class="ui.root({ class: [props.ui?.root, props.class] })"
:get-key="getItemValue"
:default-expanded="defaultExpanded"
:selection-behavior="selectionBehavior"
>
<ReuseTreeTemplate :items="items" :level="0" />
</TreeRoot>
</template>
9 changes: 6 additions & 3 deletions src/theme/tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import type { ModuleOptions } from '../module'
export default (options: Required<ModuleOptions>) => ({
slots: {
root: 'relative isolate',
item: '',
listWithChildren: 'ms-4.5 border-s border-default',
itemWithChildren: 'ps-1.5 -ms-px',
item: 'relative',
itemWithChildren: 'relative ps-[calc(var(--level)*calc(var(--indent)+0.25em))]',
link: 'relative group w-full flex items-center text-sm before:absolute before:inset-y-px before:inset-x-0 before:z-[-1] before:rounded-md focus:outline-none focus-visible:outline-none focus-visible:before:ring-inset focus-visible:before:ring-2',
linkLeadingIcon: 'shrink-0',
connector: 'relative before:absolute before:content-[\'\'] before:top-0 before:bottom-0 before:left-[var(--line-offset)] before:pointer-events-none before:w-[calc(var(--level)*(var(--indent)+0.25em))] before:bg-[repeating-linear-gradient(to_right,transparent,transparent_calc(50%-0.5px),var(--ui-border)_calc(50%-0.5px),var(--ui-border)_calc(50%+0.5px),transparent_calc(50%+0.5px))] before:bg-[size:calc(var(--indent)+0.25em)_100%]',
linkLabel: 'truncate',
linkTrailing: 'ms-auto inline-flex gap-1.5 items-center',
linkTrailingIcon: 'shrink-0 transform transition-transform duration-200 group-data-expanded:rotate-180'
Expand Down Expand Up @@ -36,16 +36,19 @@ export default (options: Required<ModuleOptions>) => ({
link: 'px-2.5 py-1.5 text-sm gap-1.5',
linkLeadingIcon: 'size-5',
linkTrailingIcon: 'size-5'

},
lg: {
link: 'px-3 py-2 text-sm gap-2',
linkLeadingIcon: 'size-5',
linkTrailingIcon: 'size-5'

},
xl: {
link: 'px-3 py-2 text-base gap-2',
linkLeadingIcon: 'size-6',
linkTrailingIcon: 'size-6'

}
},
selected: {
Expand Down
Loading