Github で非常に人気のあるヘッドレスUI Tanstack が提供するライブラリの1つにテーブルがあります。この記事では TanStack が提供している vue-table の使い方の説明です。
これからはじめるVue.js 3実践入門
TanStackとは?
技術記事を見ているときに、目に付いたのが TanStack です。
@tanstack はヘッドレスUIを提供しており、Github で複数のリポジトリが存在し、 Table だけでなく Query など複数のライブラリが提供されています。その上、Table だけでも Github スター が 2022/10 時点で 19.4K もあるようです。もともとは React のみでしたが、現在はヘッドレスUIと言う通り、 Angular や Vue ,Svelet などライブラリに依存せず利用出来るようになっています。
@tanstack/table とは?
TanStack Table は、TS/JS、React、Vue、Solid 用の強力なテーブルとデータグリッドを構築するためのヘッドレス UIライブラリです。
https://tanstack.com/table/v8/docs/guide/introduction
冒頭にもヘッドレス UIという言葉がありましたが、ヘッドレスUI ライブラリとは?
これも公式にありました。
UI 要素とインタラクションのロジック、状態、処理、および API を提供するが、マークアップ、スタイル、または事前構築済みの実装を提供しないライブラリとユーティリティ
https://tanstack.com/table/v8/docs/guide/introduction
つまり、レンダリングするUIはもたないけど、UIを構築するために必要なロジックのみを持つライブラリ、ユーザインタフェースを持たないUIライブラリ、を指すようです。ヘッドレス CMS みたいな捉えですね。
ロジック(IF)が決まっていて、React でも Vue でも同じように使えると思うとたしかに嬉しいですね。
ちなみに、ヘッドレスUIライブラリの良し悪しは公式にも書かれていますので目を通しておくと良いと思いますが、当然のことながら、何もレンダリングしない時点で、デメリットは通常の VueやReactのUIライブラリと比較すると、すぐに使えない(UIは自分で作ららないといけない)点です。
ただ、将来この流れが続くなら ライブラリごとに IF が統一された UIライブラリが登場しそうな気もします。
今回は table を触って見ましたが
- ソート
- フィルタ
- 列幅のリサイズ
- セルの編集
- ページング
- 列のグルーピング
- 仮想無限スクロール
- 行のDnD
- などなど
思った以上に高機能です。また、冒頭の通り、思想的に Vue や React などのライブラリに依存しないため、プロジェクトによって利用するライブラリが違うようなケースではかなり使えるのではないでしょうか。
セットアップ
プロジェクトの作成。今回も Vite で行います。
// vite プロジェクトを作成
yarn create vite tanstack-vue-table --template vue-ts
// eslint / prettier。いつも何をインストールするのか忘れるので...
yarn add -D eslint eslint-plugin-vue @vue/eslint-config-typescript @typescript-eslint/parser @typescript-eslint/eslint-plugin prettier @vue/eslint-config-prettier
// tanstack/vue-table の追加
yarn add @tanstack/vue-table
prettier と eslint の設定も載せておきます。prettierの設定ファイルは .prettierrc.json
です。
正確には 公式 に記載されているパターンの名前であればOKです。
{
"singleQuote": true,
"semi": false,/
"tabWidth": 2
}
env:
browser: true
extends:
- "plugin:vue/vue3-recommended"
- "eslint:recommended"
- "@vue/typescript/recommended"
parserOptions:
parser: "@typescript-eslint/parser"
sourceType: "module"
plugins:
- vue
- "@typescript-eslint"
rules: {}
テーブルの表示
tanstack/vue-table で利用されているサンプルデータを利用した一番シンプルなテーブルです。
以下、tanstack/vue-table 部分に関する補足です。コード中にもコメント記載しています。
useVueTable
で指定したオプションのテーブル用オブジェクトを作成します。getCoreRowModel
は行ごとのデータモデルを定義する関数のデフォルト実装で、useVueTable 時に必須のパラメータです。Colu
mnDef /createColumnHelper
は名前の通りカラム定義に利用します。createColumnHelper
はこちらにある通り、以下の3種類があるようです。- group(列のグルーピング)
accessor
(列の定義、フィルタ処理など)display
(チェックボックやアクションボタンなど、行ごとにコンテンツを表示する)
HTML 部分は、素の table に対して、ヘッダ / データを差し込んでいます。vue-table のオブジェクト構造は詳しくわからなくても、Vue の for さえ知っていれば、なんとなくやっていることは分かると思います。
FlexRender
は 内部では cell がプリミティブならその値、オブジェクトまたは関数の場合は vue.h
でレンダリングを行っています。cell
は上で定義した関数です。今回はどの列も (info) => info.getValue()
です。なので、render
はこのメソッドの結果がレンダリングされることになります。
無理やり書くと以下でも表示内容と同じ結果を得ることができます。
cell.column.columnDef.cell && typeof cell.column.columnDef.cell == 'function' && cell.column.columnDef.cell(cell.getContext())
この書き方で cell も header も footer も同じように書ける様になっています。
<script setup lang="ts">
import {
FlexRender,
getCoreRowModel,
useVueTable,
ColumnDef,
createColumnHelper,
} from '@tanstack/vue-table'
import { ref } from 'vue'
import UserListData from '../test/data.json'
type User = {
firstName: string
lastName: string
age: number
visits: number
status: string
progress: number
}
// ユーザ情報. 本来サーバから取得する想定のデータ
const defaultData: User[] = UserListData
// テーブルに表示するデータ
const userList = ref<User[]>([])
// カラム定義
const columnHelper = createColumnHelper<User>()
const columns: ColumnDef<User, any>[] = [
// 1つ目の引数が列プロパティ。文字列の場合は列のIDであり、データ表示時のプロパティ名として扱われる。
// (row) => row.firstName のように関数を指定することも可能。
// cell は表示するデータで、getValue でプロパティ値が取得できる。
// info は row / column など後述で貼っているイメージにあるプロパティを持つ。
columnHelper.accessor('firstName', { cell: (info) => info.getValue() }),
columnHelper.accessor('lastName', { cell: (info) => info.getValue() }),
columnHelper.accessor('age', { cell: (info) => info.getValue() }),
columnHelper.accessor('visits', { cell: (info) => info.getValue() }),
columnHelper.accessor('status', { cell: (info) => info.getValue() }),
columnHelper.accessor('progress', { cell: (info) => info.getValue() }),
]
// vue-table の作成
const table = useVueTable({
// データ
get data() {
return userList.value
},
// カラム定義
columns,
// 必須のオプション, 行モデルを返す関数を指定。
// デフォルト実装が提供されているのでそちらを利用
getCoreRowModel: getCoreRowModel(),
})
</script>
<template>
<div>
<table :border="1">
<thead>
<tr
v-for="headerGroup in table.getHeaderGroups()"
:key="headerGroup.id"
>
<th
v-for="header in headerGroup.headers"
:key="header.id"
:colSpan="header.colSpan"
>
<FlexRender
v-if="!header.isPlaceholder"
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in table.getRowModel().rows" :key="row.id">
<td v-for="cell in row.getVisibleCells()" :key="cell.id">
<FlexRender
:render="cell.column.columnDef.cell"
:props="cell.getContext()"
/>
</td>
</tr>
</tbody>
</table>
<button @click="() => (userList = defaultData)">Rerender</button>
</div>
</template>
info は以下のような値を持ちます。
こちらはテストデータです。
[
{
"firstName": "tanner",
"lastName": "linsley",
"age": 24,
"visits": 100,
"status": "In Relationship",
"progress": 50
},
{
"firstName": "tandy",
"lastName": "miller",
"age": 40,
"visits": 40,
"status": "Single",
"progress": 80
},
{
"firstName": "joe",
"lastName": "dirte",
"age": 45,
"visits": 20,
"status": "Complicated",
"progress": 10
}
]
画面に表示した結果です。
左が表示直後。列は columnHelper.accessor で指定したものがでています。
右が Rerender
ボタンをクリックした後です。ボタンクリックでテストデータをセットするようにしているので、テストデータが流し込まれているのが分かります。
複雑なヘッダ/フッタ
公式のサンプルに出ている複数行のヘッダカラムやフッターの表示です。
フッター
まずはフッターから。列定義の部分に footer
をつけるだけです。
HTML 部分もヘッダーと同じように記載するだけです(headerがfooterになっただけ)。
const columns: ColumnDef<User, any>[] = [
columnHelper.accessor('firstName', {
cell: (info) => info.getValue(),
footer: (props) => "テスト",
}),
columnHelper.accessor('lastName', {
cell: (info) => info.getValue(),
}),
columnHelper.accessor('age', {
cell: (info) => info.getValue(),
footer: (props) => props.column.id,
}),
columnHelper.accessor('visits', {
cell: (info) => info.getValue(),
footer: (props) => props.column.id,
}),
columnHelper.accessor('status', {
cell: (info) => info.getValue(),
}),
columnHelper.accessor('progress', {
cell: (info) => info.getValue(),
}),
]
~~~
<tfoot>
<tr
v-for="footerGroup in table.getFooterGroups()"
:key="footerGroup.id"
>
<th
v-for="header in footerGroup.headers"
:key="header.id"
:colSpan="header.colSpan"
>
<FlexRender
v-if="!header.isPlaceholder"
:render="header.column.columnDef.footer"
:props="header.getContext()"
/>
</th>
</tr>
</tfoot>
あえて歯抜けで footer を指定しましたが、歯抜けの場合はそのまま空白になります。 固定文字列を返せばもちろんその文字がでます。
ヘッダー
table 表示のときに説明した columnHelper.group
を利用します。
columns の配列にこれまで記載した accesor たちを入れればグルーピングできます。
footer はこれまでと同じですね。
const columns: ColumnDef<User, any>[] = [
columnHelper.group({
header: 'Name',
footer: (props) => props.column.id,
columns: [
columnHelper.accessor('firstName', {
cell: (info) => info.getValue(),
footer: (props) => 'テスト',
}),
columnHelper.accessor('lastName', {
cell: (info) => info.getValue(),
}),
],
}),
columnHelper.group({
header: 'Other',
footer: (props) => props.column.id,
columns: [
columnHelper.accessor('age', {
cell: (info) => info.getValue(),
footer: (props) => props.column.id,
}),
columnHelper.accessor('visits', {
cell: (info) => info.getValue(),
footer: (props) => props.column.id,
}),
columnHelper.accessor('status', {
cell: (info) => info.getValue(),
}),
columnHelper.accessor('progress', {
cell: (info) => info.getValue(),
}),
],
}),
]
簡単ですね。
ソート
ソートも一般的なものであればとても簡単に実現できます。
useVueTable にパラメータを与えます。具体的には、テーブルのソート状態、ソート条件が変わったときのイベント処理、ソート処理のロジック、を指定します。デフォルト実装があるのでそちらを使っています。
const table = useVueTable({
// データ
get data() {
return userList.value
},
// カラム定義
columns,
// 必須のオプション, 行モデルを返す関数を指定。
// デフォルト実装が提供されているのでそちらを利用
getCoreRowModel: getCoreRowModel(),
// テーブルの状態を管理
// ソートだけでなく、カラムの順番やカラムの表示状態なども持つことができる
state: {
get sorting() {
return sorting.value
},
},
// ソート条件が変わった場合の処理
// 未ソート → 降順 → 照準 → 未ソート
onSortingChange: (updaterOrValue) => {
sorting.value =
typeof updaterOrValue === 'function'
? updaterOrValue(sorting.value)
: updaterOrValue
},
// ソートした後にデータをソートする処理モデル
// デフォルト実装が提供されているのでそちらを利用
getSortedRowModel: getSortedRowModel(),
})
</script>
HTML部分の変更はクリックしたときのイベント発火処理です。
<th
v-for="header in headerGroup.headers"
:key="header.id"
:colSpan="header.colSpan"
// これはただのスタイル
:style="header.column.getCanSort() ? 'cursor: pointer' : ''"
// 上で定義した処理を読んでくれるイベントハンドラを呼びます
@click="header.column.getToggleSortingHandler()?.($event)"
>
<template v-if="!header.isPlaceholder">
<FlexRender
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
{{
// ソート状態を表現
{ asc: '[up]', desc: '[down]' }[
header.column.getIsSorted() as string
]
}}
</template>
</th>
visits をソートした状態です。問題なくソートできますね。
フィルタ
フィルタもやり方としてはソートに近いですが、UI の振る舞いは作り込みが必要なので複雑になってきます。とは言え、調べるほど様々な API が提供されており、高度なことができそうです。
今回は React のサンプルにあるような文字入力フィルタをやります。が、選択肢まではだしていません。
useVueTable には以下を追加で渡します。こちらもソート同様、デフォルト実装が提供されています。
できることはたくさんあり、詳しく知りたい方はこちらをみるとよいです。
// フィルタ後のデータフィルタ処理モデル
getFilteredRowModel: getFilteredRowModel(),
HTML 側です。ヘッダ部分です。本当は1つ1つコンポーネント化すればいいのですが、ここではベタに記載しています。
header.column.getCanFilter()
でフィルタ可能化がチェックできます。typeof table.getPreFilteredRowModel().flatRows[0]?.getValue(header.column.id) === 'number'
で先頭行の type を見ており、number か string かで表示するフィルタを分けています。number
の場合は、min – max がフィルタとして指定出来るようです。min 側には getFilterValue の1つ目の要素を、max は2つ目の要素を指定し、値の変更イベント(@input)で値を更新しています。これにより、行のデータも動的に変わります。- string の場合は、デフォルトでは部分一致でフィルタされます。動きは
number
と同じです。
<th
v-for="header in headerGroup.headers"
:key="header.id"
:colSpan="header.colSpan"
:style="header.column.getCanSort() ? 'cursor: pointer' : ''"
>
<template v-if="!header.isPlaceholder">
<FlexRender
:render="header.column.columnDef.header"
:props="header.getContext()"
@click="header.column.getToggleSortingHandler()?.($event)"
/>
{{
{ asc: '[up]', desc: '[down]' }[
header.column.getIsSorted() as string
]
}}
<div v-if="header.column.getCanFilter()">
<div
v-if="
typeof table
.getPreFilteredRowModel()
.flatRows[0]?.getValue(header.column.id) === 'number'
"
>
<input
type="number"
:min="0"
:max="100"
:value="(header.column.getFilterValue() as [number, number])?.[0]"
@input="(event:Event) => header.column.setFilterValue((old: [number, number]) => [event.target.value, old?.[1]])"
:placeholder="`Search...(${
header.column.getFacetedUniqueValues().size
})`"
/>
<input
type="number"
:min="0"
:max="100"
:value="(header.column.getFilterValue() as [number, number])?.[1]"
@input="(event:Event) => header.column.setFilterValue((old: [number, number]) => [old?.[0], event.target.value])"
:placeholder="`Search...(${
header.column.getFacetedUniqueValues().size
})`"
/>
</div>
<div v-else>
<input
type="text"
:value="header.column.getFilterValue()"
@input="(event:Event) => header.column.setFilterValue(event.target.value)"
:placeholder="`Search...(${
header.column.getFacetedUniqueValues().size
})`"
/>
</div>
</div>
</template>
</th>
</tr>
文字列も数値もフィルタすることができています。
列幅の変更
列幅のドラッグドロップによる変更です。
React のサンプルを見ながらやればすぐ出来るかと思いきや、結構苦戦しました。
useVueTable に追加すべきは以下の2つです。順番は逆ですが、enableColumnResizing
を true にしないと、リサイズされません。これを true にした上で、カラムが実際にリサイズされるタイミングを指定します。今回はドラッグしながら動きが見える onChange
を指定します。
// カラムの幅変更するイベント:onChange だとドラッグ中に幅が変わって見える
// onEnd の場合はドロップ時
columnResizeMode: 'onChange',
enableColumnResizing: true,
HTML側ですが、ポイントを以下似記載します(最後にここまでのコード全体をのせています)
// table 全体のサイズを指定
<table :border="1" :style="{ width: table.getCenterTotalSize() }">
~~~
// style に getSize() を追加します。React のサンプルでは px は
// ありませんが、これがないと動きません。
<th
v-for="header in headerGroup.headers"
:key="header.id"
:colSpan="header.colSpan"
:style="{
width: header.getSize() + 'px',
cursor: header.column.getCanSort() ? 'pointer' : '',
}"
>
~~~
// ヘッダに表示する情報に以下を追加します。
// こちらも、React版と同じ指定の仕方をしても動きません。
// getREsizeHandler() でイベントに応じた処理を行う関数が返却されるので
// その処理が実行されるようにします。引数としてイベントの指定が必要です
// class や style はドラッグ対象の要素です。実際に table 要素を掴んでいる
// のではなく、ここで追加した div 要素を掴みドラッグしています。
// そのスタイルの指定になります。
<div
@mousedown="header.getResizeHandler()($event)"
@touchstart="header.getResizeHandler()($event)"
:class="`resizer ${
header.column.getIsResizing() ? 'isResizing' : ''
}`"
:style="{
transform: `translateX(${
table.getState().columnSizingInfo.deltaOffset
} px)`,
}"
></div>
~~~
// フッターも同じく getSize の指定をします。
<tfoot>
<tr
v-for="footerGroup in table.getFooterGroups()"
:key="footerGroup.id"
>
<th
v-for="header in footerGroup.headers"
:key="header.id"
:colSpan="header.colSpan"
:style="{
width: header.column.getSize(),
}"
>
<FlexRender
v-if="!header.isPlaceholder"
:render="header.column.columnDef.footer"
:props="header.getContext()"
/>
</th>
</tr>
</tfoot>
これで列幅のリサイズができました。
まとめ
長くなったので本記事ではここまでにしますが、他にも、編集可能なセル、ページング、無限スクロール、実際に使うならサーバとの通信は必須なのでその場合の実装方法、などなど知っておきたいことは色々あります。これらはまた別の機会に調べてみたいと思います。
最後に今回のコード全体のせておきます。
<script setup lang="ts">
import {
FlexRender,
getCoreRowModel,
useVueTable,
ColumnDef,
createColumnHelper,
SortingState,
getSortedRowModel,
getFilteredRowModel,
} from '@tanstack/vue-table'
import { ref } from 'vue'
import UserListData from '../test/data.json'
type User = {
firstName: string
lastName: string
age: number
visits: number
status: string
progress: number
}
// ユーザ情報. 本来サーバから取得する想定のデータ
const defaultData: User[] = UserListData
// テーブルに表示するデータ
const userList = ref<User[]>([])
const columnHelper = createColumnHelper<User>()
const columns: ColumnDef<User, any>[] = [
columnHelper.group({
header: 'Name',
footer: (props) => props.column.id,
columns: [
columnHelper.accessor('firstName', {
cell: (info) => info.getValue(),
footer: (props) => 'テスト',
}),
columnHelper.accessor('lastName', {
cell: (info) => info.getValue(),
}),
],
}),
columnHelper.group({
header: 'Other',
footer: (props) => props.column.id,
columns: [
columnHelper.accessor('age', {
cell: (info) => info.getValue(),
footer: (props) => props.column.id,
}),
columnHelper.accessor('visits', {
cell: (info) => info.getValue(),
footer: (props) => props.column.id,
}),
columnHelper.accessor('status', {
cell: (info) => info.getValue(),
}),
columnHelper.accessor('progress', {
cell: (info) => info.getValue(),
}),
],
}),
]
const sorting = ref<SortingState>([])
const table = useVueTable({
// データ
get data() {
return userList.value
},
// カラム定義
columns,
// 必須のオプション, 行モデルを返す関数を指定。
// デフォルト実装が提供されているのでそちらを利用
getCoreRowModel: getCoreRowModel(),
// テーブルの状態を管理
// ソートだけでなく、カラムの順番やカラムの表示状態なども持つことができる
state: {
get sorting() {
return sorting.value
},
},
// ソート条件が変わった場合の処理
// 未ソート → 降順 → 照準 → 未ソート
onSortingChange: (updaterOrValue) => {
sorting.value =
typeof updaterOrValue === 'function'
? updaterOrValue(sorting.value)
: updaterOrValue
},
// ソートした後にデータをソートする処理モデル
// デフォルト実装が提供されているのでそちらを利用
getSortedRowModel: getSortedRowModel(),
// フィルタ後のデータフィルタ処理モデル
getFilteredRowModel: getFilteredRowModel(),
// カラムの幅変更するイベント:onChange だとドラッグ中に幅が変わって見える
// onEnd の場合はドロップ時
columnResizeMode: 'onChange',
enableColumnResizing: true,
})
</script>
<template>
<div>
<table :border="1" :style="{ width: table.getCenterTotalSize() }">
<thead>
<tr
v-for="headerGroup in table.getHeaderGroups()"
:key="headerGroup.id"
>
<th
v-for="header in headerGroup.headers"
:key="header.id"
:colSpan="header.colSpan"
:style="{
width: header.getSize() + 'px',
cursor: header.column.getCanSort() ? 'pointer' : '',
}"
>
<template v-if="!header.isPlaceholder">
<FlexRender
:render="header.column.columnDef.header"
:props="header.getContext()"
@click="header.column.getToggleSortingHandler()?.($event)"
/>
{{
{ asc: '[up]', desc: '[down]' }[
header.column.getIsSorted() as string
]
}}
<div v-if="header.column.getCanFilter()">
<div
v-if="
typeof table
.getPreFilteredRowModel()
.flatRows[0]?.getValue(header.column.id) === 'number'
"
>
<input
type="number"
:min="0"
:max="100"
:value="(header.column.getFilterValue() as [number, number])?.[0]"
@input="(event:Event) => header.column.setFilterValue((old: [number, number]) => [event.target.value, old?.[1]])"
:placeholder="`Search...(${
header.column.getFacetedUniqueValues().size
})`"
/>
<input
type="number"
:min="0"
:max="100"
:value="(header.column.getFilterValue() as [number, number])?.[1]"
@input="(event:Event) => header.column.setFilterValue((old: [number, number]) => [old?.[0], event.target.value])"
:placeholder="`Search...(${
header.column.getFacetedUniqueValues().size
})`"
/>
</div>
<div v-else>
<input
type="text"
:value="header.column.getFilterValue()"
@input="(event:Event) => header.column.setFilterValue(event.target.value)"
:placeholder="`Search...(${
header.column.getFacetedUniqueValues().size
})`"
/>
</div>
<div
@mousedown="header.getResizeHandler()($event)"
@touchstart="header.getResizeHandler()($event)"
:class="`resizer ${
header.column.getIsResizing() ? 'isResizing' : ''
}`"
:style="{
transform: `translateX(${
table.getState().columnSizingInfo.deltaOffset
} px)`,
}"
></div>
</div>
</template>
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in table.getRowModel().rows" :key="row.id">
<td
v-for="cell in row.getVisibleCells()"
:key="cell.id"
:style="{
width: cell.column.getSize() + 'px',
}"
>
<FlexRender
:render="cell.column.columnDef.cell"
:props="cell.getContext()"
/>
</td>
</tr>
</tbody>
<tfoot>
<tr
v-for="footerGroup in table.getFooterGroups()"
:key="footerGroup.id"
>
<th
v-for="header in footerGroup.headers"
:key="header.id"
:colSpan="header.colSpan"
:style="{
width: header.column.getSize() + 'px',
}"
>
<FlexRender
v-if="!header.isPlaceholder"
:render="header.column.columnDef.footer"
:props="header.getContext()"
/>
</th>
</tr>
</tfoot>
</table>
<button @click="() => (userList = defaultData)">Rerender</button>
</div>
</template>
<style lang="css">
.tr {
display: flex;
}
tr,
.tr {
width: fit-content;
height: 30px;
}
th,
.th {
padding: 2px 4px;
position: relative;
font-weight: bold;
text-align: center;
height: 30px;
}
.resizer {
position: absolute;
right: 0;
top: 0;
height: 100%;
width: 5px;
background: rgba(0, 0, 0, 0.5);
cursor: col-resize;
user-select: none;
touch-action: none;
}
.resizer.isResizing {
background: blue;
opacity: 1;
}
@media (hover: hover) {
.resizer {
opacity: 0;
}
*:hover > .resizer {
opacity: 1;
}
}
.resizer.isResizing {
background: blue;
opacity: 1;
}
</style>
これからはじめるVue.js 3実践入門
コメント