From 5e0aaf9bdf9384c9cd0b0b97b1eb22eefea45a07 Mon Sep 17 00:00:00 2001
From: hywax <me@hywax.space>
Date: Mon, 25 Nov 2024 19:19:02 +0500
Subject: [PATCH 1/2] feat(Tree): implement component

---
 docs/content/3.components/tree.md        | 32 ++++++++
 playground/app/app.vue                   |  3 +-
 playground/app/pages/components/tree.vue | 42 ++++++++++
 src/runtime/components/Tree.vue          | 99 ++++++++++++++++++++++++
 src/runtime/types/index.ts               |  1 +
 src/theme/index.ts                       |  1 +
 src/theme/tree.ts                        | 56 ++++++++++++++
 test/components/Tree.spec.ts             | 17 ++++
 8 files changed, 250 insertions(+), 1 deletion(-)
 create mode 100644 docs/content/3.components/tree.md
 create mode 100644 playground/app/pages/components/tree.vue
 create mode 100644 src/runtime/components/Tree.vue
 create mode 100644 src/theme/tree.ts
 create mode 100644 test/components/Tree.spec.ts

diff --git a/docs/content/3.components/tree.md b/docs/content/3.components/tree.md
new file mode 100644
index 0000000000..d7268940a2
--- /dev/null
+++ b/docs/content/3.components/tree.md
@@ -0,0 +1,32 @@
+---
+description:
+links: 
+  - label: Tree
+    icon: i-custom-radix-vue
+    to: https://www.radix-vue.com/components/tree.html
+  - label: GitHub
+    icon: i-simple-icons-github
+    to: https://github.com/nuxt/ui/tree/v3/src/runtime/components/Tree.vue
+---
+
+## Usage
+
+## Examples
+
+## API
+
+### Props
+
+:component-props
+
+### Slots
+
+:component-slots
+
+### Emits
+
+:component-emits
+
+## Theme
+
+:component-theme
diff --git a/playground/app/app.vue b/playground/app/app.vue
index 8a3a4b7cd4..1394c5d55e 100644
--- a/playground/app/app.vue
+++ b/playground/app/app.vue
@@ -60,7 +60,8 @@ const components = [
   'table',
   'textarea',
   'toast',
-  'tooltip'
+  'tooltip',
+  'tree'
 ]
 
 const items = components.map(component => ({ label: upperName(component), to: `/components/${component}` }))
diff --git a/playground/app/pages/components/tree.vue b/playground/app/pages/components/tree.vue
new file mode 100644
index 0000000000..d048330342
--- /dev/null
+++ b/playground/app/pages/components/tree.vue
@@ -0,0 +1,42 @@
+<script setup lang="ts">
+const items = ref([
+  {
+    label: 'Backlog',
+    value: 'backlog',
+    icon: 'i-lucide-circle-help',
+    children: [
+      {
+        label: 'Backlog 1',
+        value: 'backlog-1',
+        icon: 'i-lucide-circle-help'
+      },
+      {
+        label: 'Backlog 2',
+        value: 'backlog-2',
+        icon: 'i-lucide-circle-help'
+      }
+    ]
+  },
+  {
+    label: 'Todo',
+    value: 'todo',
+    icon: 'i-lucide-circle-plus'
+  },
+  {
+    label: 'In Progress',
+    value: 'in_progress',
+    icon: 'i-lucide-circle-arrow-up'
+  },
+  {
+    label: 'Done',
+    value: 'done',
+    icon: 'i-lucide-circle-check'
+  }
+])
+</script>
+
+<template>
+  <div>
+    <UTree :items="items" :get-key="(item) => item.label" />
+  </div>
+</template>
diff --git a/src/runtime/components/Tree.vue b/src/runtime/components/Tree.vue
new file mode 100644
index 0000000000..a39d293b60
--- /dev/null
+++ b/src/runtime/components/Tree.vue
@@ -0,0 +1,99 @@
+<script lang="ts">
+import { tv, type VariantProps } from 'tailwind-variants'
+import type { TreeRootProps, TreeRootEmits } from 'radix-vue'
+import type { AppConfig } from '@nuxt/schema'
+import _appConfig from '#build/app.config'
+import theme from '#build/ui/tree'
+import type { AvatarProps, ChipProps } from '../types'
+import type { DynamicSlots } from '../types/utils'
+
+const appConfig = _appConfig as AppConfig & { ui: { tree: Partial<typeof theme> } }
+
+const tree = tv({ extend: tv(theme), ...(appConfig.ui?.tree || {}) })
+
+type TreeVariants = VariantProps<typeof tree>
+
+export interface TreeItem {
+  label?: string
+  icon?: string
+  avatar?: AvatarProps
+  chip?: ChipProps
+  disabled?: boolean
+  slot?: string
+  children?: TreeItem[]
+}
+
+export interface TreeProps<T> extends Omit<TreeRootProps<T>, 'dir'> {
+  size?: TreeVariants['size']
+  /**
+   * The key used to get the label from the item.
+   * @defaultValue 'label'
+   */
+  labelKey?: string
+  class?: any
+  ui?: Partial<typeof tree.slots>
+}
+
+export interface TreeEmits extends TreeRootEmits {}
+
+type SlotProps<T> = (props: { item: T, index: number, level: number, hasChildren: boolean }) => any
+
+export type TreeSlots<T extends { slot?: string }> = {
+  'item': SlotProps<T>
+  'item-leading': SlotProps<T>
+  'item-label': SlotProps<T>
+  'item-trailing': SlotProps<T>
+} & DynamicSlots<T, SlotProps<T>>
+</script>
+
+<script setup lang="ts" generic="T extends TreeItem">
+import { computed } from 'vue'
+import { TreeRoot, TreeItem as TreeItemComponent, useForwardPropsEmits } from 'radix-vue'
+import { reactiveOmit, createReusableTemplate } from '@vueuse/core'
+import { get } from '../utils'
+import UIcon from './Icon.vue'
+import UAvatar from './Avatar.vue'
+
+const props = withDefaults(defineProps<TreeProps<T>>(), {
+  labelKey: 'label'
+})
+const emits = defineEmits<TreeEmits>()
+const slots = defineSlots<TreeSlots<T>>()
+
+const rootProps = useForwardPropsEmits(reactiveOmit(props, 'class', 'ui'), emits)
+
+const [DefineItemTemplate, ReuseItemTemplate] = createReusableTemplate<{ item: TreeItem, index: number, level: number, hasChildren: boolean }>()
+
+const ui = computed(() => tree({
+  size: props.size
+}))
+</script>
+
+<template>
+  <DefineItemTemplate v-slot="{ item, index, level, hasChildren }">
+    <slot :name="item.slot || 'item'" v-bind="{ item: item as T, index, level, hasChildren }">
+      <UIcon v-if="item.children?.length" :name="appConfig.ui.icons.chevronRight" :class="ui.itemTrailingIcon({ class: props.ui?.itemTrailingIcon })" />
+
+      <slot :name="item.slot ? `${item.slot}-leading`: 'item-leading'" v-bind="{ item: item as T, index, level, hasChildren }">
+        <UIcon v-if="item.icon" :name="item.icon" :class="ui.itemLeadingIcon({ class: props.ui?.itemLeadingIcon })" />
+        <UAvatar v-else-if="item.avatar" :size="((props.ui?.itemLeadingAvatarSize || ui.itemLeadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.itemLeadingAvatar({ class: props.ui?.itemLeadingAvatar })" />
+      </slot>
+
+      <span v-if="get(item, props.labelKey) || !!slots[item.slot ? `${item.slot}-label`: 'item-label']" :class="ui.itemLabel({ class: props.ui?.itemLabel })">
+        <slot :name="item.slot ? `${item.slot}-label`: 'item-label'" v-bind="{ item: item as T, index, level, hasChildren }">
+          {{ get(item, props.labelKey) }}
+        </slot>
+      </span>
+
+      <span :class="ui.itemTrailing({ class: props.ui?.itemTrailing })">
+        <slot :name="item.slot ? `${item.slot}-trailing`: 'item-trailing'" v-bind="{ item: item as T, index, level, hasChildren }" />
+      </span>
+    </slot>
+  </DefineItemTemplate>
+
+  <TreeRoot v-slot="{ flattenItems }" v-bind="rootProps" :class="ui.root({ class: [props.class, props.ui?.root] })">
+    <TreeItemComponent v-for="item in flattenItems" v-bind="item.bind" :key="item._id" :class="ui.item({ class: [props.ui?.item] })">
+      <ReuseItemTemplate :item="item.value" :index="item.index" :level="item.level" :has-children="item.hasChildren" />
+    </TreeItemComponent>
+  </TreeRoot>
+</template>
diff --git a/src/runtime/types/index.ts b/src/runtime/types/index.ts
index 072e3e8db8..709c22731d 100644
--- a/src/runtime/types/index.ts
+++ b/src/runtime/types/index.ts
@@ -43,5 +43,6 @@ export * from '../components/Textarea.vue'
 export * from '../components/Toast.vue'
 export * from '../components/Toaster.vue'
 export * from '../components/Tooltip.vue'
+export * from '../components/Tree.vue'
 export * from './form'
 export * from './locale'
diff --git a/src/theme/index.ts b/src/theme/index.ts
index 90d2af7156..229727826c 100644
--- a/src/theme/index.ts
+++ b/src/theme/index.ts
@@ -43,3 +43,4 @@ export { default as textarea } from './textarea'
 export { default as toast } from './toast'
 export { default as toaster } from './toaster'
 export { default as tooltip } from './tooltip'
+export { default as tree } from './tree'
diff --git a/src/theme/tree.ts b/src/theme/tree.ts
new file mode 100644
index 0000000000..201caeac38
--- /dev/null
+++ b/src/theme/tree.ts
@@ -0,0 +1,56 @@
+import type { ModuleOptions } from '../module'
+
+export default (options: Required<ModuleOptions>) => ({
+  slots: {
+    root: '',
+    item: ['group relative w-full flex items-center gap-2 px-2 py-1.5 text-sm select-none outline-none before:absolute before:z-[-1] before:inset-px before:rounded-[calc(var(--ui-radius)*1.5)] data-disabled:cursor-not-allowed data-disabled:opacity-75 text-[var(--ui-text)] data-[selected]:text-[var(--ui-text-highlighted)] data-[selected]:before:bg-[var(--ui-bg-elevated)]/50', options.theme.transitions && 'transition-colors before:transition-colors'],
+    itemLeadingIcon: 'shrink-0',
+    itemLeadingAvatar: 'shrink-0',
+    itemLeadingAvatarSize: '',
+    itemTrailing: 'ms-auto inline-flex',
+    itemTrailingIcon: 'shrink-0',
+    itemLabel: 'truncate'
+  },
+  variants: {
+    size: {
+      xs: {
+        label: 'p-1 text-xs gap-1',
+        item: 'p-1 text-xs gap-1',
+        itemLeadingIcon: 'size-4',
+        itemLeadingAvatarSize: '3xs',
+        itemTrailingIcon: 'size-4'
+      },
+      sm: {
+        label: 'p-1.5 text-xs gap-1.5',
+        item: 'p-1.5 text-xs gap-1.5',
+        itemLeadingIcon: 'size-4',
+        itemLeadingAvatarSize: '3xs',
+        itemTrailingIcon: 'size-4'
+      },
+      md: {
+        label: 'p-1.5 text-sm gap-1.5',
+        item: 'p-1.5 text-sm gap-1.5',
+        itemLeadingIcon: 'size-5',
+        itemLeadingAvatarSize: '2xs',
+        itemTrailingIcon: 'size-5'
+      },
+      lg: {
+        label: 'p-2 text-sm gap-2',
+        item: 'p-2 text-sm gap-2',
+        itemLeadingIcon: 'size-5',
+        itemLeadingAvatarSize: '2xs',
+        itemTrailingIcon: 'size-5'
+      },
+      xl: {
+        item: 'p-2 text-base gap-2',
+        itemLeadingIcon: 'size-6',
+        itemLeadingAvatarSize: 'xs',
+        itemTrailingIcon: 'size-6'
+      }
+    }
+  },
+  compoundVariants: [],
+  defaultVariants: {
+    size: 'md'
+  }
+})
diff --git a/test/components/Tree.spec.ts b/test/components/Tree.spec.ts
new file mode 100644
index 0000000000..db6e90a108
--- /dev/null
+++ b/test/components/Tree.spec.ts
@@ -0,0 +1,17 @@
+import { describe, it, expect } from 'vitest'
+import Tree, { type TreeProps, type TreeSlots } from '../../src/runtime/components/Tree.vue'
+import ComponentRender from '../component-render'
+
+describe('Tree', () => {
+  it.each([
+    // Props
+    ['with as', { props: { as: 'div' } }],
+    ['with class', { props: { class: '' } }],
+    ['with ui', { props: { ui: {} } }],
+    // Slots
+    ['with default slot', { slots: { default: () => 'Default slot' } }]
+  ])('renders %s correctly', async (nameOrHtml: string, options: { props?: TreeProps, slots?: Partial<TreeSlots> }) => {
+    const html = await ComponentRender(nameOrHtml, options, Tree)
+    expect(html).toMatchSnapshot()
+  })
+})

From d0464d652479ea1fba1f855a2ad354f30be28a59 Mon Sep 17 00:00:00 2001
From: hywax <me@hywax.space>
Date: Mon, 25 Nov 2024 22:07:44 +0500
Subject: [PATCH 2/2] feat(Tree): up

---
 docs/content/3.components/tree.md        | 26 ++++++++
 playground/app/pages/components/tree.vue | 82 ++++++++++++++----------
 src/runtime/components/Tree.vue          | 28 ++++++--
 src/theme/tree.ts                        |  4 +-
 4 files changed, 99 insertions(+), 41 deletions(-)

diff --git a/docs/content/3.components/tree.md b/docs/content/3.components/tree.md
index d7268940a2..c02ad279e2 100644
--- a/docs/content/3.components/tree.md
+++ b/docs/content/3.components/tree.md
@@ -11,6 +11,32 @@ links:
 
 ## Usage
 
+### Items
+
+::component-code
+---
+ignore:
+  - items
+external:
+  - items
+props:
+  items:
+    - label: Node 1
+      children:
+        - label: Node 1.1
+        - label: Node 1.2
+        - label: Node 1.3
+          children:
+            - label: Node 1.3.1
+            - label: Node 1.3.2
+    - label: Node 2
+      children:
+        - label: Node 2.1
+        - label: Node 2.2
+    - label: Node 3
+---
+::
+
 ## Examples
 
 ## API
diff --git a/playground/app/pages/components/tree.vue b/playground/app/pages/components/tree.vue
index d048330342..ea448bd2c1 100644
--- a/playground/app/pages/components/tree.vue
+++ b/playground/app/pages/components/tree.vue
@@ -1,37 +1,53 @@
 <script setup lang="ts">
-const items = ref([
-  {
-    label: 'Backlog',
-    value: 'backlog',
-    icon: 'i-lucide-circle-help',
-    children: [
-      {
-        label: 'Backlog 1',
-        value: 'backlog-1',
-        icon: 'i-lucide-circle-help'
-      },
-      {
-        label: 'Backlog 2',
-        value: 'backlog-2',
-        icon: 'i-lucide-circle-help'
-      }
-    ]
-  },
-  {
-    label: 'Todo',
-    value: 'todo',
-    icon: 'i-lucide-circle-plus'
-  },
-  {
-    label: 'In Progress',
-    value: 'in_progress',
-    icon: 'i-lucide-circle-arrow-up'
-  },
-  {
-    label: 'Done',
-    value: 'done',
-    icon: 'i-lucide-circle-check'
-  }
+const items = ref([{
+  id: 1,
+  label: 'Level one 1',
+  children: [
+    {
+      id: 4,
+      label: 'Level two 1-1',
+      children: [
+        {
+          id: 9,
+          label: 'Level three 1-1-1'
+        },
+        {
+          id: 10,
+          label: 'Level three 1-1-2'
+        }
+      ]
+    }
+  ]
+},
+{
+  id: 2,
+  label: 'Level one 2',
+  children: [
+    {
+      id: 5,
+      label: 'Level two 2-1'
+    },
+    {
+      id: 6,
+      label: 'Level two 2-2'
+    }
+  ]
+},
+{
+  id: 3,
+  label: 'Level one 3',
+  disabled: true,
+  children: [
+    {
+      id: 7,
+      label: 'Level two 3-1'
+    },
+    {
+      id: 8,
+      label: 'Level two 3-2'
+    }
+  ]
+}
 ])
 </script>
 
diff --git a/src/runtime/components/Tree.vue b/src/runtime/components/Tree.vue
index a39d293b60..5e2caf0841 100644
--- a/src/runtime/components/Tree.vue
+++ b/src/runtime/components/Tree.vue
@@ -30,6 +30,16 @@ export interface TreeProps<T> extends Omit<TreeRootProps<T>, 'dir'> {
    * @defaultValue 'label'
    */
   labelKey?: string
+  /**
+   * The icon displayed when an item is selected.
+   * @defaultValue appConfig.ui.icons.check
+   */
+  selectedIcon?: string
+  /**
+   * The element or component this component should render as.
+   * @defaultValue 'div'
+   */
+  as?: any
   class?: any
   ui?: Partial<typeof tree.slots>
 }
@@ -65,18 +75,16 @@ const rootProps = useForwardPropsEmits(reactiveOmit(props, 'class', 'ui'), emits
 const [DefineItemTemplate, ReuseItemTemplate] = createReusableTemplate<{ item: TreeItem, index: number, level: number, hasChildren: boolean }>()
 
 const ui = computed(() => tree({
-  size: props.size
+  size: props.size,
 }))
 </script>
 
 <template>
   <DefineItemTemplate v-slot="{ item, index, level, hasChildren }">
     <slot :name="item.slot || 'item'" v-bind="{ item: item as T, index, level, hasChildren }">
-      <UIcon v-if="item.children?.length" :name="appConfig.ui.icons.chevronRight" :class="ui.itemTrailingIcon({ class: props.ui?.itemTrailingIcon })" />
-
       <slot :name="item.slot ? `${item.slot}-leading`: 'item-leading'" v-bind="{ item: item as T, index, level, hasChildren }">
         <UIcon v-if="item.icon" :name="item.icon" :class="ui.itemLeadingIcon({ class: props.ui?.itemLeadingIcon })" />
-        <UAvatar v-else-if="item.avatar" :size="((props.ui?.itemLeadingAvatarSize || ui.itemLeadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.itemLeadingAvatar({ class: props.ui?.itemLeadingAvatar })" />
+        <UAvatar v-else-if="item.avatar" v-bind="item.avatar" :size="((props.ui?.itemLeadingAvatarSize || ui.itemLeadingAvatarSize()) as AvatarProps['size'])" :class="ui.itemLeadingAvatar({ class: props.ui?.itemLeadingAvatar })" />
       </slot>
 
       <span v-if="get(item, props.labelKey) || !!slots[item.slot ? `${item.slot}-label`: 'item-label']" :class="ui.itemLabel({ class: props.ui?.itemLabel })">
@@ -87,12 +95,20 @@ const ui = computed(() => tree({
 
       <span :class="ui.itemTrailing({ class: props.ui?.itemTrailing })">
         <slot :name="item.slot ? `${item.slot}-trailing`: 'item-trailing'" v-bind="{ item: item as T, index, level, hasChildren }" />
+<!--        <UIcon :name="selectedIcon || appConfig.ui.icons.check" :class="ui.itemTrailingIcon({ class: props.ui?.itemTrailingIcon })" />-->
       </span>
     </slot>
   </DefineItemTemplate>
 
-  <TreeRoot v-slot="{ flattenItems }" v-bind="rootProps" :class="ui.root({ class: [props.class, props.ui?.root] })">
-    <TreeItemComponent v-for="item in flattenItems" v-bind="item.bind" :key="item._id" :class="ui.item({ class: [props.ui?.item] })">
+  <TreeRoot v-slot="{ flattenItems }" v-bind="rootProps" :class="ui.root({ class: [props.class, props.ui?.root] })" :get-key="(item) => item.label">
+    <TreeItemComponent
+      v-for="item in flattenItems"
+      v-bind="item.bind"
+      :key="item._id"
+      :class="ui.item({ class: [props.ui?.item] })"
+      :style="{ 'padding-left': `${item.level - 0.5}rem` }"
+      :data-disabled="item.value.disabled ? 'true' : undefined"
+    >
       <ReuseItemTemplate :item="item.value" :index="item.index" :level="item.level" :has-children="item.hasChildren" />
     </TreeItemComponent>
   </TreeRoot>
diff --git a/src/theme/tree.ts b/src/theme/tree.ts
index 201caeac38..e237e00b5f 100644
--- a/src/theme/tree.ts
+++ b/src/theme/tree.ts
@@ -2,8 +2,8 @@ import type { ModuleOptions } from '../module'
 
 export default (options: Required<ModuleOptions>) => ({
   slots: {
-    root: '',
-    item: ['group relative w-full flex items-center gap-2 px-2 py-1.5 text-sm select-none outline-none before:absolute before:z-[-1] before:inset-px before:rounded-[calc(var(--ui-radius)*1.5)] data-disabled:cursor-not-allowed data-disabled:opacity-75 text-[var(--ui-text)] data-[selected]:text-[var(--ui-text-highlighted)] data-[selected]:before:bg-[var(--ui-bg-elevated)]/50', options.theme.transitions && 'transition-colors before:transition-colors'],
+    root: 'list-none min-w-32',
+    item: ['group relative w-full flex items-center gap-2 px-2 py-1.5 text-sm select-none outline-none rounded-[calc(var(--ui-radius)*1.5)] data-disabled:cursor-not-allowed data-disabled:opacity-75 text-[var(--ui-text)] hover:text-[var(--ui-text-highlighted)] hover:bg-[var(--ui-bg-elevated)]/50 focus:bg-[var(--ui-bg-elevated)]/50', options.theme.transitions && 'transition-colors before:transition-colors'],
     itemLeadingIcon: 'shrink-0',
     itemLeadingAvatar: 'shrink-0',
     itemLeadingAvatarSize: '',