v-data-table は Vuetify が誇る高機能テーブルコンポーネントだが、グルーピングとソートを組み合わせた途端に「あれ、思った通りに動かない」という壁にぶつかることが多い。この記事では、公式ドキュメントだけでは辿り着きにくい実践的なテクニックを、Vuetify 2 / 3 の差異も踏まえてまとめる。
グルーピングの基本
Vuetify 2
group-by と group-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-sort で v-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);
});
}
ここでのキモは:
custom-sortに() => sortedItemsを渡すことで、v-data-tableのデフォルトソートを無効化するsort-byとsort-descの sync は残すことで、ヘッダークリックによるUI状態(どのカラムがソートされているか、昇順/降順か)は通常通り機能させ、その state を自前のソートロジックで参照する- グループ間の比較にはグループ代表値(
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 で中央から右へ横線を引くことで ┣ を表現する。最終行では ::before の bottom: 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/ .child | Vue 2 + vue-loader 14 以前 | CSS 仕様の >>> と同等。最も古い書き方 |
>>> .child | Vue 2 + vue-loader 14 以前 | Sass/SCSS では使えない |
::v-deep .child | Vue 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 2 | Vuetify 3 |
|---|---|---|
group-by の型 | string | { key: string, order?: 'asc' | 'desc' }[] |
group-desc | あり | 廃止(group-by の order で指定) |
| グループヘッダースロット | group.header | group-header |
| スロットの引数 | { items, isOpen, toggle } | { item, columns, toggleGroup, isGroupOpen } |
custom-sort prop | あり | custom-key-sort に変更(行単位のカスタムソート) |
.sync 修飾子 | sort-by.sync / sort-desc.sync | v-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 のバージョンで異なる。移行時の一括置換を忘れずに