【Handsontable】セル内HTML要素の表示(Nested HTML)の実現

# はじめに
前記事で、セル内にグリッド表示を行いました。

https://qiita.com/yaju/items/6856553510bca3ae913a

参考にしたグレープシティ社のWijmo（ウィジモ）のグリッド：詳細行のページに「グリッドを表示」とは別に「HTML要素を表示」というのがあり、これなら前記事を応用すればすぐ出来そうだと思ったので作成してみました。

https://demo.grapecity.com/wijmo/demos/Grid/Master-Detail/NestedGrids(RowDetail)/purejs

Handsontableでは、セル内にHTML要素を表示させるのは図の表示やリンク先などで一般的に使用可能です。
あくまで列ごとに表示形式を設定しています。

https://handsontable.com/docs/cell-renderer/#rendering-custom-html-in-cells

今回のは、階層で展開した行のセル内にHTML要素を表示するという違いがあります。

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

## CDN
```html
<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（ウィジモ）のグリッド：詳細行の「HTML要素を表示」のイメージを近い形にしてみることにしました。

https://demo.grapecity.com/wijmo/demos/Grid/Master-Detail/NestedGrids(RowDetail)/purejs

## データ構造
親項目"Parent"と子項目"Child"を組み合わせます。

```json
let data = {"Parent":
  [
    { "ClassName": "Beverages",
      "Explain": "Soft drinks, coffees, teas, beers, and ales",
      "Child": "ID: <b>1</b><br>分類名: <b>Beverages</b><br>説明: <b>Soft drinks, coffees, teas, beers, and ales</b><br>商品: <b>合計12個</b><br><ol><li>Chai</li><li>Chang</li><li>Guaraná Fantástica</li><li>Sasquatch Ale</li><li>Steeleye Stout</li><li>Côte de Blaye</li><li>Chartreuse verte</li><li>Ipoh Coffee</li><li>Laughing Lumberjack Lager</li><li>Outback Lager</li><li>Rhönbräu Klosterbier</li><li>Lakkalikööri</li></ol>"
    },
```

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

```javascript
// add property row and isExpand
// 2nd row expand.
for(let [index, item] of data.Parent.entries()) {
  item["row"] = index;
  item["isExpand"] = (index == 1 ? true : false);
  item["height"] = (index == 2 ? 320 : 290);
}
```

## 詳細画面
そのままHTML要素の表示しています。

# 実装
デモ用のデータにグレープシティ社の[Wijmo（ウィジモ）のグリッド：詳細行](https://demo.grapecity.com/wijmo/demos/Grid/Master-Detail/NestedGrids(RowDetail)/purejs)を一部使用しました。

<p class="codepen" data-height="600" data-default-tab="result" data-slug-hash="XWRQrBo" data-user="yaju-the-encoder" style="height: 600px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/yaju-the-encoder/pen/XWRQrBo">
  Handsontable Nested Grid</a> by やじゅ (<a href="https://codepen.io/yaju-the-encoder">@yaju-the-encoder</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

# ソースコード
```html
<!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 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;
}
</style>
</head>
<div id="grid"></div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/handsontable/6.2.2/handsontable.full.min.js"></script>
<script language="javascript" type="text/javascript">
let data = {"Parent":
  [
    { "ClassName": "Beverages",
      "Explain": "Soft drinks, coffees, teas, beers, and ales",
      "Child": "ID: <b>1</b><br>分類名: <b>Beverages</b><br>説明: <b>Soft drinks, coffees, teas, beers, and ales</b><br>商品: <b>合計12個</b><br><ol><li>Chai</li><li>Chang</li><li>Guaraná Fantástica</li><li>Sasquatch Ale</li><li>Steeleye Stout</li><li>Côte de Blaye</li><li>Chartreuse verte</li><li>Ipoh Coffee</li><li>Laughing Lumberjack Lager</li><li>Outback Lager</li><li>Rhönbräu Klosterbier</li><li>Lakkalikööri</li></ol>"
    },
    { "ClassName": "Condiments",
      "Explain": "Sweet and savory sauces, relishes, spreads, and seasonings",
      "Child": "ID: <b>2</b><br>分類名: <b>Condiments</b><br>説明: <b>Sweet and savory sauces, relishes, spreads, and seasonings</b><br>商品: <b>合計12個</b><br><ol><li>Aniseed Syrup</li><li>Chef Anton's Cajun Seasoning</li><li>Chef Anton's Gumbo Mix</li><li>Grandma's Boysenberry Spread</li><li>Northwoods Cranberry Sauce</li><li>Genen Shouyu</li><li>Gula Malacca</li><li>Sirop d'érable</li><li>Vegie-spread</li><li>Louisiana Fiery Hot Pepper Sauce</li><li>Louisiana Hot Spiced Okra</li><li>Original Frankfurter grüne Soße</li></ol>"
    },
    { "ClassName": "Confections",
      "Explain": "Desserts, candies, and sweet breads",
      "Child": "ID: <b>3</b><br>分類名: <b>Confections</b><br>説明: <b>Desserts, candies, and sweet breads</b><br>商品: <b>合計13個</b><br><ol><li>Pavlova</li><li>Teatime Chocolate Biscuits</li><li>Sir Rodney's Marmalade</li><li>Sir Rodney's Scones</li><li>NuNuCa Nuß-Nougat-Creme</li><li>Gumbär Gummibärchen</li><li>Schoggi Schokolade</li><li>Zaanse koeken</li><li>Chocolade</li><li>Maxilaku</li><li>Valkoinen suklaa</li><li>Tarte au sucre</li><li>Scottish Longbreads</li></ol>"
    },
    { "ClassName": "Dairy Products",
      "Explain": "Cheeses",
      "Child": "ID: <b>4</b><br>分類名: <b>Dairy Products</b><br>説明: <b>Cheeses</b><br>商品: <b>合計10個</b><br><ol><li>Queso Cabrales</li><li>Queso Manchego La Pastora</li><li>Gorgonzola Telino</li><li>Mascarpone Fabioli</li><li>Geitost</li><li>Raclette Courdavault</li><li>Camembert Pierrot</li><li>Gudbrandsdalsost</li><li>Flotemysost</li><li>Mozzarella di Giovanni</li></ol>"
    }
  ]
}

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

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,
  disableVisualSelection: 'area',
  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);
    }
  },
  afterRowResize: function(row, newSize, isDoubleClick) {
    if(hot.getSourceDataAtCell(row,'isExpand') == undefined) {
      if(hot.getSourceDataAtCell(row - 1,'height') < newSize) {
        let elm = hot.getCell(row,0).firstElementChild;
        elm.style.cssText = 'height:' + newSize + 'px';
      }
    }
  }
});

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 attachHtml(cell, row) {
  let div = document.createElement('div');
  div.className = 'in-wrapper';

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

  div.innerHTML = hot.getSourceDataAtCell(row - 1,'Child');

  cell.innerText = '';
  cell.appendChild(div);
};

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 = htmlRenderer;
      cellPr.editor = false;
    }
    else {
      let rowNo = this.instance.getSourceDataAtCell(row,'row');
      if(rowNo % 2)
        cellPr.renderer = alternateRowRenderer;
    }
    
    return cellPr;
  }
});

function htmlRenderer(instance, td, row, col, prop, value, cellProperties) {
  td.style.background = 'whitesmoke';
  cellProperties.hotAttached = true;
  attachHtml(td, row);
}

function alternateRowRenderer(instance, td, row, col, prop, value, cellProperties) {
  Handsontable.renderers.HtmlRenderer.apply(this, arguments);
  td.style.background = 'lightyellow';
}
</script>
</body>
</html>
```

# ポイント
## 背景色の交互表示
今回はCSSを使わず、プログラムにて行番号で判断して交互にしています。

```javascript
hot.updateSettings({

  cells: function(row, col) {
    let isExpand = this.instance.getSourceDataAtCell(row,'isExpand');
    let cellPr = {};
    if (col === 0 && isExpand == undefined) {
      cellPr.renderer = htmlRenderer;
      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.HtmlRenderer.apply(this, arguments);
  td.style.background = 'lightyellow';
}
```

## 詳細部の行サイズの変更イベント
詳細部の高さは、HTML要素の親となるDIVタグのStyleのheight属性で高さを設定しています。
afterRowResizeイベントにて、もともとの高さより大きくした場合に追随して高さを変更しています。
もともとの高さより小さくするとヘッダー　行表示がおかしくなってしまうので制御しています。

```javascript
  afterRowResize: function(row, newSize, isDoubleClick) {
    if(hot.getSourceDataAtCell(row,'isExpand') == undefined) {
      if(hot.getSourceDataAtCell(row - 1,'height') < newSize) {
        let elm = hot.getCell(row,0).firstElementChild;
        elm.style.cssText = 'height:' + newSize + 'px';
      }
    }
  }
```

# 最後に
前記事のセル内グリッド表示(Nested Grid)の応用なので簡単に実現できました。
ポイントに挙げた「背景色の交互表示」などは、前記事に一部反映して修正してあります。

CodePenを埋め込んでいるのにソースコードまで掲載しているので修正が2度手間なんですが、この方が検索に引っ掛かるんですよね。
自分も分からない時に検索したソースコードで助かっているので、よしとします。

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