2024-09-13 09:36:41 +07:00
|
|
|
<script setup lang="ts">
|
|
|
|
|
import { Icon } from '@iconify/vue';
|
2024-09-30 16:35:41 +07:00
|
|
|
import { computed } from 'vue';
|
2024-10-02 15:06:39 +07:00
|
|
|
import DeleteButton from '../button/DeleteButton.vue';
|
2024-09-16 14:47:37 +07:00
|
|
|
|
2024-09-13 09:36:41 +07:00
|
|
|
type Node = {
|
|
|
|
|
[key: string]: any;
|
2024-09-16 13:25:06 +07:00
|
|
|
opened?: boolean;
|
2024-09-27 16:11:16 +07:00
|
|
|
checked?: boolean;
|
2024-09-26 11:51:59 +07:00
|
|
|
bg?: string;
|
|
|
|
|
fg?: string;
|
|
|
|
|
icon?: string;
|
2024-09-30 16:35:41 +07:00
|
|
|
displayDropIcon?: boolean;
|
2024-09-13 09:36:41 +07:00
|
|
|
children?: Node[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type Props = {
|
|
|
|
|
level?: number;
|
|
|
|
|
keyTitle?: string;
|
|
|
|
|
keySubtitle?: string;
|
2024-09-25 17:47:01 +07:00
|
|
|
iconSize?: string;
|
2024-10-02 15:06:39 +07:00
|
|
|
|
|
|
|
|
hideCheckBox?: boolean;
|
|
|
|
|
expandable?: boolean;
|
2024-09-27 16:11:16 +07:00
|
|
|
selectable?: boolean;
|
2024-10-02 15:06:39 +07:00
|
|
|
deleteable?: boolean;
|
2024-10-15 16:05:39 +07:00
|
|
|
deleteableDeep?: boolean;
|
2024-10-02 15:06:39 +07:00
|
|
|
movable?: boolean;
|
|
|
|
|
|
2024-09-27 16:11:16 +07:00
|
|
|
selectedNode?: Node[];
|
2024-09-30 13:23:51 +07:00
|
|
|
ancestorNode?: Node[];
|
2024-09-13 09:36:41 +07:00
|
|
|
decoration?: {
|
|
|
|
|
level?: number;
|
|
|
|
|
bg?: string;
|
|
|
|
|
fg?: string;
|
|
|
|
|
icon?: string;
|
|
|
|
|
}[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const props = defineProps<Props>();
|
|
|
|
|
const nodes = defineModel<Node[]>('nodes', { required: true });
|
2024-10-16 13:19:31 +07:00
|
|
|
const filterText = defineModel<string>('filterText', { required: false });
|
2024-09-13 09:43:21 +07:00
|
|
|
|
2024-09-27 16:11:16 +07:00
|
|
|
const emits = defineEmits<{
|
|
|
|
|
(e: 'checked'): void;
|
2024-10-03 15:27:03 +07:00
|
|
|
(e: 'unchecked'): void;
|
2024-09-30 13:23:51 +07:00
|
|
|
(e: 'select', node: Node, ancestor?: Node[]): void;
|
2024-09-30 16:35:41 +07:00
|
|
|
(e: 'open', node: Node, ancestor?: Node[]): void;
|
2024-10-02 15:06:39 +07:00
|
|
|
(e: 'delete', node: Node): void;
|
|
|
|
|
(e: 'moveUp', node: Node): void;
|
|
|
|
|
(e: 'moveDown', node: Node): void;
|
2024-09-27 16:11:16 +07:00
|
|
|
}>();
|
2024-09-18 18:02:18 +07:00
|
|
|
|
2024-09-13 09:43:21 +07:00
|
|
|
const dec = props.decoration?.find((v) => v.level === (props.level || 0));
|
2024-09-25 17:47:01 +07:00
|
|
|
const maxLevel = computed(() =>
|
|
|
|
|
props.decoration?.reduce((max, v) => {
|
|
|
|
|
return v.level && v.level > max ? v.level : max;
|
|
|
|
|
}, 0),
|
|
|
|
|
);
|
2024-09-18 18:02:18 +07:00
|
|
|
|
2024-10-03 15:27:03 +07:00
|
|
|
function recursiveSelect(node: Node) {
|
|
|
|
|
if (node.children) {
|
|
|
|
|
node.children.forEach((v) => {
|
|
|
|
|
v.checked = true;
|
|
|
|
|
recursiveSelect(v);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-09-18 18:02:18 +07:00
|
|
|
function recursiveDeselect(node: Node) {
|
|
|
|
|
if (node.children) {
|
|
|
|
|
node.children.forEach((v) => {
|
2024-09-27 16:11:16 +07:00
|
|
|
v.checked = false;
|
2024-09-18 18:02:18 +07:00
|
|
|
recursiveDeselect(v);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
2024-10-03 15:27:03 +07:00
|
|
|
|
2024-09-18 18:02:18 +07:00
|
|
|
function toggleCheck(node: Node) {
|
2024-09-27 16:11:16 +07:00
|
|
|
node.checked = !node.checked;
|
2024-10-03 15:27:03 +07:00
|
|
|
if (node.checked === false) {
|
|
|
|
|
recursiveDeselect(node);
|
|
|
|
|
emits('unchecked');
|
|
|
|
|
}
|
|
|
|
|
if (node.checked === true) {
|
|
|
|
|
recursiveSelect(node);
|
|
|
|
|
emits('checked');
|
|
|
|
|
}
|
2024-09-18 18:02:18 +07:00
|
|
|
}
|
|
|
|
|
|
2024-09-30 16:35:41 +07:00
|
|
|
function toggleExpand(node: Node, ancestor?: []) {
|
2024-09-18 18:02:18 +07:00
|
|
|
node.opened = !node.opened;
|
2024-09-30 16:35:41 +07:00
|
|
|
emits('open', node, ancestor);
|
2024-09-18 18:02:18 +07:00
|
|
|
}
|
2024-10-16 13:19:31 +07:00
|
|
|
|
2024-10-16 13:34:14 +07:00
|
|
|
function visibleNode(text: string, node: Node, ancestor?: Node[]): boolean {
|
|
|
|
|
if (!text) return true;
|
2024-10-16 13:19:31 +07:00
|
|
|
|
|
|
|
|
const getTitle = (n: Node): string => n[props.keyTitle || 'title'];
|
|
|
|
|
const getSubtitle = (n: Node): string => n[props.keySubtitle || 'subtitle'];
|
|
|
|
|
|
|
|
|
|
return (
|
2024-10-16 13:34:14 +07:00
|
|
|
[node, ...(ancestor || [])].some(
|
2024-10-16 13:19:31 +07:00
|
|
|
(v) => getTitle(v).includes(text) || getSubtitle(v).includes(text),
|
2024-10-16 13:34:14 +07:00
|
|
|
) || (node.children || []).some((v) => visibleNode(text, v, []))
|
2024-10-16 13:19:31 +07:00
|
|
|
);
|
|
|
|
|
}
|
2024-09-13 09:36:41 +07:00
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<template>
|
2024-09-25 17:47:01 +07:00
|
|
|
<div
|
|
|
|
|
class="tree-container"
|
|
|
|
|
:class="{
|
2024-09-30 16:54:50 +07:00
|
|
|
'q-pl-xl': level && level > 0,
|
2024-09-25 17:47:01 +07:00
|
|
|
'last-children': level && level === maxLevel,
|
|
|
|
|
}"
|
|
|
|
|
>
|
2024-10-16 13:19:31 +07:00
|
|
|
<template v-for="(node, i) in nodes" :key="i">
|
|
|
|
|
<div
|
|
|
|
|
class="tree-item"
|
2024-10-16 13:34:14 +07:00
|
|
|
v-if="filterText ? visibleNode(filterText, node, ancestorNode) : true"
|
2024-10-16 13:19:31 +07:00
|
|
|
>
|
|
|
|
|
<slot
|
|
|
|
|
v-if="$slots['item']"
|
|
|
|
|
name="item"
|
|
|
|
|
:data="{ node, toggleExpand, toggleCheck }"
|
|
|
|
|
/>
|
2024-09-13 09:43:21 +07:00
|
|
|
|
2024-10-16 13:19:31 +07:00
|
|
|
<div class="row items-center" v-else>
|
|
|
|
|
<div v-if="!level && movable" style="width: var(--size-16)">
|
|
|
|
|
<q-btn
|
|
|
|
|
id="btn-up"
|
|
|
|
|
for="btn-up"
|
|
|
|
|
icon="mdi-arrow-up"
|
|
|
|
|
size="sm"
|
|
|
|
|
dense
|
|
|
|
|
flat
|
|
|
|
|
round
|
|
|
|
|
:disable="i === 0"
|
|
|
|
|
style="color: hsl(var(--text-mute-2))"
|
|
|
|
|
@click.stop="$emit('moveUp', node)"
|
|
|
|
|
/>
|
|
|
|
|
<q-btn
|
|
|
|
|
id="btn-down"
|
|
|
|
|
for="btn-down"
|
|
|
|
|
icon="mdi-arrow-down"
|
|
|
|
|
size="sm"
|
|
|
|
|
dense
|
|
|
|
|
flat
|
|
|
|
|
round
|
|
|
|
|
class="q-mx-sm"
|
|
|
|
|
:disable="i === nodes.length - 1"
|
|
|
|
|
style="color: hsl(var(--text-mute-2))"
|
|
|
|
|
@click.stop="$emit('moveDown', node)"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div
|
|
|
|
|
class="item__content row items-center no-wrap col"
|
|
|
|
|
:class="{ active: selectedNode?.includes(node) }"
|
|
|
|
|
@click="
|
|
|
|
|
() => (selectable ? $emit('select', node) : toggleExpand(node))
|
|
|
|
|
"
|
|
|
|
|
>
|
|
|
|
|
<div style="width: var(--size-6); margin-right: var(--size-1)">
|
|
|
|
|
<div
|
|
|
|
|
v-if="level !== maxLevel"
|
|
|
|
|
class="q-mr-md"
|
|
|
|
|
:style="`color: ${
|
2024-09-30 16:54:50 +07:00
|
|
|
!node.displayDropIcon &&
|
|
|
|
|
(!node.children || node.children.length === 0)
|
2024-10-16 13:19:31 +07:00
|
|
|
? 'transparent'
|
|
|
|
|
: $q.dark.isActive
|
|
|
|
|
? 'var(--gray-7)'
|
|
|
|
|
: 'var(--gray-4)'
|
|
|
|
|
}`"
|
|
|
|
|
>
|
|
|
|
|
<q-icon
|
|
|
|
|
name="mdi-chevron-down-circle"
|
|
|
|
|
size="sm"
|
|
|
|
|
:style="`transform: rotate(${node.opened ? '180deg' : '0'}); transition: transform 0.3s ease;`"
|
|
|
|
|
@click.stop="
|
|
|
|
|
!node.displayDropIcon &&
|
|
|
|
|
(!node.children || node.children.length === 0)
|
|
|
|
|
? $emit('select', node)
|
|
|
|
|
: toggleExpand(node)
|
|
|
|
|
"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2024-09-30 16:54:50 +07:00
|
|
|
</div>
|
2024-09-25 17:47:01 +07:00
|
|
|
|
2024-10-16 13:19:31 +07:00
|
|
|
<label
|
|
|
|
|
v-if="!hideCheckBox"
|
|
|
|
|
class="flex items-center item__checkbox"
|
|
|
|
|
@click.stop
|
|
|
|
|
>
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
v-model="node.checked"
|
|
|
|
|
:indeterminate="
|
|
|
|
|
node.children?.some((v) => !v.checked) &&
|
|
|
|
|
!node.children?.every((v) => !v.checked)
|
|
|
|
|
"
|
|
|
|
|
:style="`accent-color: var(--blue-7)`"
|
|
|
|
|
@click="toggleCheck(node)"
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
2024-09-13 09:43:21 +07:00
|
|
|
|
2024-09-25 17:47:01 +07:00
|
|
|
<div
|
2024-10-16 13:19:31 +07:00
|
|
|
class="item__icon flex items-center justify-center"
|
|
|
|
|
:style="`background: ${node.bg || dec?.bg}; color: ${node.fg || dec?.fg}; height: ${iconSize}; width: ${iconSize}`"
|
2024-09-25 17:47:01 +07:00
|
|
|
>
|
2024-10-16 13:19:31 +07:00
|
|
|
<div
|
|
|
|
|
class="flex items-center justify-center"
|
|
|
|
|
:style="`height: calc(${iconSize} - 40%); width: calc(${iconSize} - 40%)`"
|
|
|
|
|
>
|
|
|
|
|
<Icon
|
|
|
|
|
v-if="(node.icon && dec && dec.icon) || (dec && dec.icon)"
|
|
|
|
|
:icon="node.icon || dec.icon"
|
|
|
|
|
class="full-width full-height"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2024-09-25 17:47:01 +07:00
|
|
|
</div>
|
2024-09-13 09:43:21 +07:00
|
|
|
|
2024-10-16 13:19:31 +07:00
|
|
|
<div class="column">
|
|
|
|
|
<span class="item__title">
|
|
|
|
|
{{ node[keyTitle || 'title'] || 'No Title' }}
|
|
|
|
|
</span>
|
|
|
|
|
<span class="item__subtitle">
|
|
|
|
|
{{ node[keySubtitle || 'subtitle'] || 'No Subtitle' }}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
2024-10-02 15:06:39 +07:00
|
|
|
|
2024-10-16 13:19:31 +07:00
|
|
|
<DeleteButton
|
|
|
|
|
v-if="deleteable"
|
|
|
|
|
class="q-ml-auto"
|
|
|
|
|
icon-only
|
|
|
|
|
@click.stop="$emit('delete', node)"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2024-09-13 09:50:27 +07:00
|
|
|
</div>
|
2024-09-13 09:43:21 +07:00
|
|
|
|
2024-10-16 13:19:31 +07:00
|
|
|
<transition name="slide">
|
|
|
|
|
<div
|
|
|
|
|
v-if="node.opened && node.children && node.children.length > 0"
|
|
|
|
|
:class="{ 'q-pl-xl': movable }"
|
|
|
|
|
class="tree-children"
|
|
|
|
|
>
|
|
|
|
|
<TreeView
|
|
|
|
|
:iconSize
|
|
|
|
|
:hideCheckBox
|
|
|
|
|
:selectable
|
|
|
|
|
:selectedNode
|
|
|
|
|
:filterText
|
|
|
|
|
:deleteable="deleteableDeep"
|
|
|
|
|
class="item__children"
|
|
|
|
|
v-if="node.children"
|
|
|
|
|
v-model:nodes="node.children"
|
|
|
|
|
@open="
|
|
|
|
|
(n) => {
|
|
|
|
|
$emit(
|
|
|
|
|
'open',
|
|
|
|
|
n,
|
|
|
|
|
!props.ancestorNode || props.ancestorNode.length === 0
|
|
|
|
|
? [node]
|
|
|
|
|
: [...props.ancestorNode, node],
|
|
|
|
|
);
|
2024-10-03 15:27:03 +07:00
|
|
|
}
|
2024-10-16 13:19:31 +07:00
|
|
|
"
|
|
|
|
|
@checked="
|
|
|
|
|
() => {
|
|
|
|
|
node.checked = true;
|
|
|
|
|
$emit('checked');
|
|
|
|
|
}
|
|
|
|
|
"
|
|
|
|
|
@unchecked="
|
|
|
|
|
() => {
|
|
|
|
|
if (node.children?.every((v) => v.checked === false)) {
|
|
|
|
|
node.checked = false;
|
|
|
|
|
}
|
|
|
|
|
$emit('checked');
|
|
|
|
|
}
|
|
|
|
|
"
|
|
|
|
|
@select="
|
|
|
|
|
(v) =>
|
|
|
|
|
$emit(
|
|
|
|
|
'select',
|
|
|
|
|
v,
|
|
|
|
|
!props.ancestorNode || props.ancestorNode.length === 0
|
|
|
|
|
? [node]
|
|
|
|
|
: [...props.ancestorNode, node],
|
|
|
|
|
)
|
|
|
|
|
"
|
|
|
|
|
:level="(level || 0) + 1"
|
|
|
|
|
:ancestorNode="
|
|
|
|
|
!props.ancestorNode || props.ancestorNode.length === 0
|
|
|
|
|
? [node]
|
|
|
|
|
: [...props.ancestorNode, node]
|
|
|
|
|
"
|
|
|
|
|
:expandable
|
|
|
|
|
:decoration
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</transition>
|
|
|
|
|
<q-separator v-if="!level && i !== nodes.length - 1" class="q-mt-sm" />
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
2024-09-13 09:36:41 +07:00
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
2024-10-15 10:23:02 +07:00
|
|
|
<style lang="css" scoped>
|
2024-09-13 09:36:41 +07:00
|
|
|
.tree-container {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
2024-09-13 09:43:21 +07:00
|
|
|
user-select: none;
|
2024-09-30 16:54:50 +07:00
|
|
|
gap: var(--size-2);
|
2024-09-13 09:36:41 +07:00
|
|
|
|
|
|
|
|
& .tree-item {
|
2024-09-13 09:43:21 +07:00
|
|
|
& .item__content {
|
2024-09-27 16:11:16 +07:00
|
|
|
border: solid 1px transparent;
|
2024-09-30 16:54:50 +07:00
|
|
|
border-radius: var(--radius-2);
|
2024-09-13 09:50:27 +07:00
|
|
|
padding: 0.1rem 0.5rem;
|
2024-10-01 09:51:55 +07:00
|
|
|
transition: 0.15s;
|
2024-09-13 09:43:21 +07:00
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
background: hsla(0 0% 0% / 0.1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-09-13 09:36:41 +07:00
|
|
|
& .item__checkbox {
|
2024-09-13 09:50:27 +07:00
|
|
|
padding: 0.1rem 0.5rem;
|
2024-09-13 09:36:41 +07:00
|
|
|
margin-right: 1rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
& .item__icon {
|
|
|
|
|
margin-right: 1rem;
|
2024-09-16 10:32:06 +07:00
|
|
|
width: 1.5rem;
|
|
|
|
|
height: 1.5rem;
|
|
|
|
|
border-radius: 50%;
|
2024-09-13 09:36:41 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
& .item__title {
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
& .item__subtitle {
|
|
|
|
|
font-size: 80%;
|
|
|
|
|
color: hsla(var(--text-mute-2));
|
|
|
|
|
}
|
2024-09-27 16:11:16 +07:00
|
|
|
|
2024-09-30 16:54:50 +07:00
|
|
|
& .tree-children {
|
|
|
|
|
padding-top: var(--size-2);
|
|
|
|
|
}
|
|
|
|
|
|
2024-09-27 16:11:16 +07:00
|
|
|
& .active {
|
|
|
|
|
border: solid 1px hsl(var(--info-bg));
|
|
|
|
|
background: hsla(var(--info-bg) / 0.1);
|
|
|
|
|
}
|
2024-09-13 09:36:41 +07:00
|
|
|
}
|
|
|
|
|
}
|
2024-09-16 10:32:06 +07:00
|
|
|
|
|
|
|
|
.slide-enter-active {
|
|
|
|
|
transition: all 0.1s ease-out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.slide-leave-active {
|
|
|
|
|
transition: all 0.1s cubic-bezier(1, 0.5, 0.8, 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.slide-enter-from,
|
|
|
|
|
.slide-leave-to {
|
|
|
|
|
transform: translateY(-20px);
|
|
|
|
|
opacity: 0;
|
|
|
|
|
}
|
2024-09-13 09:36:41 +07:00
|
|
|
</style>
|