2
2

More than 3 years have passed since last update.

【Handsontable】セル内グリッド表示(Nested Grid)の実現

Last updated at Posted at 2021-08-13

はじめに

Handsontableで、1レコード複数行入力を実現できないか調査しています。
前回、その段階として複数ヘッダーを実現しました。

Handsontableの有料版には、nestedRowオプションがありますが、Excelのグループ化したアウトライン機能のようなものですね。

image.png

自分のイメージとしては、1レコードにグリッドを入れ子に出来れば、1レコード複数行入力ができるかもと調べた結果、下記のサンプルを見つけました。
しかし、セル内のグリッドのテキストを編集しようとしてもフォーカスがすぐ奪われてしまいます。マウスでセル内を押しつつ文字入力すれば何とか編集できる状態でチェックボックスくらいなら問題ないですが、とても実務では使用できません。
Copy nested table to another table by autofill #2307

image.png

セル内のグリッドのテキストを編集を何とかしたいのですが、まだ調査中で分かりません。
表示専用にすれば見た目は良さそうです。そこにnestedRowオプションのような折りたたみ機能を組み合わせてみることにしました。

環境

HandsontableはMITライセンス版のバージョン 6.2.2を使用しています。

CDN

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/handsontable/6.2.2/handsontable.full.css">

<script src="https://cdnjs.cloudflare.com/ajax/libs/handsontable/6.2.2/handsontable.full.min.js"></script>

仕様

グレープシティ社のWijmo(ウィジモ)のグリッド:詳細行の「グリッドを表示」のイメージを近い形にしてみることにしました。

データ構造

親項目"Parent"と子項目"Child"を組み合わせます。

let data = {"Parent":
  [
    { "ClassName": "Beverages",
      "Explain": "Soft drinks, coffees, teas, beers, and ales",
      "Child": [
        [1,  "Chal",               "10 boxes x 20 bags", 18.0000, false ],
        [2,  "Chang",              "24 - 12 oz bottles", 19.0000, false ],
        [24, "Guaraná Fantástica", "12 - 355 ml cans",    4.5000, true  ],
        [34, "Sasquatch Ale",      "24 - 12 oz bottles", 14.0000, false ]
      ]
    },

システム用の隠し項目として行番号"row"(0〜)と展開状態"isExpand"(false:展開前、true;展開後)と行高さ"height"を追加しています。
デモ用に幾つか変更しています。

// add property row and isExpand
// 2nd,3rd row expand.
for(let [index, item] of data.Parent.entries()) {
  item["row"] = index;
  item["isExpand"] = (index == 1 || index == 2 ? true : false);
  item["height"] = (index == 2 ? 180 : 130);
}

今回学んだこととして、for(let [index, item] of XXXX.entries() とすると、索引値も一緒に取れるので便利です。

JSONの書き方

キーは必ずダブルクォーテーションで囲む必要があり、シングルクォーテーションだとエラーになります。
データはシングルクォーテーションでもいい。

詳細画面

セル内グリッドは編集が出来ないように読取専用'readOnly'にしています。

セル内グリッドのセルをダブルクリックすると、今回は例として単純なダイアログ画面を表示するようにしています。
image.png

詳細入力画面はダイアログ画面内にグリッドを表示するなりして別途入力すればいいしょう。
インフラジスティックスの複数行レイアウトを持つigGridのJsFiddleデモ画面のように、グリッド形式ではなく一般的な入力フォーム形式でもいいと思います。

実装

デモ用のデータにグレープシティ社のWijmo(ウィジモ)のグリッド:詳細行を一部使用しました。

See the Pen Handsontable Nested Table by やじゅ (@yaju-the-encoder) on CodePen.

ソースコード

<!DOCTYPE html>
<html lang="jp">
<body>
<head>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/handsontable/6.2.2/handsontable.full.css">
<style>
.in-wrapper {
  width: 100%;
  overflow: auto;
}

.handsontable .ht_master thead,
.handsontable .ht_master tr th,
.handsontable .ht_clone_left thead {
  visibility: visible;
}

.htCore tbody tr:nth-child(even) td {
  background-color: lightyellow;
}

/*
.in-wrapper .htCore tbody tr:nth-child(even) td {
  background-color: lightyellow;
}
*/

.in-wrapper .htCore tbody tr:nth-child(odd) td {
  background-color: white;
}

.handsontable th div.ht_nestingButton.ht_nestingExpand:after {
  content: "+";
}

.handsontable th div.ht_nestingButton.ht_nestingCollapse:after {
  content: "-";
}

.handsontable th div.ht_nestingButton {
  display: inline-block;
  position: absolute;
  right: -2px;
  cursor: pointer;
}

.handsontable .htDimmed {
  color: #444;
}

.modal-footer{
  display: flex;
  align-items: center;
  justify-content: center;
}
</style>
</head>

<div id="grid"></div><br>
<dialog class="modal">
<p>ここに<span id="childRow"></span>行目の詳細入力画面を作成する</p>
<div class="modal-footer">
  <button id="close">閉じる</button>
</div>
</dialog>

<script src="https://cdnjs.cloudflare.com/ajax/libs/handsontable/6.2.2/handsontable.full.min.js"></script>
<script language="javascript" type="text/javascript">

let dialog = document.querySelector('dialog');
let btn_close = document.getElementById('close');
btn_close.addEventListener('click', function() {
  dialog.close();
}, false);

let data = {"Parent":
  [
    { "ClassName": "Beverages",
      "Explain": "Soft drinks, coffees, teas, beers, and ales",
      "Child": [
        [1,  "Chal",               "10 boxes x 20 bags", 18.0000, false ],
        [2,  "Chang",              "24 - 12 oz bottles", 19.0000, false ],
        [24, "Guaraná Fantástica", "12 - 355 ml cans",    4.5000, true  ],
        [34, "Sasquatch Ale",      "24 - 12 oz bottles", 14.0000, false ]
      ]
    },
    { "ClassName": "Condiments",
      "Explain": "Sweet and savory sauces, relishes, spreads, and seasonings",
      "Child": [
        [3,  "Aniseed Sryup",                "12 - 550 ml bottles", 10.0000, false ],
        [4,  "Chef Anton's Cajun Seasoning", "48 - 6 oz jars",      22.0000, false ],
        [5,  "Chef Anton's Gumbo Mix",       "36 boxes",            21.3500, true  ],
        [6,  "Grandma's Boysenberry Spread", "12 - 8 oz jars",      25.0000, false ]
      ]
    },
    { "ClassName": "Confections",
      "Explain": "Desserts, candies, and sweet breads",
      "Child": [
        [16, "Pavlova",                    "32 - 500 g boxes",     17.4500, false ],
        [19, "Teatime Chocolate Biscuits", "10 boxes x 12 places",  9.2000, false ],
        [20, "Sir Rodney's Marmalade",     "30 gift boxes",        81.3500, false ],
        [21, "Sir Rodney's Scones",        "24 pkgs. x 4 pleaces", 10.0000, false ],
        [22, "Gustaf's Knäckebröd",        "24 - 500 g pkgs.",     21.0000, false ],
        [23, "Tunnbröd",                   "12 - 250 g pkgs.",     21.0000, false ]
      ]
    },
    { "ClassName": "Dairy Products",
      "Explain": "Cheeses",
      "Child": [
        [11, "Queso Cabrales",            "1 kg pkg.",        21.0000, false ],
        [12, "Queso Manchego La Pastora", "10 - 500 g pkgs.", 38.0000, false ],
        [31, "Gorgonzola Telino",         "12 - 100 g pkgs.", 12.5000, false ],
        [32, "Mascarpone Fabioli",        "24 - 200 g pkgs.", 32.0000, false ]
      ]
    }
  ]
}

// add property row and isExpand
// 2nd,3rd row expand.
for(let [index, item] of data.Parent.entries()) {
  item["row"] = index;
  item["isExpand"] = (index == 1 || index == 2 ? true : false);
  item["height"] = (index == 2 ? 180 : 130);
}

let hot = new Handsontable(grid, {
  data: data.Parent,
  colHeaders: ['分類名', '説明'],
  columns: [
    { data: "ClassName", type: 'text', width: 200, className: 'htLeft htMiddle' },
    { data: "Explain",   type: 'text', width: 650, className: 'htLeft htMiddle' }
  ],
  manualColumnResize: true,
  manualRowResize: true,
  rowHeights: 30,
  fillHandle: false,
  outsideClickDeselects: false,
  afterGetColHeader: function(col, TH) {
    TH.className = 'htLeft'
  },
  afterGetRowHeader: function(row, TH) {
     TH.className = 'htMiddle'
  },
  afterOnCellMouseDown: function(event, coords, TD) {
    if(coords.col < 0 && event.target.className.indexOf('ht_nestingButton') != -1){
      let isExpand = hot.getSourceDataAtCell(coords.row,'isExpand');
      expandCollapse(coords.row, !isExpand);
    }
  }
});

for (let row = 0; row < hot.countRows(); row++) {
  if(hot.getSourceDataAtCell(row,'isExpand'))
    expand(row);
}

function expand(row) {
  expandCollapse(row, true);
}

function collapse(row) {
  expandCollapse(row, false);
}

function expandCollapse(row, isExpand) {
  if(row >= hot.getSourceData().length) return;

  hot.setDataAtRowProp(row, 'isExpand', isExpand);

  let targetRow = row + 1;
  if(isExpand) {
    hot.alter('insert_row', targetRow);
    hot.updateSettings({
      mergeCells: mergeCustomAreas()
    });
  }
  else {
    hot.alter('remove_row', targetRow);

    let rowSizes = manualRowResize(targetRow);
    hot.updateSettings({
      mergeCells: mergeCustomAreas(),
      manualRowResize: rowSizes,
    });
  }
}

function manualRowResize(targetRow) {

  let rowSize = hot.getSettings().rowHeights;
  let rowHeights = [];
  for(let row = 0; row < hot.countRows(); row++){
    rowHeights.push(rowSize);
  }

  return rowHeights;
}

function attachHot(cell, row) {
  let div = document.createElement('div');
  div.className = 'in-wrapper';

  let height = hot.getSourceDataAtCell(row - 1,'height');
  div.style.cssText = 'height:' + height + 'px;'

  cell.innerText = '';
  cell.appendChild(div);
  let ht = new Handsontable(div, {
    data: [],
    colHeaders: ['ID', '商品名', '梱包単位', '単価', '生産中止'],
    rowHeaders: false,
    readOnly: true,
    columns: [
      { type: 'numeric', width: 40 },
      { type: 'text', width: 300 },
      { type: 'text', width: 300 },
      { type: 'numeric', numericFormat: { pattern: { mantissa: 4 } }, width: 80 },
      { type: 'checkbox', width: 100, className: 'htCenter htMiddle' }
    ],
    manualColumnResize: true,
    fillHandle: false,
    outsideClickDeselects: false,
    afterGetColHeader: function(col, TH) {
      switch(col) {
        case 1:
        case 2:
          TH.className = 'htLeft'
          break;
       }
    },
    afterOnCellMouseDown: function(event, coords, td) {
      // double Click
      let now = new Date().getTime();
      if(!(td.lastClick && now - td.lastClick < 500)) {
        td.lastClick = now;
        return;
      }

      let childRow = hot.getSourceDataAtCell(hot.getSelectedLast()[0] - 1,'row');
      document.getElementById("childRow").innerText = childRow + 1;
      dialog.showModal();
    }
  });

  ht.loadData(hot.getSourceDataAtCell(row - 1,'Child'));
};

function mergeCustomAreas() {

  let colMax = hot.countCols();
  let tab = [];
  for (let row = 0; row < hot.countRows(); row++) {
    let isExpand = hot.getSourceDataAtCell(row,'isExpand');
    if(isExpand == undefined) {
      tab.push({
        row: row,
        col: 0,
        rowspan: 1,
        colspan: colMax
      });
    }
  }

  return tab;
}

hot.updateSettings({
  rowHeaders: function(row) {
    let rowNo = hot.getSourceDataAtCell(row,'row');
    let isExpand = hot.getSourceDataAtCell(row,'isExpand');

    if(isExpand != undefined) {
       return Number(rowNo) + 1 + '<div class="ht_nestingButton ' + (!isExpand ? 'ht_nestingExpand' : 'ht_nestingCollapse') + '"></div>'
    }
    else {
      return ''
    }
  },
  mergeCells: mergeCustomAreas(),
  cells: function(row, col) {
    let isExpand = this.instance.getSourceDataAtCell(row,'isExpand');
    let cellPr = {};
    if (col === 0 && isExpand == undefined) {
      cellPr.renderer = hotRenderer;
      cellPr.editor = false;
    }

    return cellPr;
  }
});

function hotRenderer(instance, td, row, col, prop, value, cellProperties) {
  td.style.background = 'darkgray';

  cellProperties.hotAttached = true;
  attachHot(td, row);
}

</script>
</body>
</html>

ポイント

背景色の交互表示

CSSで実現しています。
展開した時に行が増えるので、親だけで見れば同じ色が並ぶことになります。これはWijmoでも同じ状態でした。
プログラムにて行番号で判断して交互にすることは可能ですが、今回はやめました。後述で挑戦しました。

.htCore tbody tr:nth-child(even) td {
  background-color: lightyellow;
}

上記だけだと、セル内グリッドの交互表示がおかしくなってしまうので、セル内グリッドは別途定義しています。
.in-wrapperは、セル内グリッドの上階層にdivタグを付けているのですが、それ用のクラスです。

.in-wrapper .htCore tbody tr:nth-child(odd) td {
  background-color: white;
}

行番号による背景色の交互表示

プログラムにて行番号で判断して交互にするのに挑戦しました。
セル内グリッドはCSSのままとしますので、親側はコメントアウトして、セル内グリッドのコメントアウトを外します。

/*
.htCore tbody tr:nth-child(even) td {
  background-color: lightyellow;
}
*/

.in-wrapper .htCore tbody tr:nth-child(even) td {
  background-color: lightyellow;
}

hot.updateSettingsのcellsオプションのelse以降の追加とalternateRowRenderer関数を追加します。

hot.updateSettings({
  // cells部分のみ抜粋 
  cells: function(row, col) {
    let isExpand = this.instance.getSourceDataAtCell(row,'isExpand');
    let cellPr = {};
    if (col === 0 && isExpand == undefined) {
      cellPr.renderer = myRenderer;
      cellPr.editor = false;
    }
    else {
      let rowNo = this.instance.getSourceDataAtCell(row,'row');
      if(rowNo % 2)
        cellPr.renderer = alternateRowRenderer;
    }

    return cellPr;
  }
});

function alternateRowRenderer(instance, td, row, col, prop, value, cellProperties) {
  Handsontable.renderers.TextRenderer.apply(this, arguments);
  td.style.background = 'lightyellow';
}

変更前はグリッドの行位置(row)で判断していますが、変更後は行番号(親のみ)で判断しています。
変更前(左)と変更後(右)です、行番号3が違います。
image.png

セル内グリッドのヘッダー表示

Handsontableでは、typeオプションを'handsontable'にするとドロップダウン時に複数列表示ができます。

その所為なのか分かりませんが、セル内グリッドを表示するとヘッダー部が非表示になっています。
これをCSSで表示するようにしています。これを見つけるのに一苦労でした。

.handsontable .ht_master thead,
.handsontable .ht_master tr th,
.handsontable .ht_clone_left thead {
  visibility: visible;
}

セルのダブルクリック判定

ダブルクリックのイベントが見当たらないので、検索して見つけました。
Cell on double click event #4087

200では反応が悪かったので、500にしています。

afterOnCellMouseDown: function(event, coords, td) {
      // double Click
      let now = new Date().getTime();
      if(!(td.lastClick && now - td.lastClick < 500)) {
        td.lastClick = now;
        return;
      }

行ヘッダー部分

行ヘッダーにdivタグを追加して、マウスクリックした部分がdivタグのクラス名ht_nestingButtonであるなら、展開と折り畳み処理をするようにしています。CSSの疑似要素「:after」を使用して、"+"と"-"の記号を追加しています。
HandsontableのnestedRowオプションの仕組みを解析して実現しました。

.handsontable th div.ht_nestingButton.ht_nestingExpand:after {
  content: "+";
}

.handsontable th div.ht_nestingButton.ht_nestingCollapse:after {
  content: "-";
}

.handsontable th div.ht_nestingButton {
  display: inline-block;
  position: absolute;
  right: -2px;
  cursor: pointer;
}
afterOnCellMouseDown: function(event, coords, TD) {
    if(coords.col < 0 && event.target.className.indexOf('ht_nestingButton') != -1){
      let isExpand = hot.getSourceDataAtCell(coords.row,'isExpand');
      expandCollapse(coords.row, !isExpand);
    }

hot.updateSettings({
  rowHeaders: function(row) {
    let rowNo = hot.getSourceDataAtCell(row,'row');
    let isExpand = hot.getSourceDataAtCell(row,'isExpand');

    if(isExpand != undefined) {
       return Number(rowNo) + 1 + '<div class="ht_nestingButton ' + (!isExpand ? 'ht_nestingExpand' : 'ht_nestingCollapse') + '"></div>'
    }
    else {
      return ''
    }

読み取り専用(ReadOnly)の文字色変更

読み取り専用にすると文字色がデフォルト値「color: #777;」と薄いので、少し濃くしています。

.handsontable .htDimmed {
  color: #444;
}

非表示列の値取得と値セット

システム用の隠し項目として行番号"row"(0〜)と展開状態"isExpand"(false:展開前、true;展開後)と行高さ"height"を各行に埋め込んでいます。
グリッド上では分類("ClassName")と説明("Explain")だけ定義しています。非表示列(隠し項目)となります。

let hot = new Handsontable(grid, {
  data: data.Parent,
  colHeaders: ['分類名', '説明'],
  columns: [
    { data: "ClassName", type: 'text', width: 200, className: 'htLeft htMiddle' },
    { data: "Explain",   type: 'text', width: 650, className: 'htLeft htMiddle' }
  ]

非表示列というと誤解があるかも知れません。有償版でないと本当の非表示列の機能は出来ません。MITライセンス版で非表示列を実現しようとした場合、列の幅を0.1に設定して実現する方法もあります。
Handsontableで列を非表示にする(非表示列を持たせる)

今回の方法は、バインドしてある前提で、幅を0.1にしなくても非表示列の値が取得できる方法です。

// 読み込み
let isExpand = hot.getSourceDataAtCell(row,'isExpand');

// 書き込み
hot.setDataAtRowProp(row, 'isExpand', isExpand);

getSourceDataAtCellの対であるsetSourceDataAtCellはMITライセンス版には存在せず、v8.0.0から使用できます。使用できないので、setDataAtRowPropを使用します。今回くらいのものであれば支障はなさそうです。

setDataAtRowPropsetDataAtCellでは下記問題があったため、setSourceDataAtCellが作成されました。
Set data at physical row instead of visual. #6309

セル結合と解除

展開した際に1行挿入してからセルを結合をしている。セルの解除は行削除するので何もしていない。
折り畳み処理で行削除した際にもセル結合処理を呼ばないと、下行でセル結合されたまま残ってしまったりしていた。

function expandCollapse(row, isExpand) {
  if(row >= hot.getSourceData().length) return;

  hot.setDataAtRowProp(row, 'isExpand', isExpand);

  let targetRow = row + 1;
  if(isExpand) {
    hot.alter('insert_row', targetRow);
    hot.updateSettings({
      mergeCells: mergeCustomAreas()
    });
  }
  else {
    hot.alter('remove_row', targetRow);

    let rowSizes = manualRowResize(targetRow);
    hot.updateSettings({
      mergeCells: mergeCustomAreas(),
      manualRowResize: rowSizes,
    });
  }
}

セル結合解除

セル結合解除は今回は使用しなかったが、使用方法は理解した。
セル結合はmergeCellsオプションを使用するが、セル結合解除はgetPlugin('mergeCells')して、unmergeメソッドを呼ぶ必要がある。

hot.getPlugin('mergeCells').unmerge(rows, 0, rows, 1);

最後に

作り込みしていないので不具合はあるかと思いますが、サンプルとしては、いい感じに作成できたと思います。
Handsontableは機能が豊富で追いきれないものの、作り込めば何とか出来てしまうのがいいですね。あと分からない時に検索でヒットするのは有り難いです。それだけコミュニティがしっかりしているってことですね。

本来作りたいのでは、1レコード複数行入力で今回のは副産物だったりします。作り込む際にいろいろ勉強になりました。

気になっているのが、不意に分類のセルの値が消える現象があります。まだ現象のパターンや原因が分かっていません。

2
2
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
2
2