Files
pleno-vue/src/components/buefy/tree/BuefyTree.vue
T

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>