378 lines
11 KiB
Vue
378 lines
11 KiB
Vue
<script setup lang="ts">
|
|
import { computed, provide, reactive, watch } from "vue";
|
|
import BuefyTreeNode from "./BuefyTreeNode.vue";
|
|
|
|
type TreeNode = Record<string, any>;
|
|
|
|
const props = withDefaults(defineProps<{
|
|
data: TreeNode[];
|
|
fields?: Partial<{
|
|
id: string;
|
|
label: string;
|
|
children: string;
|
|
isLeaf: string;
|
|
disabled: string;
|
|
}>;
|
|
selectionMode?: "none" | "single" | "multiple" | "checkbox";
|
|
selected?: any;
|
|
expandedKeys?: any[];
|
|
checkedKeys?: any[];
|
|
defaultExpandAll?: boolean;
|
|
expandOnClickNode?: boolean;
|
|
lazy?: boolean;
|
|
load?: (node: TreeNode) => Promise<TreeNode[]>;
|
|
progressiveBatchSize?: number;
|
|
loadMoreLabel?: string;
|
|
ariaLabel?: string;
|
|
}>(), {
|
|
data: () => [],
|
|
fields: () => ({}),
|
|
selectionMode: "none",
|
|
selected: null,
|
|
expandedKeys: () => [],
|
|
checkedKeys: () => [],
|
|
defaultExpandAll: false,
|
|
expandOnClickNode: true,
|
|
lazy: false,
|
|
load: undefined,
|
|
progressiveBatchSize: 0,
|
|
loadMoreLabel: "Show {count} more",
|
|
ariaLabel: undefined,
|
|
});
|
|
|
|
const emit = defineEmits<{
|
|
(event: "update:selected", value: any): void;
|
|
(event: "update:expandedKeys", value: any[]): void;
|
|
(event: "update:checkedKeys", value: any[]): void;
|
|
(event: "select", node: TreeNode, key: any): void;
|
|
(event: "check", node: TreeNode, key: any, checkedKeys: any[]): void;
|
|
(event: "expand", node: TreeNode, key: any): void;
|
|
(event: "collapse", node: TreeNode, key: any): void;
|
|
(event: "node-click", node: TreeNode, key: any): void;
|
|
(event: "load-start", node: TreeNode, key: any): void;
|
|
(event: "load-error", error: unknown, node: TreeNode, key: any): void;
|
|
}>();
|
|
|
|
const resolvedFields = computed(() => ({
|
|
id: props.fields?.id || "id",
|
|
label: props.fields?.label || "label",
|
|
children: props.fields?.children || "children",
|
|
isLeaf: props.fields?.isLeaf || "isLeaf",
|
|
disabled: props.fields?.disabled || "disabled",
|
|
}));
|
|
|
|
const state = reactive({
|
|
selected: props.selected,
|
|
expandedKeys: [...props.expandedKeys],
|
|
checkedKeys: [...props.checkedKeys],
|
|
lazyChildrenCache: {} as Record<string, TreeNode[]>,
|
|
loadingKeys: [] as any[],
|
|
loadErrorKeys: [] as any[],
|
|
renderedLimits: {} as Record<string, number>,
|
|
});
|
|
|
|
const keyOf = (node: TreeNode) => node?.[resolvedFields.value.id];
|
|
const keyString = (key: any) => String(key ?? "");
|
|
const childrenOf = (node: TreeNode) => {
|
|
const children = node?.[resolvedFields.value.children];
|
|
return Array.isArray(children) ? children : [];
|
|
};
|
|
const cachedChildrenOf = (node: TreeNode) => state.lazyChildrenCache[keyString(keyOf(node))] || [];
|
|
const effectiveChildrenOf = (node: TreeNode) => {
|
|
const children = childrenOf(node);
|
|
return children.length > 0 ? children : cachedChildrenOf(node);
|
|
};
|
|
const progressiveBatchSize = computed(() => Math.max(0, Math.floor(Number(props.progressiveBatchSize) || 0)));
|
|
const visibleChildrenOf = (node: TreeNode) => {
|
|
const children = effectiveChildrenOf(node);
|
|
if (progressiveBatchSize.value < 1 || children.length <= progressiveBatchSize.value) {
|
|
return children;
|
|
}
|
|
const key = keyString(keyOf(node));
|
|
const limit = state.renderedLimits[key] || progressiveBatchSize.value;
|
|
return children.slice(0, limit);
|
|
};
|
|
const remainingChildrenCount = (node: TreeNode) => Math.max(0, effectiveChildrenOf(node).length - visibleChildrenOf(node).length);
|
|
const hasMoreChildren = (node: TreeNode) => remainingChildrenCount(node) > 0;
|
|
const showMoreChildren = (node: TreeNode) => {
|
|
if (!hasMoreChildren(node)) {
|
|
return;
|
|
}
|
|
const key = keyString(keyOf(node));
|
|
const currentLimit = state.renderedLimits[key] || progressiveBatchSize.value;
|
|
state.renderedLimits[key] = Math.min(effectiveChildrenOf(node).length, currentLimit + progressiveBatchSize.value);
|
|
};
|
|
const loadMoreLabelFor = (node: TreeNode) => props.loadMoreLabel.replace(
|
|
"{count}",
|
|
String(Math.min(progressiveBatchSize.value, remainingChildrenCount(node)))
|
|
);
|
|
const isDisabled = (node: TreeNode) => Boolean(node?.[resolvedFields.value.disabled]);
|
|
const isSelfSelectable = (node: TreeNode) => node?.selectable !== false;
|
|
const isBranchCheckable = (node: TreeNode) => node?.checkable === true;
|
|
const isCheckDisabled = (node: TreeNode) => isDisabled(node) || (!isSelfSelectable(node) && !isBranchCheckable(node));
|
|
const isLeaf = (node: TreeNode) => Boolean(node?.[resolvedFields.value.isLeaf]);
|
|
|
|
const collectKeys = (node: TreeNode): any[] => {
|
|
const keys: any[] = [];
|
|
const key = keyOf(node);
|
|
if (key !== undefined && key !== null && isSelfSelectable(node)) {
|
|
keys.push(key);
|
|
}
|
|
effectiveChildrenOf(node).forEach((child) => {
|
|
keys.push(...collectKeys(child));
|
|
});
|
|
return keys;
|
|
};
|
|
|
|
const collectAllExpandableKeys = (nodes: TreeNode[]): any[] => {
|
|
const keys: any[] = [];
|
|
nodes.forEach((node) => {
|
|
const key = keyOf(node);
|
|
if (key !== undefined && key !== null && !isLeaf(node)) {
|
|
keys.push(key);
|
|
}
|
|
keys.push(...collectAllExpandableKeys(childrenOf(node)));
|
|
});
|
|
return keys;
|
|
};
|
|
|
|
watch(
|
|
() => props.selected,
|
|
(value) => {
|
|
state.selected = value;
|
|
}
|
|
);
|
|
|
|
watch(
|
|
() => props.expandedKeys,
|
|
(value) => {
|
|
state.expandedKeys = [...value];
|
|
},
|
|
{ deep: true }
|
|
);
|
|
|
|
watch(
|
|
() => props.checkedKeys,
|
|
(value) => {
|
|
state.checkedKeys = [...value];
|
|
},
|
|
{ deep: true }
|
|
);
|
|
|
|
watch(
|
|
() => props.data,
|
|
(nodes) => {
|
|
state.renderedLimits = {};
|
|
if (props.defaultExpandAll) {
|
|
state.expandedKeys = collectAllExpandableKeys(nodes);
|
|
emit("update:expandedKeys", [...state.expandedKeys]);
|
|
}
|
|
},
|
|
{ immediate: true, deep: true }
|
|
);
|
|
|
|
const setExpandedKeys = (keys: any[]) => {
|
|
state.expandedKeys = [...keys];
|
|
emit("update:expandedKeys", [...state.expandedKeys]);
|
|
};
|
|
|
|
const setCheckedKeys = (keys: any[]) => {
|
|
state.checkedKeys = [...new Set(keys)];
|
|
emit("update:checkedKeys", [...state.checkedKeys]);
|
|
};
|
|
|
|
const loadNode = async (node: TreeNode) => {
|
|
if (!props.lazy || !props.load) {
|
|
return;
|
|
}
|
|
const key = keyOf(node);
|
|
if (key === undefined || key === null) {
|
|
return;
|
|
}
|
|
if (state.loadingKeys.includes(key)) {
|
|
return;
|
|
}
|
|
|
|
state.loadingKeys = [...state.loadingKeys, key];
|
|
state.loadErrorKeys = state.loadErrorKeys.filter((current) => current !== key);
|
|
emit("load-start", node, key);
|
|
|
|
try {
|
|
state.lazyChildrenCache[keyString(key)] = await props.load(node);
|
|
if (progressiveBatchSize.value > 0) {
|
|
state.renderedLimits[keyString(key)] = progressiveBatchSize.value;
|
|
}
|
|
} catch (error) {
|
|
state.loadErrorKeys = [...new Set([...state.loadErrorKeys, key])];
|
|
emit("load-error", error, node, key);
|
|
} finally {
|
|
state.loadingKeys = state.loadingKeys.filter((current) => current !== key);
|
|
}
|
|
};
|
|
|
|
const ensureChildrenLoadedForCheck = async (node: TreeNode) => {
|
|
if (!props.lazy || !props.load || isLeaf(node)) {
|
|
return;
|
|
}
|
|
const key = keyOf(node);
|
|
if (key === undefined || key === null) {
|
|
return;
|
|
}
|
|
if (childrenOf(node).length > 0 || state.lazyChildrenCache[keyString(key)]) {
|
|
return;
|
|
}
|
|
await loadNode(node);
|
|
};
|
|
|
|
const toggleExpand = async (node: TreeNode) => {
|
|
if (isDisabled(node) || isLeaf(node)) {
|
|
return;
|
|
}
|
|
const key = keyOf(node);
|
|
if (key === undefined || key === null) {
|
|
return;
|
|
}
|
|
|
|
if (state.expandedKeys.includes(key)) {
|
|
setExpandedKeys(state.expandedKeys.filter((current) => current !== key));
|
|
emit("collapse", node, key);
|
|
return;
|
|
}
|
|
|
|
setExpandedKeys([...state.expandedKeys, key]);
|
|
emit("expand", node, key);
|
|
if (props.lazy && childrenOf(node).length === 0 && !state.lazyChildrenCache[keyString(key)]) {
|
|
await loadNode(node);
|
|
}
|
|
};
|
|
|
|
const retryLoad = async (node: TreeNode) => {
|
|
const key = keyOf(node);
|
|
if (key === undefined || key === null) {
|
|
return;
|
|
}
|
|
delete state.lazyChildrenCache[keyString(key)];
|
|
state.loadErrorKeys = state.loadErrorKeys.filter((current) => current !== key);
|
|
if (!state.expandedKeys.includes(key)) {
|
|
setExpandedKeys([...state.expandedKeys, key]);
|
|
}
|
|
await loadNode(node);
|
|
};
|
|
|
|
const toggleCheck = async (node: TreeNode) => {
|
|
if (props.selectionMode !== "checkbox" || isCheckDisabled(node)) {
|
|
return;
|
|
}
|
|
const key = keyOf(node);
|
|
if (key === undefined || key === null) {
|
|
return;
|
|
}
|
|
|
|
await ensureChildrenLoadedForCheck(node);
|
|
const keys = collectKeys(node);
|
|
if (keys.length === 0) {
|
|
emit("check", node, key, [...state.checkedKeys]);
|
|
return;
|
|
}
|
|
const allChecked = keys.every((current) => state.checkedKeys.includes(current));
|
|
if (allChecked) {
|
|
const toRemove = new Set(keys);
|
|
setCheckedKeys(state.checkedKeys.filter((current) => !toRemove.has(current)));
|
|
} else {
|
|
setCheckedKeys([...state.checkedKeys, ...keys]);
|
|
}
|
|
emit("check", node, key, [...state.checkedKeys]);
|
|
};
|
|
|
|
const checkState = (node: TreeNode): "checked" | "unchecked" | "indeterminate" => {
|
|
const key = keyOf(node);
|
|
const children = effectiveChildrenOf(node);
|
|
if (children.length === 0) {
|
|
return state.checkedKeys.includes(key) ? "checked" : "unchecked";
|
|
}
|
|
const states = children.map((child) => checkState(child));
|
|
if (states.every((item) => item === "checked")) {
|
|
return "checked";
|
|
}
|
|
if (states.every((item) => item === "unchecked") && !state.checkedKeys.includes(key)) {
|
|
return "unchecked";
|
|
}
|
|
return "indeterminate";
|
|
};
|
|
|
|
const handleNodeClick = async (node: TreeNode) => {
|
|
if (isDisabled(node)) {
|
|
return;
|
|
}
|
|
const key = keyOf(node);
|
|
emit("node-click", node, key);
|
|
if (props.selectionMode === "single") {
|
|
state.selected = key;
|
|
emit("update:selected", key);
|
|
emit("select", node, key);
|
|
} else if (props.selectionMode === "multiple") {
|
|
const selected = Array.isArray(state.selected) ? state.selected : [];
|
|
state.selected = selected.includes(key)
|
|
? selected.filter((current) => current !== key)
|
|
: [...selected, key];
|
|
emit("update:selected", state.selected);
|
|
emit("select", node, key);
|
|
}
|
|
if (props.expandOnClickNode) {
|
|
await toggleExpand(node);
|
|
}
|
|
};
|
|
|
|
provide("BuefyTreeContext", {
|
|
props,
|
|
state,
|
|
fields: resolvedFields,
|
|
keyOf,
|
|
childrenOf,
|
|
effectiveChildrenOf,
|
|
visibleChildrenOf,
|
|
hasMoreChildren,
|
|
showMoreChildren,
|
|
remainingChildrenCount,
|
|
loadMoreLabelFor,
|
|
isDisabled,
|
|
isCheckDisabled,
|
|
isLeaf,
|
|
toggleExpand,
|
|
retryLoad,
|
|
toggleCheck,
|
|
checkState,
|
|
handleNodeClick,
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<ul class="b-tree" role="tree" :aria-label="ariaLabel">
|
|
<BuefyTreeNode
|
|
v-for="(node, index) in data"
|
|
:key="keyOf(node) ?? index"
|
|
:node="node"
|
|
:depth="1"
|
|
:setsize="data.length"
|
|
:posinset="index + 1"
|
|
>
|
|
<template #default="slotProps">
|
|
<slot v-bind="slotProps">
|
|
{{ slotProps.node?.[resolvedFields.label] }}
|
|
</slot>
|
|
</template>
|
|
<template #icon="slotProps">
|
|
<slot name="icon" v-bind="slotProps"></slot>
|
|
</template>
|
|
</BuefyTreeNode>
|
|
</ul>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.b-tree {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
</style>
|