0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Vuetify3 v-data-table-serverを使った表パターン試した

Last updated at Posted at 2025-09-13

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行にする

実行結果のイメージ

スクリーンショット 2025-09-13 21.59.04.png

ソースコード

<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が連続した場合に、セル結合して表示するみたいなイメージ。

image.png

ソースコード

要は、データを回してキーブレイクしてって感じです

<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の世界ですね。

実行結果のイメージ
スクリーンショット 2025-09-13 22.36.56.png

ソースコード

これは、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>

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?