【Handsontable】セル内に複数選択セレクト入力(Multi Select ComboTree)の表示

# はじめに
Handsontable でリスト選択をするには、セル種類に `Autocomplete`と`Dropdown`および`select`(正確にはセル種類ではない)を指定する方法があります。 

|種類|内容|デモ|
|:--|:--|:--|
|Autocomplete|セル右端の▼ボタンのクリックでリストが表示され、リストから1つを選択する。セル内の文字入力が出来る。<br>オプション設定でリストにないものは検証エラーにしないようにも出来るし、検証エラー(セル背景色が赤表示)にすることでも出来る。|https://handsontable.com/docs/autocomplete-cell-type/|
|Dropdown|Autocompleteの派生、セル内の文字入力が出来る。<br>リストにないものは検証エラーとなる。|https://handsontable.com/docs/dropdown-cell-type/|
|Select|セルをダブルクリックすることでリストが表示され、その中から1つを選択する。セル内の文字入力が出来ない。|https://handsontable.com/docs/select-cell-type/|

どれを選択したとしても、リストから1つしか選択できません。
以前、.NETで同僚から複数選択のコンボボックスってないですか？　と聞かれ調査して記事を書いたことがあります。
リスト内にチェックボックスが表示されて、複数選択するとカンマ区切りでセットされるようになっています。

https://qiita.com/yaju/items/369cb5be0c0298859b3e

今回、Javascriptでリスト内にチェックボックスが表示されて複数選択できるものを調査して、「ComboTree jQuery Plugin」というのを見つけました。名前にTreeが付くように階層構造も可能になっています。

https://www.jqueryscript.net/demo/Drop-Down-Combo-Tree/

https://github.com/erhanfirat/combo-tree

【2021/09/17追記】
Handsontableの複数選択セレクト入力として以前からある「[Handsontable-chosen-editor](https://github.com/mydea/handsontable-chosen-editor)」を研究していたところ、大きく下記2点について見直しました。

  * 値はidとし、レンダーでidから名称(title)に変換して表示する。<br>前回、値と表示とも名称(title)になっていました。
  * キー操作を考慮する。<br>まだキー操作のみで完結できるようにはなっていないですが、キー操作でリスト表示が残る不具合を修正

# 環境
HandsontableはMITライセンス版のバージョン 6.2.2を使用しています。
一応、有償版バージョン 8.3.2と9.0.2でも動作は確認しています。

# CDN+α
```html
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/handsontable/6.2.2/handsontable.full.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/MaterialDesign-Webfont/5.9.55/css/materialdesignicons.min.css">
<link rel="stylesheet" href="https://rawcdn.githack.com/erhanfirat/combo-tree/a216a2bd4e53223ac25a3b68b319608770253861/style.css">

<script src="https://cdnjs.cloudflare.com/ajax/libs/handsontable/6.2.2/handsontable.full.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script src="https://rawcdn.githack.com/erhanfirat/combo-tree/a216a2bd4e53223ac25a3b68b319608770253861/comboTreePlugin.js"></script>
```

ComboTreeの階層用アイコン表示に、`MaterialDesign-WebFont`を使用しています。
ComboTreeがマイナーのため、CDNに登録がありません。GitHub上のファイルをCDNとして参照するようにしました。

https://qiita.com/takanorig/items/89db46120d2ec171e3d8

GitHub に上がっているJavascriptやCSSをCodePenで参照しようとしても、text/plain として返却されるため実行できないが、下記サイトで変換したURLにすることで適切な Content-Type を返してくれるため実行することができる。

http://raw.githack.com/

## 最新版
Handsontableは、下記のCDNを使用すると常に最新バージョンになります。

```html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/handsontable/dist/handsontable.full.min.css">

<script src="https://cdn.jsdelivr.net/npm/handsontable/dist/handsontable.full.min.js"></script>
```

有償版はとりあえず非商用および評価版のみ無償用のライセンスをセットする。

```javascript
let hot = new Handsontable(document.getElementById("grid"), {
  licenseKey: "non-commercial-and-evaluation"
```

# 仕様
セル右端の▼ボタンのクリックまたはセルのダブルクリックでリストが表示され、リストから1つまたは複数を選択する。セル内の文字入力が出来ない。また、リストは階層構造で表示することが出来る。

## 表示設定
|設定|値|初期値|説明|
|:--|:--|:--|:--|
|isMultiple|true/false|false|複数選択か単一選択かを決める|
|cascadeSelect|true/false|false|複数選択時に、親の選択が子に連動するかどうかを決める|
|source|JSON Data Array|　|リストをJSON配列で指定する|
|selected|JSON Data Array|　|~~初期選択としてソースから対応するIDのリストをJSON配列（例 selected: [[0],[11]]）で指定する~~|
|collapse|true/false|false|サブリストを折りたたむかを決める|

Handsontableで使用する場合、`selected`を無視します。
セルの値で一致する項目名を選択します。もし同じ項目名があった場合、最初に見つけたところが選択されます。

## リスト設定
```javascript
let SampleJSONData = [
  { id: 0, title: 'Horse'}, 
  { id: 1, title: 'Birds', isSelectable: false, 
    subs: [ 
      { id: 10, title: 'Pigeon', isSelectable: false },
      { id: 11, title: 'Parrot' },
      { id: 12, title: 'Owl' },
      { id: 13, title: 'Falcon' }
    ]
  }
```

基本はリスト用識別IDの`id`と表示名の`title`、選択可能/不可設定の`isSelectable`で、falseに設定すると一覧から選択できないようなります。階層構造として、`subs`として同じように指定します。また、`subs`の中に`subs`を指定することも出来ます。

## カラム設定
Handsontableのcolumns設定に、`comboWidth`オプションを新規に追加してあります。`comboWidth`オプションを指定しない場合、セルの横幅と同じ幅でリストが表示されますが、指定した場合は設定した値の横幅でリストを表示します。

```javascript
let hot = new Handsontable(document.getElementById("grid"), {
  data: data,
  columns:[
     { data: 'A', type: 'text', width: 200, renderer: customDropdownRenderer, editor: 'combotree', 
       comboTreeConfig: { source: SampleJSONData,  isMultiple: true, cascadeSelect: false } },
     { data: 'B', type: 'text', width: 200, renderer: customDropdownRenderer, editor: 'combotree', 
       comboTreeConfig: { source: SampleJSONData2, isMultiple: true, cascadeSelect: true }  },
     { data: 'C', type: 'text', width: 150, renderer: customDropdownRenderer, editor: 'combotree', 
       comboTreeConfig: { source: SampleJSONData,  isMultiple: false }, comboWidth: 200 }
  ],
```

## 注意
idを指定します。複数値はカンマ区切りとなります。

```javascript
let data = [
  { A: "0,11", B: "2,20,21,22,3", C: "11" }
];
```

値を分解する際には、カンマ区切り後の値にtrim()をかけています。

# 実装
<p class="codepen" data-height="500" data-default-tab="result" data-slug-hash="JjJRwdP" data-user="yaju-the-encoder" style="height: 500px; 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/JjJRwdP">
  Handsontable ComboTree</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>

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14053/268f9fb0-c2eb-bc60-718b-1b78773db2d9.png)

# ソースコード
```html
<!DOCTYPE html>
<html lang="jp">
<body>
<head>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/handsontable/6.2.2/handsontable.full.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/MaterialDesign-Webfont/5.9.55/css/materialdesignicons.min.css">
<link rel="stylesheet" href="https://rawcdn.githack.com/erhanfirat/combo-tree/a216a2bd4e53223ac25a3b68b319608770253861/style.css">
</head>

<div id="grid"></div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/handsontable/6.2.2/handsontable.full.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script src="https://rawcdn.githack.com/erhanfirat/combo-tree/a216a2bd4e53223ac25a3b68b319608770253861/comboTreePlugin.js"></script>

<script type="text/javascript">
(function(Handsontable){

  'use strict';

  const ComboTreeEditor = Handsontable.editors.SelectEditor.prototype.extend();

  const onBeforeKeyDown = function(event) {
    let that = this.getActiveEditor();

    let keyCodes = Handsontable.helper.KEY_CODES;

    switch (event.keyCode) {
      case keyCodes.ARROW_RIGHT:
      case keyCodes.ARROW_LEFT:
      case keyCodes.ARROW_DOWN:
      case keyCodes.ARROW_UP:
      case keyCodes.ESCAPE:
        event.preventDefault();
        event.stopPropagation();
      case keyCodes.ENTER:
        that.comboInput.closeDropDownMenu();
        break;
     }
  }

  ComboTreeEditor.prototype.open = function() {
    this.refreshDimensions();

    if (this.comboInput)
      this.comboInput.destroy();

    if (this.comboParent)
      $(this.comboParent).remove();

    this.combo = document.createElement('input');
    this.combo.setAttribute('type', 'text');
    this.comboStyle = this.combo.style;

    this.comboParent = document.createElement('DIV');
    this.comboParentStyle = this.comboParent.style;
    let wid = this.TD.clientWidth;
    if (this.cellProperties.comboWidth != undefined)
      wid = this.cellProperties.comboWidth;
    this.comboParentStyle.width = wid + 'px';

    this.comboParent.appendChild(this.combo);
    this.instance.rootElement.appendChild(this.comboParent);

    const that = this;
    this.comboInput = $(this.combo).comboTree(this.cellProperties.comboTreeConfig);
    this.comboInput.onChange(() => {
      that.instance.setDataAtCell(that.row, that.col, this.getValue());
    });

    this.comboInput._elemInput.val(this.originalValue);
    this.comboStyle.display = 'block';
    $('.comboTreeArrowBtn').remove();

    let mouseupHandler = $._data($(document).get(0), "events").mouseup[0].handler;
    $(document).off('mouseup.' + this.comboInput.comboTreeId);
    this.combo.focus();

    this.comboStyle.display = 'none';

    let $td = $(this.TD);
    let offset = $td.offset();
    let $pop = $('.comboTreeDropDownContainer');
    let locate = { top: offset.top + $td.height() + 2, left: offset.left };
    $pop.offset(locate);

    const _this = this.comboInput;
    if (_this.options.isMultiple) {
      let selectData = [];
      let value = _this._elemInput.val().trim();
      if (value) {
        selectData = value.split(",");
        this.comboInput.setSelection(selectData);
      }
    }
    else {
      $('span.comboTreeItemTitle.selectable').each(function(i, elem) {
        if (elem.dataset.id == _this._elemInput.val()) {
          _this.dropDownMenuHover(elem);
        }
      });
    }
    
    this.instance.addHook('beforeKeyDown', onBeforeKeyDown);

    setTimeout(() => {
      $(document).on('mouseup.' + this.comboInput.comboTreeId, mouseupHandler)
    }, 300);
    
    let timerId = setInterval(()=> {
      if (!_this._elemDropDownContainer.is(":visible")) {
        clearInterval(timerId);
        this.close();
      }
    }, 500);
  };

  ComboTreeEditor.prototype.close = function() {
    this.instance.removeHook('beforeKeyDown', onBeforeKeyDown);
    this.finishEditing();
  };

  ComboTreeEditor.prototype.getValue = function(){
    let value = this.comboInput.getSelectedIds();
    if (value == undefined && !this.comboInput.options.isMultiple) {
      value = this.originalValue;
      if (!Array.isArray(value)) return value;
    }

    if (value == undefined) return;

    return value.join(",");
  };

  ComboTreeEditor.prototype.setValue = function(newValue){
    if (this.comboInput != undefined)
      this.comboInput._elemInput.val(newValue);
  };

  ComboTreeEditor.prototype.focus = function() {};

  ComboTreeEditor.prototype.finishEditing = function (isCancelled, ctrlDown) {
    this.instance.listen();
    return Handsontable.editors.TextEditor.prototype.finishEditing.apply(this, arguments);
  };
  
  Handsontable.editors.ComboTreeEditor = ComboTreeEditor;
  Handsontable.editors.registerEditor('combotree', ComboTreeEditor);
}(Handsontable));

let SampleJSONData = [
  { id: 0, title: 'Horse'}, 
  { id: 1, title: 'Birds', isSelectable: false, 
    subs: [ 
      { id: 10, title: 'Pigeon', isSelectable: false },
      { id: 11, title: 'Parrot' },
      { id: 12, title: 'Owl' },
      { id: 13, title: 'Falcon' }
    ]
  },
  { id: 2, title: 'Rabbit' },
  { id: 3, title: 'Fox'}, 
  { id: 5, title: 'Cats',
    subs: [
      { id: 50, title: 'Kitty' },
      { id: 51, title: 'Bigs',
        subs: [
          { id: 510, title: 'Cheetah' },
          { id: 511, title: 'Jaguar' },
          { id: 512, title: 'Leopard' }
        ]
      }
    ]
  },
  { id: 6, title: 'Fish' }
];
    
let SampleJSONData2 = [
  { id: 1, title: 'Four Wheels',
    subs: [
      { id: 10, title: 'Car' },
      { id: 11, title: 'Truck' },
      { id: 12, title: 'Transporter'},
      { id: 13, title: 'Dozer' }
    ]
  },
  { id: 2, title: 'Two Wheels',
    subs: [
      { id: 20, title: 'Cycle' },
      { id: 21, title: 'Motorbike' },
      { id: 22, title: 'Scooter' }
    ]
  },
  { id: 2, title: 'Van' },
  { id: 3, title: 'Bus' }
];

let data = [
  { A: "0,11", B: "2,20,21,22,3", C: "11" }
];

let hot = new Handsontable(document.getElementById("grid"), {
  data: data,
  columns:[
     { data: 'A', type: 'text', width: 200, renderer: customDropdownRenderer, editor: 'combotree', 
       comboTreeConfig: { source: SampleJSONData,  isMultiple: true, cascadeSelect: false } },
     { data: 'B', type: 'text', width: 200, renderer: customDropdownRenderer, editor: 'combotree', 
       comboTreeConfig: { source: SampleJSONData2, isMultiple: true, cascadeSelect: true }  },
     { data: 'C', type: 'text', width: 150, renderer: customDropdownRenderer, editor: 'combotree', 
       comboTreeConfig: { source: SampleJSONData,  isMultiple: false }, comboWidth: 200 }
  ],
  colHeaders: ["Multi Selection", "Multi Selection With Cascade", "Single Selection"],
  manualColumnResize: true,
  contextMenu: {
    items:{
      'row_below': { name: '１行挿入' },
      'remove_row': { name: '１行削除', disabled: function(){ return hot.countRows() < 2; }  },
      "hsep": "---------",
      'undo': { name: '戻る' },
    },
  },
});

function customDropdownRenderer(instance, td, row, col, prop, value, cellProperties) {
  let selectedId;
  let optionsList = cellProperties.comboTreeConfig.source;

  if (typeof optionsList === "undefined" || typeof optionsList.length === "undefined" || !optionsList.length) {
    Handsontable.cellTypes.text.renderer(instance, td, row, col, prop, value, cellProperties);
    return td;
  }

  const findItembyTitle = (item, source) => {
    for (let i = 0; i < source.length; i++) {
      if (source[i].id == item)
        return source[i].title;
      if (source[i].hasOwnProperty("subs")) {
        let found = findItembyTitle(item, source[i].subs);
      if (found)
        return found;
      }
    }
  };
  
  if(value) {
    let values = (value + "").split(",");
    value = [];
    for (let i = 0; i < values.length; i++) {
      value.push(findItembyTitle(values[i].trim(), cellProperties.comboTreeConfig.source));
    }
    value = value.join(", ");
  }

  Handsontable.cellTypes.dropdown.renderer(instance, td, row, col, prop, value, cellProperties);
  return td;
}
```

# ポイント
今回はリストが表示されるまでが苦労しました。リスト表示がされれば、そこからはComboTreeの世界なので、あとは値をセルにセットしたり初期値のチェックを付けたりするくらいです。

## リスト表示
前回のClockPickerやDatetimePickerの際には、inputタグの親であるDIVタグは不要でしたが今回は必要でした。
リスト表示された後、余分なinputタグを非表示にしています。
※リスト表示される度にDIVタグが増えてしまう不具合を今回修正しました。

```javascript
if (this.comboInput)
  this.comboInput.destroy();

if (this.comboParent)
  $(this.comboParent).remove();

this.combo = document.createElement('input');
this.combo.setAttribute('type', 'text');
this.comboStyle = this.combo.style;

this.comboParent = document.createElement('DIV');
this.comboParentStyle = this.comboParent.style;

this.comboParent.appendChild(this.combo);
this.instance.rootElement.appendChild(this.comboParent);

const that = this;
this.comboInput = $(this.combo).comboTree(this.cellProperties.comboTreeConfig);

this.comboStyle.display = 'none';
```

## リスト表示が即閉
リストが表示されたらすぐに閉じてしまう。
解決方法は、フォーカスをセットしたらリストが表示されます、その前にmouseupイベントを解除します。
表示後にmouseupイベントを300ms後に再セットすることで、フォーカスが外れたらリストを閉じるようにしています。

```javascript
let mouseupHandler = $._data($(document).get(0), "events").mouseup[0].handler;
$(document).off('mouseup.' + this.comboInput.comboTreeId);
this.combo.focus();


setTimeout(() => {
  $(document).on('mouseup.' + this.comboInput.comboTreeId, mouseupHandler)
}, 300);
```

外部モジュールをHandsontableに組み込む上では、ソースコードがあるからこそ何とか対応できるわけです。

前回はcomboTreeのmouseupイベントのソースコードを持ってきて入れていましたが、今回の修正によりmouseupイベントを変数に退避しておき復帰させるようにしました。

## リスト表示の横幅
ここで親のDIVタグが役に立ちました。横幅を指定しないと画面いっぱいに横幅が広がってしまいます。
セルの横幅(clientWidth)を取得してセットしていますが、`comboWidth`オプション指定があれば値をセットします。

```javascript
this.comboParent = document.createElement('DIV');
this.comboParentStyle = this.comboParent.style;
let wid = this.TD.clientWidth;
if(this.cellProperties.comboWidth != undefined)
  wid = this.cellProperties.comboWidth;
this.comboParentStyle.width = wid + 'px';
```

## リストの値
値はidとし、レンダーでidから名称(title)に変換して表示するように修正しました。
renderer: 'autocomplete' から、 customDropdownRenderer にしています。

customDropdownRendererで値を`source`から検索した名称(title)を表示するようにしています。
本当はcustomDropdownRendererをComboTreeEditor内に入れたかったのですが無理そうです。

## キー操作
複数選択の場合、フィルター入力項目にマウスクリックでフォーカスをセットすれば、リスト内を上下キーで操作することが出来るのですが、その状態でESCAPEキーを押すとセル移動が出来なくなっていました。今回は下記コードで解消することが出来ました。
ComboTree内の操作によりリスト表示が閉じた際のイベントが取れなかったため、500ms間隔でリスト表示が閉じているか確認するようにしました。finishEditingのthis.instance.listen()を呼ぶことでセル移動が可能になりました。

```javascript
let timerId = setInterval(()=> {
  if (!_this._elemDropDownContainer.is(":visible")) {
    clearInterval(timerId);
    this.close();
  }
}, 500);

ComboTreeEditor.prototype.close = function() {
  this.instance.removeHook('beforeKeyDown', onBeforeKeyDown);
  this.finishEditing();
};

ComboTreeEditor.prototype.finishEditing = function (isCancelled, ctrlDown) {
  this.instance.listen();
  return Handsontable.editors.TextEditor.prototype.finishEditing.apply(this, arguments);
};
```

ここまで来たら、本当はリスト表示されたら上下キーでリスト選択できるようにしたかったのですが、やり方がまだ分かりません。フィルター入力項目にフォーカスをセットするだけでは駄目でした。
その為、現在は上下キーでリスト表示を閉じてセル移動するようにしています。

# 最後に
jQuery Pluginで作られたコンポーネントって素晴らしいのが結構あります。
別に脱Jqueryを無理にする必要なんてない気がします。
まだ、Handsontableに追加したいコンポーネントがあるので、もう少し頑張ります。

日本だとコンポーネント関連は、GrapeCityやInfragisticsが強いです、だけども価格が高いです。
Handsontable有償版もそこそこの値段ですけどね。

日本のデジタルトランスフォーメーション(DX)を考えたら、MITライセンスで手軽にできるようにしていきたいな。