【Handsontable】セル内に時刻入力(ClockPicker)の表示

# はじめに
Handsontable のセル種類には、`time` があり書式による入力バリデーションチェックは用意されているものの、`Date`のように DatePicker が標準で用意されていません。
https://handsontable.com/docs/time-cell-type/#overview
https://handsontable.com/docs/date-cell-type/#overview

Handsontable のライバル製品である Jspreadsheet(旧jExcel)では、CUSTOM COLUMN TYPEのサンプルとして、ClockPickerを見ることが出来る。これは、weareoutman氏が作成した ClockPicker を使用している。

https://bossanova.uk/jspreadsheet/v4/examples/column-types

これなら、Handsontable でも同様なことができるし、既に作成している人がいるのでは無いかと検索してみると見つけました。しかし、7年前ということもあってデモサイトは既にリンク切れになっており、ソースコードを元に動かしても init処理のところでエラーになってしまいます。

https://github.com/raduvultur/handsontable-timepicker

さらに検索してみると、なんと日本の方(posturanさん)が直近（2021/08/07）で実現していました。

https://oboe2uran.hatenablog.com/entry/2021/08/07/003000

これを動かしてみると幾つか改善したい部分が見つかったので、参考にしつつ自前で作成することにしました。

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

# CDN+α
```html
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/handsontable/6.2.2/handsontable.full.css">
<link rel="stylesheet" href="https://weareoutman.github.io/clockpicker/dist/jquery-clockpicker.min.css" />

<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.slim.min.js"></script>
<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/moment.js/2.29.1/moment-with-locales.min.js"></script>
<script src="https://weareoutman.github.io/clockpicker/dist/jquery-clockpicker.js"></script>
```

jquery-clockpickerは、weareoutman氏のgithubから直接取得しています。cdnjs上のでは正常に動作しませんでした。

# 仕様
データは(HH24:MI形式)、24時間制の前0埋め2桁の時(hour)と区切り文字に`:`と前0埋め2桁の分(minute)を指定します。

```javascript
let data = [
  { A:'09:08', B:'15:20', C:'18:45'}
];
```

columns の指定ですが、renderer: 'autocomplete', editor: 'time' を指定します。
rendererを autocomplete にしているのは、セルの右端に「▼」を表示したいためです。
10分や15分単位など分刻みを制限したい場合、sourceに配列で指定します。（posturanさんのアイディアを参考）

```javascript
let hot = new Handsontable(document.getElementById("grid"), {
  data: data,
  columns:[
     { data: 'A', type: 'text', renderer: 'autocomplete', editor: 'time' },
     { data: 'B', type: 'text', renderer: 'autocomplete', editor: 'time', source: ['00','10','20','30','40','50'] },
     { data: 'C', type: 'text', renderer: 'autocomplete', editor: 'time', source: ['00','15','30','45'] }
  ],
```

# 実装
<p class="codepen" data-height="500" data-default-tab="result" data-slug-hash="bGRbxER" 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/bGRbxER">
  Handsontable jquery-clockpicker</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/bf7c0a1e-f1c4-b599-ed90-5ed64b321b0e.png)
10分単位(左下図)と15分単位(右下図)を指定した場合
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14053/1170dad1-8f1c-01e5-cd67-7e342b02160d.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.css">
<link rel="stylesheet" href="https://weareoutman.github.io/clockpicker/dist/jquery-clockpicker.min.css" />
<style>
</style>
</head>

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

<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.slim.min.js"></script>
<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/moment.js/2.29.1/moment-with-locales.min.js"></script>
<script src="https://weareoutman.github.io/clockpicker/dist/jquery-clockpicker.js"></script>

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

  'use strict';

  const TimeEditor = Handsontable.editors.BaseEditor.prototype.extend();

  let choices;
  let locate;

  TimeEditor.prototype.init = function() {
    this.clock = document.createElement('input');
    this.clock.setAttribute('type', 'text');

    const that = this;
    this.clockInput = $(this.clock).clockpicker({
      placement: 'bottom',
      align: 'left',
      autoclose: true,
      afterShow: function(){
        $('.clockpicker-minutes').find('.clockpicker-tick').show();

        if(choices == undefined || choices.length == 0) return;
        $('.clockpicker-minutes').find('.clockpicker-tick').filter(function(index, element){
          return !($.inArray($(element).text(), choices) != -1)
        }).hide();
      },
      afterDone: function(){
        if(choices != undefined && choices.length > 0) {
          let selectedMinutes = that.clockInput.val().split(":")[1];
          if ($.inArray(selectedMinutes, choices) == -1) {
            let time = that.clockInput.val().split(":");
            let hour = time[0];
            let minutes = choices[0];
            for(minutes of choices) {
              if(time[1] < minutes) break;
            }
            that.clockInput.val(hour + ':' + minutes)
            that.clockInput.clockpicker('show').clockpicker('toggleView', 'minutes');
            let $cpop = $('.clockpicker-popover');
            $cpop.offset(locate);
            return;
          }
        }

        that.instance.setDataAtCell(that.row, that.col, that.clockInput.val());
      }
    });
  };

  TimeEditor.prototype.prepare = function () {
    Handsontable.editors.BaseEditor.prototype.prepare.apply(this, arguments);
    choices = this.cellProperties.source;
  };

  TimeEditor.prototype.open = function () {
    //make sure that editor position matches cell position
    let $td = $(this.TD);
    let offset = $td.offset();
    
    this.clockInput.clockpicker('show');
    
    //remove clockpicker event handlers
    $(document).off('click.clockpicker.cp1 focusin.clockpicker.cp1');
    
    let $cpop = $('.clockpicker-popover');
    locate = { top: offset.top + $td.height() + 10, left: offset.left };
    $cpop.offset(locate);

    $('.clockpicker-hours, .clockpicker-span-hours, .clockpicker-span-minutes').on('mousedown mouseup', function (event) {
      event.stopPropagation();
    });
        
    $('.clockpicker-minutes').on('mousedown', function (event) {
      event.stopPropagation();
    });
  };

  TimeEditor.prototype.close = function () {
    this.clockInput.clockpicker('hide');
  };

  TimeEditor.prototype.getValue = function(){
    return $('.clockpicker-span-hours').text() + ':' + $('.clockpicker-span-minutes').text();
  };

  TimeEditor.prototype.setValue = function(newValue){
    this.clock.value = newValue;
  };

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

  Handsontable.editors.TimeEditor = TimeEditor;
  Handsontable.editors.registerEditor('time', TimeEditor);
}(Handsontable));


let data = [
  { A:'09:08', B:'15:20', C:'18:45'}
];

let hot = new Handsontable(document.getElementById("grid"), {
  data: data,
  columns:[
     { data: 'A', type: 'text', renderer: 'autocomplete', editor: 'time' },
     { data: 'B', type: 'text', renderer: 'autocomplete', editor: 'time', source: ['00','10','20','30','40','50'] },
     { data: 'C', type: 'text', renderer: 'autocomplete', editor: 'time', source: ['00','15','30','45'] }
  ],
  colHeaders: ["制限なし", "10分単位", "15分単位" ],
  manualColumnResize: true,
  contextMenu: {
    items:{
      'row_below': { name: '１行挿入' },
      'remove_row': { name: '１行削除', disabled: function(){ return hot.countRows() < 2; }  },
      "hsep": "---------",
      'undo': { name: '戻る' },
    },
  },
});
</script>
</body>
</html>
```

# 問題点の解決
## 参考サイトの問題点
>Editor の beginEditing （編集開始）にならないと Clockpicker が画面に現れない点だ。
(中略)
focus 時処理で、this.beginEditing(); を実行することで、編集開始にしてしまう。
でも、この方法は、Clockpicker を表示するセルだけ、右クリックの
コンテキストメニュー、任意のコンテキストメニュー表示ができなくなる。
>
>これを解決させる方法がまだわからない。
https://oboe2uran.hatenablog.com/entry/2021/08/08/175117

今回はこの問題点を解決している。デモとしてコンテキストメニューを付けてある。

## columnsのsource取得方法変更
### 修正前
修正前は、afterShowとafterDoneの2ヶ所で別々に取得していました。

```javascript
let choices = that.instance.getCellMeta(that.row, that.col).source;
```

これは下記の方法で値は取得できたのですが、Open時に「Uncaught TypeError: Cannot read property 'readOnly' of undefined  handsontable.full.min.js;35」のエラーとなり解決方法が不明だったからです。

```javascript
TimeEditor.prototype.prepare = function (row, col, prop, td, originalValue, cellProperties) {
  choices = cellProperties.source;
};
```

### 修正後
参考のソースコードの方法を取り入れることでエラーが出なくなりました。
参考:https://gist.github.com/ben-dalton/6fd4cad0917cad150324afc8060562a3

```javascript
TimeEditor.prototype.prepare = function () {
  Handsontable.editors.BaseEditor.prototype.prepare.apply(this, arguments);
  choices = cellProperties.source;
};
```

# 最後に
正直、Custom Editorの作り方がまだよく分かっていない。raduvulturさんとposturanさんのソースコードがなければ辿り着かなかったかも知れない。
それでも、それなりのものが出来たので、今度はDateTimePickerに挑戦してみたくなった。

https://qiita.com/yaju/items/d037ce7e4ed8ebf6c467