🌲

(Vue.js) 入れ子になったチェックリストを制御するツリーコンポーネントを実装する

2023/04/17に公開

始めに

入れ子のチェックリストをツリーで表現する際、子孫の状態に応じてチェックの状態及びチェック時の挙動を変更する必要があります。

Vue.jsでツリーを扱うライブラリはいくつか出回っていますが、既存のライブラリを利用すると細かいカスタマイズが難しいという課題もあり、勉強も兼ねて改めて上記の挙動を実現するコンポーネントを実装してみました。

実装

完成品

ソースコード: https://github.com/m2tkl/vue-samples/tree/main/src/components/app/Tree

tree-demo

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: []
            },
            ...
        ]
    },
    ...
]

チェックの状態・チェック時の挙動

チェックの表示には checkedindeterminateunchecked の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