【Vuetify2/Vuetify3】 v-data-table グルーピング × custom-sort ややこしいので整理

v-data-table は Vuetify が誇る高機能テーブルコンポーネントだが、グルーピングとソートを組み合わせた途端に「あれ、思った通りに動かない」という壁にぶつかることが多い。この記事では、公式ドキュメントだけでは辿り着きにくい実践的なテクニックを、Vuetify 2 / 3 の差異も踏まえてまとめる。


目次

グルーピングの基本

Vuetify 2

group-bygroup-desc props で指定する。

<v-data-table
  :items="items"
  :headers="headers"
  group-by="category"
  :group-desc="false"
/>

Vuetify 3

group-by が配列形式になり、複数キーによるネストしたグルーピングにも対応した。

<v-data-table
  :items="items"
  :headers="headers"
  :group-by="[{ key: 'category', order: 'asc' }]"
/>

Vuetify 3 では group-desc は廃止され、group-by 配列内の order で方向を指定する。


グルーピング中のソートはどう動くのか?

ここが最大の混乱ポイントになる。

結論:ソートはグループ内に閉じる

v-data-table でグルーピングを有効にした状態でヘッダーのソートをクリックすると、ソートはグループ内の行に対してのみ適用され、グループの並び順は変わらない

つまり「数学」「国語」の2カテゴリでグルーピングしている場合、createdAt 列でソートしても、数学と国語の表示順はそのままで、各グループ内の問題だけが createdAt 順に並び替わる。

ソースコードでの確認

Vuetify 2 では、VDataTable の内部ソートロジック(VData.ts)で、グルーピングが有効な場合、まず group-by キーで安定ソートした後に、各グループ内をユーザー指定のソートキーで並べ替える構造になっている。

GitHub: vuetifyjs/vuetify – VData.ts (v2-stable)

該当の処理を簡略化すると以下のようになる。

// Vuetify 2 内部(概念的な疑似コード)
sortItems(items, sortBy, sortDesc) {
  // group-by が最優先のソートキーとして先頭に挿入される
  const allSortBy = [this.groupBy, ...sortBy];
  const allSortDesc = [this.groupDesc, ...sortDesc];
  return this.customSort(items, allSortBy, allSortDesc);
}

Vuetify 3 でもこの設計は同じで、composables/group.ts にて group-by キーが最優先ソートキーとして扱われる。

GitHub: vuetifyjs/vuetify – group.ts (v3)

つまり、グループキーが常にソートの第一基準として固定されるため、ユーザーのソート操作がグループの境界を越えてアイテムを移動させることはない。


グループ自体の並び順を変えたいとき

ここからが本番。実務では「グループ内のソート」ではなく「グループ自体をカスタムロジックで並べ替えたい」ケースの方が多い。

例えば、問題集管理アプリでカテゴリごとにグルーピングしている場合:

  • カテゴリ名の五十音順ではなく、カテゴリ内で最も新しく作成された問題の日時順にグループを並べたい
  • カテゴリ内の問題数が多い順にグループを表示したい

v-data-table にはグループ単位のソートを直接制御する props が存在しない。custom-sort / sort-by はあくまで行単位のソートであり、グループの並び順には直接影響しない。

解決策:computed で事前にソート済みデータを渡す

v-data-table にバインドする :items 自体を、グループの並び順が意図通りになるよう事前にソートしておく。グループ内のソートは group-by の安定ソートに任せ、グループ間の順序はデータの出現順で決まるという性質を利用する。

<template>
  <v-data-table
    :items="sortedItems"
    :headers="headers"
    group-by="category"
  />
</template>

<script>
export default {
  computed: {
    sortedItems() {
      const items = [...this.items];

      // 各カテゴリの「最も古い作成日時」を算出
      const categoryMinDate = {};
      items.forEach(item => {
        if (!categoryMinDate[item.category] ||
            item.createdAt < categoryMinDate[item.category]) {
          categoryMinDate[item.category] = item.createdAt;
        }
      });

      // カテゴリの最古作成日時でソートし、
      // 同一カテゴリ内は個別のソートキーで並べる
      return items.sort((a, b) => {
        // グループ間: カテゴリの最古日時順
        const catDiff =
          categoryMinDate[a.category] - categoryMinDate[b.category];
        if (catDiff !== 0) return catDiff;

        // グループ内: アイテム自身のソート
        return a.createdAt - b.createdAt;
      });
    }
  }
};
</script>

ポイントは、v-data-table のグループ表示順はデータ配列中でそのグループのアイテムが最初に出現する位置に依存すること。つまり事前ソートでカテゴリAのアイテムがカテゴリBより先に来ていれば、テーブル上でもカテゴリAが上に表示される。


実践例:問題集管理テーブル

もう少し複雑な実例を見ていく。問題集(クイズ)管理アプリで「問題一覧」を表示するケースを想定する。

要件

  • 各問題はカテゴリ(数学・国語・理科など)に属する
  • 同一カテゴリに複数の問題(レベル違い、バリエーション等)がある
  • カテゴリ単位でグルーピングし、各問題を行として表示
  • グループヘッダーにはカテゴリ名・説明・問題数・合計所要時間を表示
  • ソートはグループ単位で行いたい(例:最新の問題作成日時が新しいカテゴリを上に)
  • ヘッダークリックでソート方向やソートキーを切り替え可能にする

グルーピングとグループヘッダーのカスタマイズ

<v-data-table
  :headers="headers"
  :items="sortedItems"
  :items-per-page-options="[{ value: -1, title: '全件' }]"
  group-by="categoryId"
  :custom-sort="() => sortedItems"
  :must-sort="true"
  :sort-by.sync="sortByState"
  :sort-desc.sync="sortDescState"
>
  <template v-slot:group.header="{ items, isOpen, toggle }">
    <th :colspan="headers.length" class="group-header" @click="toggle">
      <span>
        <v-icon :class="{ 'is-rotated': isOpen }">expand_more</v-icon>
        <span class="font-weight-bold ml-2">
          {{ items[0].categoryName }}
        </span>
      </span>
      <span v-if="items[0].categoryDescription"
            class="ml-2 grey--text text--darken-1">
        {{ items[0].categoryDescription }}
      </span>
      <v-chip x-small class="ml-4" outlined>
        {{ items.length }} 問
      </v-chip>
      <v-chip x-small class="ml-2" outlined>
        合計 {{ items[0].groupTotalTime }} 分
      </v-chip>
    </th>
  </template>
</v-data-table>

Vuetify 3 の場合: スロット名が group.header ではなく group-header に変わり、引数も { item, columns, toggleGroup, isGroupOpen } になる。items ではなく item(単数形)になっている点に注意。

グループ統計の算出

filteredItems computed の中で、グループ単位の集計値を各行に付与する。

filteredItems() {
  const items = /* フィルタ済み問題配列 */;

  // グループ(categoryId)単位で統計情報を算出
  const groupStats = {};
  items.forEach(q => {
    if (!groupStats[q.categoryId]) {
      groupStats[q.categoryId] = {
        minCreatedAt: q.createdAt,
        maxCreatedAt: q.createdAt,
        maxNo: q.questionNo,
        totalTime: 0,
      };
    }
    const g = groupStats[q.categoryId];
    if (q.createdAt < g.minCreatedAt) g.minCreatedAt = q.createdAt;
    if (q.createdAt > g.maxCreatedAt) g.maxCreatedAt = q.createdAt;
    if (q.questionNo > g.maxNo) g.maxNo = q.questionNo;
    g.totalTime += q.estimatedMinutes;
  });

  // 各行にグループ統計を付与
  return items.map(q => ({
    ...q,
    isLastInGroup: q.questionNo === groupStats[q.categoryId].maxNo,
    groupMinCreatedAt: groupStats[q.categoryId].minCreatedAt,
    groupMaxCreatedAt: groupStats[q.categoryId].maxCreatedAt,
    groupTotalTime: groupStats[q.categoryId].totalTime,
  }));
}

グループ単位のカスタムソート

custom-sortv-data-table のデフォルトソートを無効化し、sortedItems computed 内でグループ単位のソートロジックを実装する。

sortedItems() {
  return [...this.filteredItems].sort((a, b) => {
    // 1. 同一グループ内は問題番号順で固定
    if (a.categoryId === b.categoryId) {
      return a.questionNo - b.questionNo;
    }

    // 2. グループ間のソート:選択されたカラムのグループ代表値を使用
    let valA, valB;

    if (this.sortBy === 'createdAt') {
      // カテゴリ内で最も新しい作成日時をそのカテゴリの代表値とする
      valA = a.groupMaxCreatedAt;
      valB = b.groupMaxCreatedAt;
    } else if (this.sortBy === 'estimatedMinutes') {
      valA = a.groupTotalTime;
      valB = b.groupTotalTime;
    } else if (this.sortBy === 'categoryName') {
      valA = a.categoryName;
      valB = b.categoryName;
    } else {
      // デフォルト: 最古の作成日時
      valA = a.groupMinCreatedAt;
      valB = b.groupMinCreatedAt;
    }

    if (valA < valB) return this.sortDesc ? 1 : -1;
    if (valA > valB) return this.sortDesc ? -1 : 1;

    // 同値の場合は categoryId で安定ソート
    return a.categoryId.localeCompare(b.categoryId);
  });
}

ここでのキモは:

  1. custom-sort() => sortedItems を渡すことで、v-data-table のデフォルトソートを無効化する
  2. sort-bysort-desc の sync は残すことで、ヘッダークリックによるUI状態(どのカラムがソートされているか、昇順/降順か)は通常通り機能させ、その state を自前のソートロジックで参照する
  3. グループ間の比較にはグループ代表値groupMinCreatedAt, groupTotalTime 等)を使い、グループ内の比較には問題番号を使う、という二段構えにする

グループ内の階層表現を CSS 疑似要素で実現する

グルーピングしたテーブルで、各行がグループヘッダーの子要素であることを視覚的に表現したいケースがある。ファイルツリーのように 形の罫線を CSS だけで描画できる。

▼ 数学
  ┣ 二次方程式 - 基礎
  ┣ 二次方程式 - 応用
  ┗ 二次方程式 - 発展
▼ 国語
  ┣ 古文読解 - 基礎
  ┗ 古文読解 - 応用

CSS 実装

各行の先頭セルに .tree-cell クラスを付与し、最終行には .tree-cell--last を追加する。

/* 中間行: 縦線 + 横分岐(┣) */
.tree-cell {
  position: relative;
  padding-left: 24px !important;
}
.tree-cell::before {
  content: '';
  position: absolute;
  top: 0;
  left: 8px;
  bottom: 0;
  border-left: 1px solid #ccc;
}
.tree-cell::after {
  content: '';
  position: absolute;
  top: 50%;
  left: 8px;
  width: 12px;
  border-top: 1px solid #ccc;
}

/* 最終行: 縦線を途中で止める(┗) */
.tree-cell--last::before {
  bottom: 50%;  /* 縦線をセルの中央で止める */
}

中間行では ::before で上から下まで縦線を引き、::after で中央から右へ横線を引くことで を表現する。最終行では ::beforebottom: 50% で縦線をセル中央で止めることで になる。

テンプレート側では isLastInGroup(先ほどの computed で付与済み)を使ってクラスを切り替える。

<template v-slot:item.name="{ item }">
  <td :class="['tree-cell', { 'tree-cell--last': item.isLastInGroup }]">
    {{ item.name }}
  </td>
</template>

グループヘッダーのスタイリング

グルーピング時のヘッダー行にカーソルポインタやホバーエフェクトを付けると、クリッカブルであることが伝わりやすい。

.group-header {
  background-color: #fafafa;
  cursor: pointer;
  transition: background-color 0.2s ease;
}
.group-header:hover {
  background-color: #efefef !important;
}

/* 展開アイコンの回転アニメーション */
.group-icon {
  transition: transform 0.3s ease-in-out;
  transform: rotate(-90deg);
}
.group-icon.is-rotated {
  transform: rotate(0deg);
}

<style scoped> の deep セレクタ:Vue 2 / 3 での書き方の違い

v-data-table の内部要素にスタイルを当てるには、scoped スタイルを貫通させる deep セレクタが必要になる。ここが Vue のバージョン(正確には vue-loader / @vue/compiler-sfc のバージョン) によって書き方が変わるポイントで、混乱しやすい。

変遷

書き方対応環境備考
/deep/ .childVue 2 + vue-loader 14 以前CSS 仕様の >>> と同等。最も古い書き方
>>> .childVue 2 + vue-loader 14 以前Sass/SCSS では使えない
::v-deep .childVue 2 + vue-loader 15〜長らく標準だった書き方
:deep(.child)Vue 3 (@vue/compiler-sfc)現行の推奨書き方

具体例

v-data-table 内部の td や独自クラスにスタイルを当てる場合:

<!-- Vue 2(vue-loader 15+) -->
<style scoped>
::v-deep .v-data-table td {
  word-break: break-all;
  white-space: normal;
}

::v-deep .tree-cell {
  position: relative;
  padding-left: 24px !important;
}
</style>
<!-- Vue 3 -->
<style scoped>
:deep(.v-data-table td) {
  word-break: break-all;
  white-space: normal;
}

:deep(.tree-cell) {
  position: relative;
  padding-left: 24px !important;
}
</style>

注意点

  • Vue 2 でも vue-loader のバージョンで書き方が変わる/deep/ は古い vue-loader でしか動かないが、プロジェクトによっては未だに使われている。Vue 2 のプロジェクトで ::v-deep が効かない場合は vue-loader のバージョンを確認するとよい。
  • Vue 3 で ::v-deep を使うと deprecation warning が出る。動きはするが、:deep() への移行が推奨されている。
  • /deep/>>> は Vue 3 では完全に動作しない。移行時に一括置換が必要になる。
  • Sass/SCSS を使っている場合、>>> は Sass パーサーが解釈できないため使えない。Vue 2 時代から ::v-deep が事実上の標準だった理由でもある。

Vuetify 2 → 3 移行時の注意点まとめ

項目Vuetify 2Vuetify 3
group-by の型string{ key: string, order?: 'asc' | 'desc' }[]
group-descあり廃止(group-byorder で指定)
グループヘッダースロットgroup.headergroup-header
スロットの引数{ items, isOpen, toggle }{ item, columns, toggleGroup, isGroupOpen }
custom-sort propありcustom-key-sort に変更(行単位のカスタムソート)
.sync 修飾子sort-by.sync / sort-desc.syncv-model:sort-by(オブジェクト配列)
sort-by の型string{ key: string, order: 'asc' | 'desc' }[]
deep セレクタ::v-deep .child:deep(.child)

Vuetify 3 では sort-by が配列になったことで、マルチカラムソートがネイティブでサポートされた。一方で custom-sort が廃止されているため、この記事で紹介したような「computed で事前ソートして items に渡す」パターンの重要性がさらに増している。


まとめ

  • v-data-table のソートは常にグループ内に閉じる。グループの並び順を変えたければ、:items に渡すデータ自体を事前ソートする
  • グループ単位の集計値(最古/最新日時、合計時間など)を各行に付与しておくと、グループヘッダーのカスタマイズやグループ単位ソートの実装が楽になる
  • custom-sort を identity 関数にして sort-by / sort-desc の UI 状態だけ借りるのが、グループ単位ソートの定番パターン
  • Vuetify 3 では custom-sort が廃止されたため、computed による事前ソートが標準的なアプローチになる
  • CSS 疑似要素で のツリー罫線を描画すると、グループ内の階層関係が視覚的に伝わる
  • deep セレクタの書き方は Vue / vue-loader のバージョンで異なる。移行時の一括置換を忘れずに
目次