かずきのBlog@hatena

すきな言語は C# + XAML の組み合わせ。Azure Functions も好き。最近は Go 言語勉強中。日本マイクロソフトで働いていますが、ここに書いていることは個人的なメモなので会社の公式見解ではありません。

縦横スクロールつきのテーブルを Web で使いたい

むか~~しむかし、Flash や Silverlight みたいなブラウザープラグイン系のリッチクライアントアプリケーションのプラットフォームと HTML 5 が覇権を争ってた時代がありました。私はブラウザープラグイン寄りの人で「DataGrid コントロールがあるだけでプラグイン系のほう使うわ」って思ってました。

やりたかったことは

  • 表形式でデータを表示したい
  • 表を縦横スクロールしたい
  • スクロールしても固定列や固定行はスクロールしないで欲しい

JavaScript で頑張ることで当時でもできてましたが、結構しんどかったら重めだったりと何かと問題がおきがちだった気がするので例えば

<DataGrid ItemsSource="{Binding People}">
  <DataGrid.Columns>
    <DataGridTextColumn Header="ID" Binding="{Binding ID}" />
    <DataGridTextColumn Header="名前" Binding="{Binding Name}" />
  </DataGrid.Columns>
</DataGrid>

みたいに書くだけで安定したデータグリッドが出てくれるので少なくとも社内システムで使うんならこっちのほうが楽なのでは…と思ってました。

HTML 5 が覇権を握ってプラグイン系が淘汰されてからも、なかなかこういうの実装するのめんどくさそうだなぁと思ってて、自分がやるならどっかから買うかって思ってたのですが先日呟いてみると…

まじですか。

やってみよう

ということで、Vue.js のプロジェクトを vue cli でさくっと作りました。App.vue に全部詰め込むシンプル構成で作っていきます。 ちょっと件数多めのデータ作るのがだるかったので、manipula という LINQ を実装してるというライブラリを導入しました。

www.npmjs.com

ということで以下のコマンドでさくっとね。

npm i manipula

filter と map と reduce で結構なケースがカバーできるけど、込み入ったことしたいときとか皆どうしてるんだろうなぁ。

ということで App.vue を以下のように変更して、そこそこの大きさのテーブルを出しました。

<template>
  <div id="app">
    <h1 class="title">テーブルスクロールテスト</h1>
    <div class="table-scroll-host">
      <table>
        <thead>
          <tr>
            <th>ID</th>
            <th>Name</th>
            <th>Comment1</th>
            <th>Comment2</th>
            <th>Comment3</th>
            <th>Comment4</th>
            <th>Comment5</th>
            <th>Comment6</th>
            <th>Comment7</th>
            <th>Comment8</th>
            <th>Comment9</th>
            <th>Comment10</th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="x in people" :key="x.id">
            <td>{{ x.id }}</td>
            <td>{{ x.name }}</td>
            <td>{{ x.comment }}</td>
            <td>{{ x.comment }}</td>
            <td>{{ x.comment }}</td>
            <td>{{ x.comment }}</td>
            <td>{{ x.comment }}</td>
            <td>{{ x.comment }}</td>
            <td>{{ x.comment }}</td>
            <td>{{ x.comment }}</td>
            <td>{{ x.comment }}</td>
            <td>{{ x.comment }}</td>
          </tr>
        </tbody>
      </table>
    </div>
  </div>
</template>

<script lang="ts">
import Vue from 'vue';
import Manipula from 'manipula';

class Person {
  constructor(public id: string, public name: string, public comment: string) {}
}

export default Vue.extend({
  name: 'app',
  data() {
    return {
      people: Manipula.range(1, 1000) // 1000 件のデータ作成
        .select(
          (x) =>
            new Person(
              x.toString(),
              `Tanaka taro ${x}`,
              'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
            ),
        )
        .toArray(),
    };
  },
});
</script>

<style>
#app {
  height: 100%;
  display: grid;
  grid-template-rows: auto 1fr;
  grid-template-columns: 100%;
  grid-template-areas: 
    "title"
    "content";
}

.title {
  grid-area: title;
}

.table-scroll-host {
  grid-area: content;
}
</style>

The table タグ!!って感じですね。

f:id:okazuki:20190721111527p:plain

スクロールをつけよう

これは簡単。table タグを覆ってる div タグ(今回は class="table-scroll-host" のやつ) に高さを指定してオーバーフローをスクロールにするだけ。

.table-scroll-host {
  grid-area: content;
  overflow: scroll;
}

スクロールバーがつきました。ちょっとスクロールさせるとヘッダー行とかも悲しくスクロールアウトしていくのがわかります。

f:id:okazuki:20190721111929p:plain

ヘッダー行を固定しよう

th タグのスタイルで display をsticky に設定します。こんなの出来てたんですね。知らなかった。

ということで display を sticky にして top を 0 にします。 今のままだと th の背景が透明なのでスクロールしたときに裏側が透けてかっこ悪いので色もつけました。

table th {
  position: sticky;
  top: 0;
  background-color: black;
  color: white;
}

さくっとヘッダー行固定いけました。

f:id:okazuki:20190721112826g:plain

ヘッダー列固定をしよう

次は横スクロールしたときに ID 列は残るようにしたいと思います。

ということで最初の th と td に対して left 0 で position を sticky にして固定します。そしてスクロール時に他のセルが上に重なったりしないように z-index を指定しておきます。 th は上スクロール時に下にある td よりも上にないといけないので z-index は 2 にしてます。

最後に td がデフォルトで背景透明で、スクロール時にセルが重なると残念な見た目になるので、とりあえず白で塗っておきます。

table th:nth-child(1) {
  position: sticky;
  left: 0;
  z-index: 2;
}

table td:nth-child(1) {
  position: sticky;
  left: 0;
  z-index: 1;
  background-color: white;
}

これで動かすと…

f:id:okazuki:20190721141937g:plain

ばっちり!!

先頭 2 列を固定列にしたい

1 列とかなら、これでいいのですが複数列ならどうなるだろう?left: 0; を 2 列目に指定すると 1 列目も 2 列目も左端で止まるのでちょっと残念な感じになります。ということで、2 列まとめて固定列にするなら列幅を指定する感じかな。

厳密な幅計算とかするとなるとボーダーも邪魔なので隠して…各列の最小幅も指定して…。style はこんな感じになりました。

#app {
  height: 100%;
  display: grid;
  grid-template-rows: auto 1fr;
  grid-template-columns: 100%;
  grid-template-areas: 
    "title"
    "content";
}

.title {
  grid-area: title;
}

.table-scroll-host {
  grid-area: content;
  overflow: scroll;
}

table th {
  position: sticky;
  top: 0;
  background-color: black;
  color: white;
}

table {
  width: auto;
  min-width: 100%;
  table-layout: fixed;
  border-collapse: collapse;  
}

table td, th {
  min-width: 100px;
}

table th:nth-child(1) {
  position: sticky;
  left: 0;
  z-index: 2;
  width: 100px;
}

table td:nth-child(1) {
  position: sticky;
  left: 0;
  z-index: 1;
  background-color: white;
  width: 100px;
}

table th:nth-child(2) {
  position: sticky;
  left: 100px;
  z-index: 2;
  width: 100px;
}

table td:nth-child(2) {
  position: sticky;
  left: 100px;
  z-index: 1;
  background-color: white;
  width: 100px;
}

実行すると…

f:id:okazuki:20190721151303g:plain

いい感じに固定列こみのスクロールしてくれますね。

まとめ

ということで、最終的な App.vue は以下のような感じになりました。

<template>
  <div id="app">
    <h1 class="title">テーブルスクロールテスト</h1>
    <div class="table-scroll-host">
      <table>
        <thead>
          <tr>
            <th>ID</th>
            <th>Name</th>
            <th>Comment1</th>
            <th>Comment2</th>
            <th>Comment3</th>
            <th>Comment4</th>
            <th>Comment5</th>
            <th>Comment6</th>
            <th>Comment7</th>
            <th>Comment8</th>
            <th>Comment9</th>
            <th>Comment10</th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="x in people" :key="x.id">
            <td>{{ x.id }}</td>
            <td>{{ x.name }}</td>
            <td>{{ x.comment }}</td>
            <td>{{ x.comment }}</td>
            <td>{{ x.comment }}</td>
            <td>{{ x.comment }}</td>
            <td>{{ x.comment }}</td>
            <td>{{ x.comment }}</td>
            <td>{{ x.comment }}</td>
            <td>{{ x.comment }}</td>
            <td>{{ x.comment }}</td>
            <td>{{ x.comment }}</td>
          </tr>
        </tbody>
      </table>
    </div>
  </div>
</template>

<script lang="ts">
import Vue from 'vue';
import Manipula from 'manipula';

class Person {
  constructor(public id: string, public name: string, public comment: string) {}
}

export default Vue.extend({
  name: 'app',
  data() {
    return {
      people: Manipula.range(1, 1000) // 1000 件のデータ作成
        .select(
          (x) =>
            new Person(
              x.toString(),
              `Tanaka taro ${x}`,
              'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
            ),
        )
        .toArray(),
    };
  },
});
</script>

<style>
#app {
  height: 100%;
  display: grid;
  grid-template-rows: auto 1fr;
  grid-template-columns: 100%;
  grid-template-areas: 
    "title"
    "content";
}

.title {
  grid-area: title;
}

.table-scroll-host {
  grid-area: content;
  overflow: scroll;
}

table th {
  position: sticky;
  top: 0;
  background-color: black;
  color: white;
}

table {
  width: auto;
  min-width: 100%;
  table-layout: fixed;
  border-collapse: collapse;  
}

table td, th {
  min-width: 100px;
}

table th:nth-child(1) {
  position: sticky;
  left: 0;
  z-index: 2;
  width: 100px;
}

table td:nth-child(1) {
  position: sticky;
  left: 0;
  z-index: 1;
  background-color: white;
  width: 100px;
}

table th:nth-child(2) {
  position: sticky;
  left: 100px;
  z-index: 2;
  width: 100px;
}

table td:nth-child(2) {
  position: sticky;
  left: 100px;
  z-index: 1;
  background-color: white;
  width: 100px;
}
</style>

HTML / CSS も進化してるなぁ…。