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: '',