🌲
(Vue.js) 入れ子になったチェックリストを制御するツリーコンポーネントを実装する
始めに
入れ子のチェックリストをツリーで表現する際、子孫の状態に応じてチェックの状態及びチェック時の挙動を変更する必要があります。
Vue.jsでツリーを扱うライブラリはいくつか出回っていますが、既存のライブラリを利用すると細かいカスタマイズが難しいという課題もあり、勉強も兼ねて改めて上記の挙動を実現するコンポーネントを実装してみました。
実装
完成品
ソースコード: https://github.com/m2tkl/vue-samples/tree/main/src/components/app/Tree
App.vue:
<script setup lang="ts">
import TreeView from './components/Tree/TreeView.vue'
import { sampleData } from './components/Tree/data';
import { ref } from 'vue';
const treeData = ref(sampleData)
</script>
<template>
<TreeView :items="treeData"/>
</template>
TreeView.vue:
<script setup lang="ts">
import TreeNodeRec from './TreeNodeRec.vue';
import { NodeData } from './types';
interface Props {
items: Array<NodeData>;
}
defineProps<Props>();
</script>
<template>
<ul>
<li v-for="item in items" :key="item.id">
<TreeNodeRec :item="item" :indent="0" @check="(e) => (item.checked = e)" />
</li>
</ul>
</template>
TreeNodeRec.vue (一部抜粋):
<script setup lang="ts">
import { computed, watch } from 'vue';
import { NodeData } from './types';
interface Props {
item: NodeData;
indent: number;
}
interface Emits {
(e: 'check', value: boolean): void;
}
const props = defineProps<Props>();
const emits = defineEmits<Emits>();
const indentWidth = 16;
/**
* Check state of tree node
*/
const checkedComputed = computed({
get: () => props.item.checked,
set: (newState) => {
emits('check', newState);
}
})
...
</script>
<template>
<!-- Node -->
<div>
<!-- Indent -->
<div :style="{ width: indent * indentWidth + 'px' }"></div>
<!-- Contents -->
<input
type="checkbox"
v-model="checkedComputed"
@change="onChange"
:indeterminate="indeterminate"
/>
<label class="ml-1">
{{ item.name }}
</label>
</div>
<!-- Children node -->
<ul v-if="item.children.length !== 0">
<li v-for="child in item.children" :key="child.id">
<TreeNodeRec :item="child" :indent="indent + 1" @check="(e) => (child.checked = e)" />
</li>
</ul>
</template>
実装詳細
データ構造
ツリーで扱う各Nodeにチェック状態を表す checked
属性を持たせます。また、子Nodeとしてchildren
属性を持たせます。
export type NodeData = {
id: string;
name: string;
checked: boolean;
children: NodeData[];
}
例:
const items: NodeData[] = [
{
id: 'xxx',
name: 'A',
checked: false,
children: [
{
id: 'yyy'
name: 'B',
checked: false,
children: []
},
...
]
},
...
]
チェックの状態・チェック時の挙動
チェックの表示には checked
、indeterminate
、unchecked
の3種類があります。
チェック時には自身だけでなく子孫もまとめて更新します。
表示 | 自身の状態 (checked) | 子孫の状態 (checked) | チェック時の挙動 |
---|---|---|---|
checked |
true |
全て true
|
自身と全ての子孫を unchecked にする |
indeterminate |
false |
一部 true
|
自身と全ての子孫を checked にする |
unchecked |
false |
全て false
|
自身と全ての子孫を checked にする |
/**
* Event handler
*/
const onChange = () => {
if (childrenAllChecked.value) {
updateCheckStateOfDescendants(props.item, false);
return;
}
if (someDescendantsChecked.value) {
updateCheckStateOfDescendants(props.item, true);
return;
}
updateCheckStateOfDescendants(props.item, true);
}
/**
* Update check state of descendants recursively
*/
function updateCheckStateOfDescendants(item: NodeData, value: boolean) {
for (const child of item.children) {
child.checked = value;
updateCheckStateOfDescendants(child, value);
}
}
indeterminate
の判定
子孫が一つでも checked
であれば、自身は indeterminate
となります。
ただし、全ての子孫が checked
である場合は indeterminate
ではなく checked
となることに注意します。
全ての子孫が checked
であるかどうかを判定するには、自身の子が全て checked
であるかどうかを確認すればOKです。(あるNodeが checked
の場合、その先の子孫は全て checked
です。)
const someDescendantsChecked = computed(() => {
return isSomeDescendantsChecked(props.item);
})
const childrenAllChecked = computed(() => {
return isAllChildrenChecked(props.item);
})
const indeterminate = computed(() => {
return someDescendantsChecked.value && !childrenAllChecked.value;
})
/**
* Determine check state of descendants
*/
function isSomeDescendantsChecked(item: NodeData): boolean {
for (const child of item.children) {
if (child.checked) {
return true;
}
if (isSomeDescendantsChecked(child)) {
return true;
}
}
return false;
}
function isAllChildrenChecked(item: NodeData): boolean {
if (item.children.length === 0) {
return true;
}
const isAllChecked = item.children.every((child) => {
return child.checked;
})
return isAllChecked;
}
子孫の状態更新に応じて自身の状態を更新する
子孫の状態変化を監視し、変更に応じて自身の check
を更新します。
- 子孫が全て
unchecked
→ 自身をunchecked
に更新 - 子が全て
checked
(子孫が全てchecked
) → 自身をchecked
に更新 - 子孫の一部が
checked
→ 自身をunchecked
に更新 (indeterminate
として表示)
watch([someDescendantsChecked, childrenAllChecked], () => {
if (!someDescendantsChecked.value) {
emits('check', false);
return;
}
emits('check', childrenAllChecked.value ? true : false);
})
終わりに
indeterminate
な状態を子孫の状態から算出することで、思ったよりもすっきりと書くことができました。
Discussion