mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-14 12:14:41 +01:00
feat(Tree): new component (#3180)
Co-authored-by: hywax <me@hywax.space> Co-authored-by: Benjamin Canac <canacb1@gmail.com> Co-authored-by: Sébastien Chopin <atinux@gmail.com> Co-authored-by: Sébastien Chopin <seb@nuxt.com>
This commit is contained in:
@@ -97,7 +97,7 @@ export function devtoolsMetaPlugin({ resolve, options, templates }: { resolve: R
|
||||
&& !tag.text?.includes('appConfig'))?.text
|
||||
?? template?.defaultVariants?.[prop.name]
|
||||
|
||||
if (typeof defaultValue === 'string') defaultValue = defaultValue?.replaceAll(/["'`]/g, '')
|
||||
if (typeof defaultValue === 'string') defaultValue = defaultValue?.replace(/\s+as\s+\w+$/g, '').replaceAll(/["'`]/g, '')
|
||||
if (defaultValue === 'true') defaultValue = true
|
||||
if (defaultValue === 'false') defaultValue = false
|
||||
if (!Number.isNaN(Number.parseInt(defaultValue))) defaultValue = Number.parseInt(defaultValue)
|
||||
|
||||
231
src/runtime/components/Tree.vue
Normal file
231
src/runtime/components/Tree.vue
Normal file
@@ -0,0 +1,231 @@
|
||||
<script lang="ts">
|
||||
import type { VariantProps } from 'tailwind-variants'
|
||||
import type { TreeRootProps, TreeRootEmits } from 'reka-ui'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import _appConfig from '#build/app.config'
|
||||
import theme from '#build/ui/tree'
|
||||
import { extendDevtoolsMeta } from '../composables/extendDevtoolsMeta'
|
||||
import { tv } from '../utils/tv'
|
||||
import type { PartialString, DynamicSlots, MaybeMultipleModelValue, SelectItemKey } 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 type TreeItem = {
|
||||
icon?: string
|
||||
label?: string
|
||||
trailingIcon?: string
|
||||
defaultExpanded?: boolean
|
||||
disabled?: boolean
|
||||
value?: string
|
||||
slot?: string
|
||||
children?: TreeItem[]
|
||||
onToggle?(e: Event): void
|
||||
onSelect?(e?: Event): void
|
||||
}
|
||||
|
||||
export interface TreeProps<T extends TreeItem, M extends boolean = false, K extends SelectItemKey<T> | undefined = undefined> extends Pick<TreeRootProps<T>, 'expanded' | 'defaultExpanded' | 'selectionBehavior' | 'propagateSelect' | 'disabled'> {
|
||||
/**
|
||||
* The element or component this component should render as.
|
||||
* @defaultValue 'ul'
|
||||
*/
|
||||
as?: any
|
||||
color?: TreeVariants['color']
|
||||
size?: TreeVariants['size']
|
||||
/**
|
||||
* The key used to get the value from the item.
|
||||
* @defaultValue 'value'
|
||||
*/
|
||||
valueKey?: K
|
||||
/**
|
||||
* The key used to get the label from the item.
|
||||
* @defaultValue 'label'
|
||||
*/
|
||||
labelKey?: K
|
||||
/**
|
||||
* The icon displayed on the right side of a parent node.
|
||||
* @defaultValue appConfig.ui.icons.chevronDown
|
||||
*/
|
||||
trailingIcon?: string
|
||||
/**
|
||||
* The icon displayed when a parent node is expanded.
|
||||
* @defaultValue appConfig.ui.icons.folderOpen
|
||||
*/
|
||||
expandedIcon?: string
|
||||
/**
|
||||
* The icon displayed when a parent node is collapsed.
|
||||
* @defaultValue appConfig.ui.icons.folder
|
||||
*/
|
||||
collapsedIcon?: string
|
||||
items?: T[]
|
||||
/** The controlled value of the Tree. Can be bind as `v-model`. */
|
||||
modelValue?: MaybeMultipleModelValue<T, M>
|
||||
/** The value of the Tree when initially rendered. Use when you do not need to control the state of the Tree. */
|
||||
defaultValue?: MaybeMultipleModelValue<T, M>
|
||||
/** Whether multiple options can be selected or not. */
|
||||
multiple?: M & boolean
|
||||
class?: any
|
||||
ui?: PartialString<typeof tree.slots>
|
||||
}
|
||||
|
||||
export type TreeEmits<T, M extends boolean = false> = Omit<TreeRootEmits, 'update:modelValue'> & {
|
||||
'update:modelValue': [payload: MaybeMultipleModelValue<T, M>]
|
||||
}
|
||||
|
||||
type SlotProps<T> = (props: { item: T, index: number, level: number, expanded: boolean, selected: 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>>
|
||||
|
||||
extendDevtoolsMeta({ defaultProps: {
|
||||
items: [
|
||||
{
|
||||
label: 'app',
|
||||
icon: 'i-lucide-folder',
|
||||
defaultExpanded: true,
|
||||
children: [{
|
||||
label: 'composables',
|
||||
icon: 'i-lucide-folder',
|
||||
defaultExpanded: true,
|
||||
children: [
|
||||
{ label: 'useAuth.ts', icon: 'vscode-icons:file-type-typescript' },
|
||||
{ label: 'useUser.ts', icon: 'vscode-icons:file-type-typescript' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'components',
|
||||
icon: 'i-lucide-folder',
|
||||
children: [
|
||||
{
|
||||
label: 'Home',
|
||||
icon: 'i-lucide-folder',
|
||||
children: [
|
||||
{ label: 'Card.vue', icon: 'vscode-icons:file-type-vue' },
|
||||
{ label: 'Button.vue', icon: 'vscode-icons:file-type-vue' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}]
|
||||
},
|
||||
{ label: 'app.vue', icon: 'vscode-icons:file-type-vue' },
|
||||
{ label: 'nuxt.config.ts', icon: 'vscode-icons:file-type-nuxt' }
|
||||
]
|
||||
} })
|
||||
</script>
|
||||
|
||||
<script setup lang="ts" generic="T extends TreeItem, M extends boolean = false, K extends SelectItemKey<T> | undefined = undefined">
|
||||
import { computed } from 'vue'
|
||||
import { TreeRoot, TreeItem, useForwardPropsEmits } from 'reka-ui'
|
||||
import { reactivePick, createReusableTemplate } from '@vueuse/core'
|
||||
import { get } from '../utils'
|
||||
import UIcon from './Icon.vue'
|
||||
|
||||
const props = withDefaults(defineProps<TreeProps<T, M, K>>(), {
|
||||
labelKey: 'label' as never,
|
||||
valueKey: 'value' as never
|
||||
})
|
||||
const emits = defineEmits<TreeEmits<T, M>>()
|
||||
const slots = defineSlots<TreeSlots<T>>()
|
||||
|
||||
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'items', 'multiple', 'expanded', 'disabled', 'propagateSelect'), emits)
|
||||
|
||||
const [DefineTreeTemplate, ReuseTreeTemplate] = createReusableTemplate<{ items?: T[], level: number }>({
|
||||
props: {
|
||||
items: Array as PropType<T[]>,
|
||||
level: Number
|
||||
}
|
||||
})
|
||||
|
||||
const ui = computed(() => tree({
|
||||
color: props.color,
|
||||
size: props.size
|
||||
}))
|
||||
|
||||
function getItemLabel(item: T) {
|
||||
return get(item, props.labelKey as string)
|
||||
}
|
||||
|
||||
function getItemValue(item?: T) {
|
||||
return get(item, props.valueKey as string) ?? get(item, props.labelKey as string)
|
||||
}
|
||||
|
||||
function getDefaultOpenedItems(item: T): string[] {
|
||||
const currentItem = item.defaultExpanded ? getItemValue(item) : null
|
||||
const childItems = item.children?.flatMap(child => getDefaultOpenedItems(child as T)) ?? []
|
||||
|
||||
return [currentItem, ...childItems].filter(Boolean) as string[]
|
||||
}
|
||||
|
||||
const defaultExpanded = computed(() => props.defaultExpanded ?? props.items?.flatMap(getDefaultOpenedItems))
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-template-shadow -->
|
||||
<template>
|
||||
<DefineTreeTemplate v-slot="{ items, level }">
|
||||
<li
|
||||
v-for="(item, index) in items"
|
||||
:key="`${level}-${index}`"
|
||||
:class="level > 0 ? ui.itemWithChildren({ class: props.ui?.itemWithChildren }) : ui.item({ class: props.ui?.item })"
|
||||
>
|
||||
<TreeItem
|
||||
v-slot="{ isExpanded, isSelected }"
|
||||
as-child
|
||||
:level="level"
|
||||
:value="item"
|
||||
@toggle="item.onToggle"
|
||||
@select="item.onSelect"
|
||||
>
|
||||
<button :disabled="item.disabled || disabled" :class="ui.link({ class: props.ui?.link, selected: isSelected, disabled: item.disabled || disabled })">
|
||||
<slot :name="item.slot || 'item'" v-bind="{ item, index, level, expanded: isExpanded, selected: isSelected }">
|
||||
<slot :name="item.slot ? `${item.slot}-leading`: 'item-leading'" v-bind="{ item, index, level, expanded: isExpanded, selected: isSelected }">
|
||||
<UIcon
|
||||
v-if="item.icon"
|
||||
:name="item.icon"
|
||||
:class="ui.linkLeadingIcon({ class: props.ui?.linkLeadingIcon })"
|
||||
/>
|
||||
<UIcon
|
||||
v-else-if="item.children?.length"
|
||||
:name="isExpanded ? (expandedIcon ?? appConfig.ui.icons.folderOpen) : (collapsedIcon ?? appConfig.ui.icons.folder)"
|
||||
:class="ui.linkLeadingIcon({ class: props.ui?.linkLeadingIcon })"
|
||||
/>
|
||||
</slot>
|
||||
|
||||
<span v-if="getItemLabel(item) || !!slots[item.slot ? `${item.slot}-label`: 'item-label']" :class="ui.linkLabel({ class: props.ui?.linkLabel })">
|
||||
<slot :name="item.slot ? `${item.slot}-label`: 'item-label'" v-bind="{ item, index, level, expanded: isExpanded, selected: isSelected }">
|
||||
{{ getItemLabel(item) }}
|
||||
</slot>
|
||||
</span>
|
||||
|
||||
<span v-if="item.trailingIcon || item.children?.length || !!slots[item.slot ? `${item.slot}-trailing`: 'item-trailing']" :class="ui.linkTrailing({ class: props.ui?.linkTrailing })">
|
||||
<slot :name="item.slot ? `${item.slot}-trailing`: 'item-trailing'" v-bind="{ item, index, level, expanded: isExpanded, selected: isSelected }">
|
||||
<UIcon v-if="item.trailingIcon" :name="item.trailingIcon" :class="ui.linkTrailingIcon({ class: props.ui?.linkTrailingIcon })" />
|
||||
<UIcon v-else-if="item.children?.length" :name="trailingIcon ?? appConfig.ui.icons.chevronDown" :class="ui.linkTrailingIcon({ class: props.ui?.linkTrailingIcon })" />
|
||||
</slot>
|
||||
</span>
|
||||
</slot>
|
||||
</button>
|
||||
|
||||
<ul v-if="item.children?.length && isExpanded" :class="ui.listWithChildren({ class: props.ui?.listWithChildren })">
|
||||
<ReuseTreeTemplate :items="(item.children as T[])" :level="level + 1" />
|
||||
</ul>
|
||||
</TreeItem>
|
||||
</li>
|
||||
</DefineTreeTemplate>
|
||||
|
||||
<TreeRoot
|
||||
v-bind="rootProps"
|
||||
:class="ui.root({ class: [props.class, props.ui?.root] })"
|
||||
:get-key="getItemValue"
|
||||
:default-expanded="defaultExpanded"
|
||||
:selection-behavior="selectionBehavior"
|
||||
>
|
||||
<ReuseTreeTemplate :items="items" :level="0" />
|
||||
</TreeRoot>
|
||||
</template>
|
||||
@@ -46,6 +46,7 @@ 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'
|
||||
export * from './utils'
|
||||
|
||||
@@ -36,6 +36,8 @@ export type SelectModelValueEmits<T, V, M extends boolean = false, DV = T> = {
|
||||
'update:modelValue': [payload: SelectModelValue<T, V, M, DV>]
|
||||
}
|
||||
|
||||
export type MaybeMultipleModelValue<T, M extends boolean = false> = (T extends infer U ? M extends true ? U[] : U : never)
|
||||
|
||||
export type StringOrVNode =
|
||||
| string
|
||||
| VNode
|
||||
|
||||
@@ -11,6 +11,8 @@ export default {
|
||||
close: 'i-lucide-x',
|
||||
ellipsis: 'i-lucide-ellipsis',
|
||||
external: 'i-lucide-arrow-up-right',
|
||||
folder: 'i-lucide-folder',
|
||||
folderOpen: 'i-lucide-folder-open',
|
||||
loading: 'i-lucide-refresh-cw',
|
||||
minus: 'i-lucide-minus',
|
||||
plus: 'i-lucide-plus',
|
||||
|
||||
@@ -46,3 +46,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'
|
||||
|
||||
82
src/theme/tree.ts
Normal file
82
src/theme/tree.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { ModuleOptions } from '../module'
|
||||
|
||||
export default (options: Required<ModuleOptions>) => ({
|
||||
slots: {
|
||||
root: 'relative isolate',
|
||||
item: '',
|
||||
listWithChildren: 'ms-4.5 border-s border-(--ui-border)',
|
||||
itemWithChildren: 'ps-1.5 -ms-px',
|
||||
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-[calc(var(--ui-radius)*1.5)] focus:outline-none focus-visible:outline-none focus-visible:before:ring-inset focus-visible:before:ring-2',
|
||||
linkLeadingIcon: 'shrink-0',
|
||||
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'
|
||||
},
|
||||
variants: {
|
||||
color: {
|
||||
...Object.fromEntries((options.theme.colors || []).map((color: string) => [color, {
|
||||
link: `focus-visible:before:ring-(--ui-${color})`
|
||||
}])),
|
||||
neutral: {
|
||||
link: 'focus-visible:before:ring-(--ui-border-inverted)'
|
||||
}
|
||||
},
|
||||
size: {
|
||||
xs: {
|
||||
link: 'px-2 py-1 text-xs gap-1',
|
||||
linkLeadingIcon: 'size-4',
|
||||
linkTrailingIcon: 'size-4'
|
||||
},
|
||||
sm: {
|
||||
link: 'px-2.5 py-1.5 text-xs gap-1.5',
|
||||
linkLeadingIcon: 'size-4',
|
||||
linkTrailingIcon: 'size-4'
|
||||
},
|
||||
md: {
|
||||
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: {
|
||||
true: {
|
||||
link: 'before:bg-(--ui-bg-elevated)'
|
||||
},
|
||||
false: {
|
||||
link: ['hover:not-disabled:text-(--ui-text-highlighted) hover:not-disabled:before:bg-(--ui-bg-elevated)/50', options.theme.transitions && 'transition-colors before:transition-colors']
|
||||
}
|
||||
},
|
||||
disabled: {
|
||||
true: {
|
||||
link: 'cursor-not-allowed opacity-75'
|
||||
}
|
||||
}
|
||||
},
|
||||
compoundVariants: [...(options.theme.colors || []).map((color: string) => ({
|
||||
color,
|
||||
selected: true,
|
||||
class: {
|
||||
link: `text-(--ui-${color})`
|
||||
}
|
||||
})), {
|
||||
color: 'neutral',
|
||||
selected: true,
|
||||
class: {
|
||||
link: 'text-(--ui-text-highlighted)'
|
||||
}
|
||||
}],
|
||||
defaultVariants: {
|
||||
color: 'primary',
|
||||
size: 'md'
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user