v-data-table-serverを使った様々なデータ表を試した
Nuxt3,Vuetify3を使ってテーブルを作るタイミングがあり、シンプルな表からExcelのようなヘッター行2行や、データ行の結合とかパターンを試行錯誤しました。
色々、苦労した部分があったので、共有しようと思い記事を書きます。
※コードはすべて、VuetifyのPlaygroundで動くものです。お手元で確認したい場合でも、Playgroundにピタっと貼ってもらえればカスタマイズできると思います
※ソースは綺麗にしていないので、ネストとかv-forとかは無礼講でお願いします。
今回使ったのは、v-data-table-serverです。公式は以下です。Playgroundも以下のリンクから見れます。
https://vuetifyjs.com/en/components/data-tables/server-side-tables/
パターン①ヘッダー行を2行にする
実行結果のイメージ
ソースコード
<template>
<v-data-table-server
v-model:items-per-page="itemsPerPage"
:headers="headers"
:items="serverItems"
:items-length="totalItems"
:loading="loading"
:search="search"
item-value="name"
@update:options="loadItems"
>
<template #item.teika="{item}"> {{ item.price.teika }} </template>
<template #item.zeikomi="{item}"> {{ item.price.zeikomi }} </template>
</v-data-table-server>
</template>
<script setup>
import { ref } from 'vue'
const desserts = [
{
name: 'Frozen Yogurt',
calories: 159,
fat: 6,
carbs: 24,
protein: 4,
iron: '1',
price: {
teika: 100,
zeikomi: 110,
},
},
{
name: 'Jelly bean',
calories: 375,
fat: 0,
carbs: 94,
protein: 0,
iron: '0',
price: {
teika: 200,
zeikomi: 220,
},
},
{
name: 'KitKat',
calories: 518,
fat: 26,
carbs: 65,
protein: 7,
iron: '6',
price: {
teika: 300,
zeikomi: 330,
},
},
{
name: 'Eclair',
calories: 262,
fat: 16,
carbs: 23,
protein: 6,
iron: '7',
price: {
teika: 400,
zeikomi: 440,
},
},
{
name: 'Gingerbread',
calories: 356,
fat: 16,
carbs: 49,
protein: 3.9,
iron: '16',
price: {
teika: 500,
zeikomi: 550,
},
},
{
name: 'Ice cream sandwich',
calories: 237,
fat: 9,
carbs: 37,
protein: 4.3,
iron: '1',
price: {
teika: 600,
zeikomi: 660,
},
},
{
name: 'Lollipop',
calories: 392,
fat: 0.2,
carbs: 98,
protein: 0,
iron: '2',
price: {
teika: 700,
zeikomi: 770,
},
},
{
name: 'Cupcake',
calories: 305,
fat: 3.7,
carbs: 67,
protein: 4.3,
iron: '8',
price: {
teika: 800,
zeikomi: 880,
},
},
{
name: 'Honeycomb',
calories: 408,
fat: 3.2,
carbs: 87,
protein: 6.5,
iron: '45',
price: {
teika: 900,
zeikomi: 990,
},
},
{
name: 'Donut',
calories: 452,
fat: 25,
carbs: 51,
protein: 4.9,
iron: '22',
price: {
teika: 1000,
zeikomi: 1100,
},
},
]
const FakeAPI = {
async fetch ({ page, itemsPerPage, sortBy }) {
return new Promise(resolve => {
setTimeout(() => {
const start = (page - 1) * itemsPerPage
const end = start + itemsPerPage
const items = desserts.slice()
if (sortBy.length) {
const sortKey = sortBy[0].key
const sortOrder = sortBy[0].order
items.sort((a, b) => {
const aValue = a[sortKey]
const bValue = b[sortKey]
return sortOrder === 'desc' ? bValue - aValue : aValue - bValue
})
}
const paginated = items.slice(start, end === -1 ? undefined : end)
resolve({ items: paginated, total: items.length })
}, 500)
})
},
}
const itemsPerPage = ref(5)
const headers = ref([
{
title: 'Dessert (100g serving)',
align: 'start',
sortable: false,
key: 'name',
},
{ title: 'Calories', key: 'calories', align: 'end' },
{ title: 'Fat (g)', key: 'fat', align: 'end' },
{ title: 'Carbs (g)', key: 'carbs', align: 'end' },
{ title: 'Protein (g)', key: 'protein', align: 'end' },
{ title: 'Iron (%)', key: 'iron', align: 'end' },
{ title: 'price ($)', key: 'price', align: 'center', children: [{ title: '定価', key: 'teika'},{ title: '税込み', key: 'zeikomi'}] },
])
const search = ref('')
const serverItems = ref([])
const loading = ref(true)
const totalItems = ref(0)
function loadItems ({ page, itemsPerPage, sortBy }) {
loading.value = true
FakeAPI.fetch({ page, itemsPerPage, sortBy }).then(({ items, total }) => {
serverItems.value = items
totalItems.value = total
loading.value = false
})
}
</script>
よくみたら、childrenって要素持っていますね。(気づくの遅くて、結構めんどうだった。)
https://vuetifyjs.com/en/api/v-data-table-server/#props-headers
パターン②データ行を2行にする
同じコード値が連続した場合に、連続している限りデータ行を結合して表示するみたいなユースケース
実行結果のイメージ
Proteinが連続した場合に、セル結合して表示するみたいなイメージ。
ソースコード
要は、データを回してキーブレイクしてって感じです
<template>
<v-data-table-server
v-model:items-per-page="itemsPerPage"
:headers="headers"
:items="fixedDataTble"
:items-length="totalItems"
:loading="loading"
:search="search"
item-value="name"
@update:options="loadItems"
>
<template #body>
<tr v-for="row in fixedDataTble" :key="row.name">
<td>{{ row.name }}</td>
<td>{{ row.calories }}</td>
<td>{{ row.fat }}</td>
<td>{{ row.carbs }}</td>
<template v-if="row.showType">
<td :rowspan="row.rowspan">{{ row.protein }}</td>
</template>
<td>{{ row.iron }}</td>
</tr>
</template>
</v-data-table-server>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
const desserts = [
{
name: 'Frozen Yogurt',
calories: 159,
fat: 6,
carbs: 24,
protein: 4,
iron: '1',
},
{
name: 'Jelly bean',
calories: 375,
fat: 0,
carbs: 94,
protein: 0,
iron: '0',
},
{
name: 'KitKat',
calories: 518,
fat: 26,
carbs: 65,
protein: 0,
iron: '6',
},
{
name: 'Eclair',
calories: 262,
fat: 16,
carbs: 23,
protein: 6,
iron: '7',
},
{
name: 'Gingerbread',
calories: 356,
fat: 16,
carbs: 49,
protein: 3.9,
iron: '16',
},
{
name: 'Ice cream sandwich',
calories: 237,
fat: 9,
carbs: 37,
protein: 4.3,
iron: '1',
},
{
name: 'Lollipop',
calories: 392,
fat: 0.2,
carbs: 98,
protein: 0,
iron: '2',
},
{
name: 'Cupcake',
calories: 305,
fat: 3.7,
carbs: 67,
protein: 0,
iron: '8',
},
{
name: 'Honeycomb',
calories: 408,
fat: 3.2,
carbs: 87,
protein: 6.5,
iron: '45',
},
{
name: 'Donut',
calories: 452,
fat: 25,
carbs: 51,
protein: 4.9,
iron: '22',
},
]
const FakeAPI = {
async fetch ({ page, itemsPerPage, sortBy }) {
return new Promise(resolve => {
setTimeout(() => {
const start = (page - 1) * itemsPerPage
const end = start + itemsPerPage
const items = desserts.slice()
if (sortBy.length) {
const sortKey = sortBy[0].key
const sortOrder = sortBy[0].order
items.sort((a, b) => {
const aValue = a[sortKey]
const bValue = b[sortKey]
return sortOrder === 'desc' ? bValue - aValue : aValue - bValue
})
}
const paginated = items.slice(start, end === -1 ? undefined : end)
resolve({ items: paginated, total: items.length })
}, 500)
})
},
}
const itemsPerPage = ref(5)
const headers = ref([
{
title: 'Dessert (100g serving)',
align: 'start',
sortable: false,
key: 'name',
},
{ title: 'Calories', key: 'calories', align: 'end' },
{ title: 'Fat (g)', key: 'fat', align: 'end' },
{ title: 'Carbs (g)', key: 'carbs', align: 'end' },
{ title: 'Protein (g)', key: 'protein', align: 'end' },
{ title: 'Iron (%)', key: 'iron', align: 'end' },
])
const search = ref('')
const serverItems = ref([])
const loading = ref(true)
const totalItems = ref(0)
function loadItems ({ page, itemsPerPage, sortBy }) {
loading.value = true
FakeAPI.fetch({ page, itemsPerPage, sortBy }).then(({ items, total }) => {
serverItems.value = items
totalItems.value = total
loading.value = false
})
}
const fixedDataTble = computed(() => {
const result: any[] = [] // 表示用にフォーマットされたデータ
let prevType: number | null = null // 直前の行の値を覚える(今回はProtein)
let count = 0 // 連続している数
let startIndex = 0 // 同じ値のグループの開始Index
desserts.forEach((row,index) => {
if(row.protein === prevType) {
count++
} else {
if(count > 1){
for(let i = startIndex + 1; i < startIndex + count; i++) {
result[i].showType = false
}
result[startIndex].rowspan = count
}
count = 1
startIndex = index
prevType = row.protein
}
result.push({
...row,
showType: true,
rowspan: 1,
})
})
// 最終行は別途判定(0番目→1番目、1番目→2番目っていう順で比較。最終行は、次のデータがないから)
if(count > 1) {
for(let i = startIndex + 1; i < startIndex + count; i++){
result[i].showType = false
}
result[startIndex].rowspan = count
}
return result
})
</script>
パターン③行ごとに色を変えたい
これは、もうCSSの世界ですね。
ソースコード
これは、CSSだけですね。(へえぇ、そんなこと出来るんだって勉強になった)
<template>
<v-data-table-server
v-model:items-per-page="itemsPerPage"
class="zebra"
:headers="headers"
:items="serverItems"
:items-length="totalItems"
:loading="loading"
:search="search"
item-value="name"
@update:options="loadItems"
></v-data-table-server>
</template>
<script setup>
import { ref } from 'vue'
const desserts = [
{
name: 'Frozen Yogurt',
calories: 159,
fat: 6,
carbs: 24,
protein: 4,
iron: '1',
},
{
name: 'Jelly bean',
calories: 375,
fat: 0,
carbs: 94,
protein: 0,
iron: '0',
},
{
name: 'KitKat',
calories: 518,
fat: 26,
carbs: 65,
protein: 7,
iron: '6',
},
{
name: 'Eclair',
calories: 262,
fat: 16,
carbs: 23,
protein: 6,
iron: '7',
},
{
name: 'Gingerbread',
calories: 356,
fat: 16,
carbs: 49,
protein: 3.9,
iron: '16',
},
{
name: 'Ice cream sandwich',
calories: 237,
fat: 9,
carbs: 37,
protein: 4.3,
iron: '1',
},
{
name: 'Lollipop',
calories: 392,
fat: 0.2,
carbs: 98,
protein: 0,
iron: '2',
},
{
name: 'Cupcake',
calories: 305,
fat: 3.7,
carbs: 67,
protein: 4.3,
iron: '8',
},
{
name: 'Honeycomb',
calories: 408,
fat: 3.2,
carbs: 87,
protein: 6.5,
iron: '45',
},
{
name: 'Donut',
calories: 452,
fat: 25,
carbs: 51,
protein: 4.9,
iron: '22',
},
]
const FakeAPI = {
async fetch ({ page, itemsPerPage, sortBy }) {
return new Promise(resolve => {
setTimeout(() => {
const start = (page - 1) * itemsPerPage
const end = start + itemsPerPage
const items = desserts.slice()
if (sortBy.length) {
const sortKey = sortBy[0].key
const sortOrder = sortBy[0].order
items.sort((a, b) => {
const aValue = a[sortKey]
const bValue = b[sortKey]
return sortOrder === 'desc' ? bValue - aValue : aValue - bValue
})
}
const paginated = items.slice(start, end === -1 ? undefined : end)
resolve({ items: paginated, total: items.length })
}, 500)
})
},
}
const itemsPerPage = ref(5)
const headers = ref([
{
title: 'Dessert (100g serving)',
align: 'start',
sortable: false,
key: 'name',
},
{ title: 'Calories', key: 'calories', align: 'end' },
{ title: 'Fat (g)', key: 'fat', align: 'end' },
{ title: 'Carbs (g)', key: 'carbs', align: 'end' },
{ title: 'Protein (g)', key: 'protein', align: 'end' },
{ title: 'Iron (%)', key: 'iron', align: 'end' },
])
const search = ref('')
const serverItems = ref([])
const loading = ref(true)
const totalItems = ref(0)
function loadItems ({ page, itemsPerPage, sortBy }) {
loading.value = true
FakeAPI.fetch({ page, itemsPerPage, sortBy }).then(({ items, total }) => {
serverItems.value = items
totalItems.value = total
loading.value = false
})
}
</script>
<style scoped>
.zebra :deep(tbody tr:nth-child(even)) {
background-color: white;
}
.zebra :deep(tbody tr:nth-child(odd)) {
background-color: antiquewhite;
}
.zebra :deep(th),
.zebra :deep(td) {
padding: 5px;
border: 3px solid black;
}
</style>


