|
@@ -4,7 +4,9 @@ import type { FlattenedItem } from 'radix-vue';
|
|
|
|
|
|
import type { ClassType, Recordable } from '@vben-core/typings';
|
|
|
|
|
|
-import { onMounted, ref, watch, watchEffect } from 'vue';
|
|
|
+import type { TreeProps } from './types';
|
|
|
+
|
|
|
+import { onMounted, ref, watchEffect } from 'vue';
|
|
|
|
|
|
import { ChevronRight, IconifyIcon } from '@vben-core/icons';
|
|
|
import { cn, get } from '@vben-core/shared/utils';
|
|
@@ -14,46 +16,13 @@ import { TreeItem, TreeRoot } from 'radix-vue';
|
|
|
|
|
|
import { Checkbox } from '../checkbox';
|
|
|
|
|
|
-interface TreeProps {
|
|
|
- /** 单选时允许取消已有选项 */
|
|
|
- allowClear?: boolean;
|
|
|
- /** 显示边框 */
|
|
|
- bordered?: boolean;
|
|
|
- /** 取消父子关联选择 */
|
|
|
- checkStrictly?: boolean;
|
|
|
- /** 子级字段名 */
|
|
|
- childrenField?: string;
|
|
|
- /** 默认展开的键 */
|
|
|
- defaultExpandedKeys?: Array<number | string>;
|
|
|
- /** 默认展开的级别(优先级高于defaultExpandedKeys) */
|
|
|
- defaultExpandedLevel?: number;
|
|
|
- /** 默认值 */
|
|
|
- defaultValue?: Arrayable<number | string>;
|
|
|
- /** 禁用 */
|
|
|
- disabled?: boolean;
|
|
|
- /** 自定义节点类名 */
|
|
|
- getNodeClass?: (item: FlattenedItem<Recordable<any>>) => string;
|
|
|
- iconField?: string;
|
|
|
- /** label字段 */
|
|
|
- labelField?: string;
|
|
|
- /** 当前值 */
|
|
|
- modelValue?: Arrayable<number | string>;
|
|
|
- /** 是否多选 */
|
|
|
- multiple?: boolean;
|
|
|
- /** 显示由iconField指定的图标 */
|
|
|
- showIcon?: boolean;
|
|
|
- /** 启用展开收缩动画 */
|
|
|
- transition?: boolean;
|
|
|
- /** 树数据 */
|
|
|
- treeData: Recordable<any>[];
|
|
|
- /** 值字段 */
|
|
|
- valueField?: string;
|
|
|
-}
|
|
|
const props = withDefaults(defineProps<TreeProps>(), {
|
|
|
allowClear: false,
|
|
|
+ autoCheckParent: true,
|
|
|
bordered: false,
|
|
|
checkStrictly: false,
|
|
|
defaultExpandedKeys: () => [],
|
|
|
+ defaultExpandedLevel: 0,
|
|
|
disabled: false,
|
|
|
expanded: () => [],
|
|
|
iconField: 'icon',
|
|
@@ -61,7 +30,7 @@ const props = withDefaults(defineProps<TreeProps>(), {
|
|
|
modelValue: () => [],
|
|
|
multiple: false,
|
|
|
showIcon: true,
|
|
|
- transition: false,
|
|
|
+ transition: true,
|
|
|
valueField: 'value',
|
|
|
childrenField: 'children',
|
|
|
});
|
|
@@ -72,28 +41,36 @@ const emits = defineEmits<{
|
|
|
'update:modelValue': [value: Arrayable<Recordable<any>>];
|
|
|
}>();
|
|
|
|
|
|
-interface InnerFlattenItem<T = Recordable<any>> {
|
|
|
+interface InnerFlattenItem<T = Recordable<any>, P = number | string> {
|
|
|
hasChildren: boolean;
|
|
|
level: number;
|
|
|
+ parents: P[];
|
|
|
value: T;
|
|
|
}
|
|
|
|
|
|
-function flatten<T = Recordable<any>>(
|
|
|
+function flatten<T = Recordable<any>, P = number | string>(
|
|
|
items: T[],
|
|
|
childrenField: string = 'children',
|
|
|
level = 0,
|
|
|
-): InnerFlattenItem<T>[] {
|
|
|
- const result: InnerFlattenItem<T>[] = [];
|
|
|
+ parents: P[] = [],
|
|
|
+): InnerFlattenItem<T, P>[] {
|
|
|
+ const result: InnerFlattenItem<T, P>[] = [];
|
|
|
items.forEach((item) => {
|
|
|
const children = get(item, childrenField) as Array<T>;
|
|
|
const val = {
|
|
|
hasChildren: Array.isArray(children) && children.length > 0,
|
|
|
level,
|
|
|
+ parents: [...parents],
|
|
|
value: item,
|
|
|
};
|
|
|
result.push(val);
|
|
|
if (val.hasChildren)
|
|
|
- result.push(...flatten(children, childrenField, level + 1));
|
|
|
+ result.push(
|
|
|
+ ...flatten(children, childrenField, level + 1, [
|
|
|
+ ...parents,
|
|
|
+ get(item, props.valueField),
|
|
|
+ ]),
|
|
|
+ );
|
|
|
});
|
|
|
return result;
|
|
|
}
|
|
@@ -133,14 +110,6 @@ function updateTreeValue() {
|
|
|
: getItemByValue(val);
|
|
|
}
|
|
|
|
|
|
-watch(
|
|
|
- modelValue,
|
|
|
- () => {
|
|
|
- updateTreeValue();
|
|
|
- },
|
|
|
- { deep: true, immediate: true },
|
|
|
-);
|
|
|
-
|
|
|
function updateModelValue(val: Arrayable<Recordable<any>>) {
|
|
|
modelValue.value = Array.isArray(val)
|
|
|
? val.map((v) => get(v, props.valueField))
|
|
@@ -186,7 +155,33 @@ function collapseAll() {
|
|
|
function onToggle(item: FlattenedItem<Recordable<any>>) {
|
|
|
emits('expand', item);
|
|
|
}
|
|
|
-function onSelect(item: FlattenedItem<Recordable<any>>) {
|
|
|
+function onSelect(item: FlattenedItem<Recordable<any>>, isSelected: boolean) {
|
|
|
+ if (
|
|
|
+ !props.checkStrictly &&
|
|
|
+ props.multiple &&
|
|
|
+ props.autoCheckParent &&
|
|
|
+ isSelected
|
|
|
+ ) {
|
|
|
+ flattenData.value
|
|
|
+ .find((i) => {
|
|
|
+ return (
|
|
|
+ get(i.value, props.valueField) === get(item.value, props.valueField)
|
|
|
+ );
|
|
|
+ })
|
|
|
+ ?.parents?.forEach((p) => {
|
|
|
+ if (Array.isArray(modelValue.value) && !modelValue.value.includes(p)) {
|
|
|
+ modelValue.value.push(p);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ } else {
|
|
|
+ if (Array.isArray(modelValue.value)) {
|
|
|
+ const index = modelValue.value.indexOf(get(item.value, props.valueField));
|
|
|
+ if (index !== -1) {
|
|
|
+ modelValue.value.splice(index, 1);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ updateTreeValue();
|
|
|
emits('select', item);
|
|
|
}
|
|
|
|
|
@@ -224,78 +219,130 @@ defineExpose({
|
|
|
<div class="w-full" v-if="$slots.header">
|
|
|
<slot name="header"> </slot>
|
|
|
</div>
|
|
|
- <TreeItem
|
|
|
- v-for="item in flattenItems"
|
|
|
- v-slot="{
|
|
|
- isExpanded,
|
|
|
- isSelected,
|
|
|
- isIndeterminate,
|
|
|
- handleSelect,
|
|
|
- handleToggle,
|
|
|
- }"
|
|
|
- :key="item._id"
|
|
|
- :style="{ 'padding-left': `${item.level - 0.5}rem` }"
|
|
|
- :class="
|
|
|
- cn('cursor-pointer', getNodeClass?.(item), {
|
|
|
- 'data-[selected]:bg-accent': !multiple,
|
|
|
- })
|
|
|
- "
|
|
|
- v-bind="item.bind"
|
|
|
- @select="
|
|
|
- (event) => {
|
|
|
- if (event.detail.originalEvent.type === 'click') {
|
|
|
- // event.preventDefault();
|
|
|
- }
|
|
|
- onSelect(item);
|
|
|
- }
|
|
|
- "
|
|
|
- @toggle="
|
|
|
- (event) => {
|
|
|
- if (event.detail.originalEvent.type === 'click') {
|
|
|
- event.preventDefault();
|
|
|
- }
|
|
|
- onToggle(item);
|
|
|
- }
|
|
|
- "
|
|
|
- class="tree-node focus:ring-grass8 my-0.5 flex items-center rounded px-2 py-1 outline-none focus:ring-2"
|
|
|
+ <TransitionGroup
|
|
|
+ :name="transition ? 'fade' : ''"
|
|
|
+ mode="out-in"
|
|
|
+ class="container"
|
|
|
>
|
|
|
- <ChevronRight
|
|
|
- v-if="item.hasChildren"
|
|
|
- class="size-4 cursor-pointer transition"
|
|
|
- :class="{ 'rotate-90': isExpanded }"
|
|
|
- @click.stop="handleToggle"
|
|
|
- />
|
|
|
- <div v-else class="h-4 w-4">
|
|
|
- <!-- <IconifyIcon v-if="item.value.icon" :icon="item.value.icon" /> -->
|
|
|
- </div>
|
|
|
- <Checkbox
|
|
|
- v-if="multiple"
|
|
|
- :checked="isSelected"
|
|
|
- :indeterminate="isIndeterminate"
|
|
|
- @click.stop="handleSelect"
|
|
|
- />
|
|
|
- <div
|
|
|
- class="flex items-center gap-1 pl-2"
|
|
|
- @click="
|
|
|
- ($event) => {
|
|
|
- $event.stopPropagation();
|
|
|
- $event.preventDefault();
|
|
|
- handleSelect();
|
|
|
+ <TreeItem
|
|
|
+ v-for="item in flattenItems"
|
|
|
+ v-slot="{
|
|
|
+ isExpanded,
|
|
|
+ isSelected,
|
|
|
+ isIndeterminate,
|
|
|
+ handleSelect,
|
|
|
+ handleToggle,
|
|
|
+ }"
|
|
|
+ :key="item._id"
|
|
|
+ :style="{ 'padding-left': `${item.level - 0.5}rem` }"
|
|
|
+ :class="
|
|
|
+ cn('cursor-pointer', getNodeClass?.(item), {
|
|
|
+ 'data-[selected]:bg-accent': !multiple,
|
|
|
+ })
|
|
|
+ "
|
|
|
+ v-bind="item.bind"
|
|
|
+ @select="
|
|
|
+ (event) => {
|
|
|
+ if (event.detail.originalEvent.type === 'click') {
|
|
|
+ // event.preventDefault();
|
|
|
+ }
|
|
|
+ onSelect(item, event.detail.isSelected);
|
|
|
}
|
|
|
"
|
|
|
+ @toggle="
|
|
|
+ (event) => {
|
|
|
+ if (event.detail.originalEvent.type === 'click') {
|
|
|
+ event.preventDefault();
|
|
|
+ }
|
|
|
+ onToggle(item);
|
|
|
+ }
|
|
|
+ "
|
|
|
+ class="tree-node focus:ring-grass8 my-0.5 flex items-center rounded px-2 py-1 outline-none focus:ring-2"
|
|
|
>
|
|
|
- <slot name="node" v-bind="item">
|
|
|
- <IconifyIcon
|
|
|
- class="size-4"
|
|
|
- v-if="showIcon && get(item.value, iconField)"
|
|
|
- :icon="get(item.value, iconField)"
|
|
|
- />
|
|
|
- {{ get(item.value, labelField) }}
|
|
|
- </slot>
|
|
|
- </div>
|
|
|
- </TreeItem>
|
|
|
+ <ChevronRight
|
|
|
+ v-if="item.hasChildren"
|
|
|
+ class="size-4 cursor-pointer transition"
|
|
|
+ :class="{ 'rotate-90': isExpanded }"
|
|
|
+ @click.stop="
|
|
|
+ () => {
|
|
|
+ handleToggle();
|
|
|
+ onToggle(item);
|
|
|
+ }
|
|
|
+ "
|
|
|
+ />
|
|
|
+ <div v-else class="h-4 w-4">
|
|
|
+ <!-- <IconifyIcon v-if="item.value.icon" :icon="item.value.icon" /> -->
|
|
|
+ </div>
|
|
|
+ <Checkbox
|
|
|
+ v-if="multiple"
|
|
|
+ :checked="isSelected"
|
|
|
+ :indeterminate="isIndeterminate"
|
|
|
+ @click="
|
|
|
+ () => {
|
|
|
+ handleSelect();
|
|
|
+ // onSelect(item, !isSelected);
|
|
|
+ }
|
|
|
+ "
|
|
|
+ />
|
|
|
+ <div
|
|
|
+ class="flex items-center gap-1 pl-2"
|
|
|
+ @click="
|
|
|
+ (_event) => {
|
|
|
+ // $event.stopPropagation();
|
|
|
+ // $event.preventDefault();
|
|
|
+ handleSelect();
|
|
|
+ // onSelect(item, !isSelected);
|
|
|
+ }
|
|
|
+ "
|
|
|
+ >
|
|
|
+ <slot name="node" v-bind="item">
|
|
|
+ <IconifyIcon
|
|
|
+ class="size-4"
|
|
|
+ v-if="showIcon && get(item.value, iconField)"
|
|
|
+ :icon="get(item.value, iconField)"
|
|
|
+ />
|
|
|
+ {{ get(item.value, labelField) }}
|
|
|
+ </slot>
|
|
|
+ </div>
|
|
|
+ </TreeItem>
|
|
|
+ </TransitionGroup>
|
|
|
<div class="w-full" v-if="$slots.footer">
|
|
|
<slot name="footer"> </slot>
|
|
|
</div>
|
|
|
</TreeRoot>
|
|
|
</template>
|
|
|
+<style lang="scss" scoped>
|
|
|
+.container {
|
|
|
+ position: relative;
|
|
|
+ padding: 0;
|
|
|
+ list-style-type: none;
|
|
|
+}
|
|
|
+
|
|
|
+.item {
|
|
|
+ box-sizing: border-box;
|
|
|
+ width: 100%;
|
|
|
+ height: 30px;
|
|
|
+ background-color: #f3f3f3;
|
|
|
+ border: 1px solid #666;
|
|
|
+}
|
|
|
+
|
|
|
+/* 1. 声明过渡效果 */
|
|
|
+.fade-move,
|
|
|
+.fade-enter-active,
|
|
|
+.fade-leave-active {
|
|
|
+ transition: all 0.5s cubic-bezier(0.55, 0, 0.1, 1);
|
|
|
+}
|
|
|
+
|
|
|
+/* 2. 声明进入和离开的状态 */
|
|
|
+.fade-enter-from,
|
|
|
+.fade-leave-to {
|
|
|
+ opacity: 0;
|
|
|
+ transform: scaleY(0.01) translate(30px, 0);
|
|
|
+}
|
|
|
+
|
|
|
+/* 3. 确保离开的项目被移除出了布局流
|
|
|
+ 以便正确地计算移动时的动画效果。 */
|
|
|
+.fade-leave-active {
|
|
|
+ position: absolute;
|
|
|
+}
|
|
|
+</style>
|