「Handsontable(ハンズオンテーブル)」は、WEBでExcelのようなスプレッドシートライクな入力を可能にしてくれるJavaScriptライブラリです。
デモ で見るととわかりますが、Excelのようにデータ入力ができるだけでなく、セルの書式の指定やチャートが作れたりと、機能が多いのも魅力です。
数あるJavascript用のフリーのグリッドライブラリの中でも使い勝手が一番いい。
フリーで商用利用可能なグリッドライブラリ - しぐれがきのブログ
公式サイト:https://handsontable.com/
ライセンスは、MITとなっている。有料版「Handsontable Pro」がある。
Visual Studio上では、メニューの[ツール]→[NuGetパッケージマネージャー]→[ソリューションの NuGet パッケージの管理]にて、「handsontable」を検索してインストールすることが出来る。
Enter でセルを抜ける時、デフォルトだと下のセルに移動する。
これを横に移動するようにする。
enterMoves: { row: 0, col: 1}
範囲選択をできなくする
注意として一覧表示のみの際に範囲選択でコピーしてExcelに貼り付けるなどが出来なくなる。
new Handsontable(grid, { disableVisualSelection: 'area' });
デフォルト値は「color: #777;」と薄いので、少し濃くする。
.handsontable .htDimmed { color: #444; }
フィルハンドルは、選択したセルの右下にカーソルをドラッグすることで、セルの内容を上下 or 左右にコピーできる。
new Handsontable(grid, { fillHandle: false });
ドラッグで列の幅を変更できるようにする。
new Handsontable(grid, { manualColumnResize: true });
minSpareRows: 1,
minSpareCols: 3,
表示内容が見切れないように折り返して表示させる場合、行の高さがデータごとに変わる。
このとき、表の列が多く横スクロールが必要となる場合、表示している内容で行の高さが自動で変わり、どのデータにフォーカスが当たっているのか見分けがつかなくなってしまう。
以下の設定を行うことで、行の高さが自動で変わる事象を解消することができる。
autoRowSize: true
先頭列(Edit)に同じ行のいずれかの列の変更があった場合に「*」を付ける。
※例では、2行目は対象行のチェックボックス(Select)がある。
let table = new Handsontable(grid,xxx); // changesには[[row, col, pretext, aftertext]] でセットされる。 afterChange: function (changes, source) { if (source === 'loadData') return; for (let i = 0; i < changes.length; i++) { let change = changes[i]; // 編集と選択は対象外 if (change[1] === 'Edit' || change[1] === 'Select') continue; // 変更前と変更後が同じは対象外 if (change[2] === change[3]) continue; // 編集に"*"を付ける table.setDataAtCell(changes[0][0], 0, '*'); } }
先頭列に編集マークがある場合のみ必須項目チェックを行う。
こうすることで新規行はチェックされない。
※編集マークがなければ beforeValidate でもいいと思う。
let table = new Handsontable(grid,xxx); afterValidate: function (isValid, value, row, prop, source) { if (table.getDataAtCell(row, 0) === '*' && (value === null || value === "")) return false; }
Handsontableのセル入力では、MaxLengthがサポートされていません。よって、ユーザー側でカスタムエディタで対応するようにしています。
Handsontable Limit Cell Characters
(function (Handsontable) { 'use strict'; let MaxLengthEditor = Handsontable.editors.TextEditor.prototype.extend(); MaxLengthEditor.prototype.prepare = function () { Handsontable.editors.TextEditor.prototype.prepare.apply(this, arguments); this.TEXTAREA.maxLength = this.cellProperties.maxLength; }; Handsontable.editors.MaxLengthEditor = MaxLengthEditor; Handsontable.editors.registerEditor('maxlength', MaxLengthEditor); })(Handsontable); // 使い方 let table = new Handsontable(grid, { data: data, columns: [ { type: 'text', maxLength: 5, editor: 'maxlength' } ] }
バリデーションを組み込み、特定の条件を満たす値のみした許可しないようにすることができる。
また、設定によって、条件を満たさない場合の値が入力された場合の挙動を変えることが可能。
allowInvalid: true → 条件を満たさない値を入力した場合、セル色が赤くなる
allowInvalid: false → 条件を満たす値が入力するまで、フォーカスが外れない
例として日付の未来日はエラーとしたい場合
日付の妥当性チェック後に未来日をチェックします。
afterValidate: function(isValid, value, row, prop, source) { if (this.propToCol(prop) === 4 && isValid){ if (value > moment(new Date()).format("YYYY/MM/DD")) { return false; } } }
未来日以外なら既存の日付の妥当性チェックを呼びます。
const dtFormat = 'YYYY/MM/DD'; let isFuture = function (value, callback) { if (moment(value, dtFormat).isValid() && value > moment(new Date()).format(dtFormat)) { callback(false); } else { Handsontable.validators.DateValidator.call(this, value, callback); } }; let table = new Handsontable(grid, { data: data, columns: [ {type: 'date', dateFormat: dtFormat, datePickerConfig: { maxDate: new Date() }, validator: isFuture },
text入力で afterValidateでまとめて桁数チェックをしようとしたが、afterValidateが動作しない。
無理矢理、validatorを経由させることでafterValidateの処理に入ってくるようになった。
※IE11対応なのでアロー演算子は使用していない。
{ data: 'Line', type: 'text', validator: function (value, callback) { callback(true); } }
明示的に検証するには、validateCells() を使用する。他にvalidateColumns()、validateRows() がある。
バリデーションは末行末列から開始されるので順番には注意。
let table = new Handsontable(grid,xxx); table.validateCells(function(isValid) { if (isValid) { alert("Success"); } else { alert("Error"); e.preventDefault(); } })
非同期処理を操作する Promise を利用する。
validateColumnsで列ごとにエラーをチェックするが非同期なので、Promise でメソッドチェーン化して実行している。
エラーは catch で一括チェックする。
※validateCells だと未チェック列もエラーならセルが赤くなってしまう。
let table = new Handsontable(grid,xxx); function checkItem(item) { return new Promise(function (resolve, reject) { table.validateColumns([item], function (valid) { if (valid) { resolve(); } else { reject(); } }); }); } let check0002 = function () { return checkItem(2) } let check0003 = function () { return checkItem(3) } // 非同期チェック処理 let promise = Promise.resolve(); promise .then(check0002) .then(check0003) .then(function () { return alert("成功"); }).catch(function () { // エラー表示 return alert("失敗"); });
全てエラー箇所が赤くなる。最終的にはこっちを採用した。
エラーは catch で一括チェックする。
let table = new Handsontable(grid,xxx); let result = { msg: '', row: 0, col: 0 }; let checkAllItem = function () { return new Promise(function (resolve, reject) { table.validateCells(function (valid) { if (valid) { resolve(); } else { result = { msg: 'All', row: 0, col: 0 }; reject(result); } }); }); } // 非同期チェック処理 var promise = Promise.resolve(); promise .then(checkAllItem) .then(function () { return alert("成功"); }).catch(function (result) { return alert("失敗:" + result); });
validの戻り値は「false:不正値、true:正常値、undefined:なし」の3値あるので注意する。
let table = new Handsontable(grid,xxx); let isValid = table.getCellMeta(row, col).valid; if (isValid === false) { alert("不正値"); }
選択位置は、getSelected() で取得することが出来る。
しかし、ボタンを押してフォーカスがGridから離れると getSelected() では取れなくなってしまう。
以下の設定を行うことで、フォーカスロストした時も選択状態が維持される。
outsideClickDeselects: false
デフォルトのoutsideClickDeselects: true で選択位置を取得するには、afterSelectionイベントで位置を保持する必要がある。
HandsonTableのサンプル
let selectedRow = 0; let selectedCol = 0; afterSelection: function (r, c, r2, c2) { // ボタンとかおしてフォーカスがGridから離れると // getSelected() では取れなくなるので、 // このタイミングで常に保持しておいたほうが無難 selectedRow = r2; selectedCol = c2; }
指定は行、列の順番
let table = new Handsontable(grid,xxx); table.selectCell(row, col);
一覧表示のみの場合、フォーカスを行単位で移動させたい
Programmatically select entire row
<style type="text/css"> .handsontable td.currentRow, .handsontable td.current { background: rgb(230, 239, 254); } </style> <script type="text/javascript"> let grid = document.getElementById('grid'); let table = new Handsontable(grid, { data: [], currentRowClassName: 'currentRow' }); </script>
コンテキストメニューには「上に行を挿入:Insert Row Above」と「下に行を挿入:Insert Row Below」と2種類あるが、メソッドとしては、上側に行追加するしか用意されていない。
行追加位置を +1 加算して実現する。
let table = new Handsontable(grid,xxx); let sel = table.getSelected(); table.alter('insert_row', (sel[0][0]) + 1);
現在の行数で追加すればいい。
let table = new Handsontable(grid,xxx); table.alter('insert_row', table.countRows());
先頭列にチェックボックスがあって、行削除ボタンでチェック対象を行削除する。
※ループ処理で行を削除する場合は、下から上に向かって行うのが鉄則です。
let table = new Handsontable(grid,xxx); for (let i = table.countRows() - 1; i >= 0; i--) { if (table.getDataAtCell(i, 0) === true) { table.alter('remove_row', i); } }
全選択/全解除チェックボックスがあり、全行の先頭列チェックボックスを全選択/全解除チェックボックスの値を同じにする。
※IE11では40件データで15秒かかった。Chromeは速い。仕方ないのでデータを読み直す方式に変更した。
let table = new Handsontable(grid,xxx); for (let i = 0; i < table.countRows() ; i++) { table.setDataAtCell(i, 0, Checkbox.checked) }
その後に調査した結果、populateFromArrayで高速化できることが分かった。
let table = new Handsontable(grid,xxx); let col = table.propToCol('Select'); table.populateFromArray(0, col, [[Checkbox.checked]], table.countRows() - 1, col, null, null, 'down');
Performance issue with instance.setDataAtCell()
パフォーマンスサンプル
http://jsfiddle.net/1kruLmjo/
setDataAtCell は遅いので、getDataにアクセスして変更する。
※全選択/全解除チェックに書いたが、populateFromArrayで高速化できることが分かった。
setDataAtCell.addEventListener('click', function() { for(var i = 0; i < hot1.countRows(); i++) { hot1.setDataAtCell(i, 0, 'value') } }); loadData.addEventListener('click', function() { let tab = hot1.getData(); for(let i = 0; i < hot1.countRows(); i++){ tab[i].shift(); tab[i].unshift('value'); } hot1.loadData(tab) });
一覧表示で結果列に「エラー」があった場合に、対象行を赤色の文字にする。
listRenderer = function (instance, td, row, col, prop, value, cellProperties) { Handsontable.renderers.TextRenderer.apply(this, arguments); if (instance.getDataAtCell(row, 4) === 'エラー') { td.style.color = 'red'; } }; let table = new Handsontable(grid,xxx); cells: function (row, col, prop) { if (this.instance.countRows() == 0) return; this.renderer = listRenderer; },
最初は次のようにcellsイベント側でエラー判定をしていたが、列ソート(columnSorting:true)させると赤色になる行が崩れることが分かった。列ソートさせないなら、こちらでも良い。
listRenderer = function (instance, td, row, col, prop, value, cellProperties) { Handsontable.renderers.TextRenderer.apply(this, arguments); td.style.color = 'red'; }; let table = new Handsontable(grid,xxx); cells: function (row, col, prop) { if (this.instance.countRows() == 0) return; if (this.instance.getDataAtCell(row, 4) === 'エラー') { this.renderer = listRenderer; } },
データ0件の判定処理で「this.instance.getData().length == 0」としていたが、これだとデータ件数が多くなると遅い原因(特にIE11は数分かかるレベル)となっていたため、「this.instance.countRows() == 0」に変更した。
結果列に「エラー」のセルのみを赤色の文字にする。
valueの値を使用して判定する。
listRenderer = function (instance, td, row, col, prop, value, cellProperties) { Handsontable.renderers.TextRenderer.apply(this, arguments); if (value === 'エラー') { td.style.color = 'red'; } }; let table = new Handsontable(grid,xxx); cells: function (row, col, prop) { if (this.instance.countRows() == 0) return; this.renderer = listRenderer; },
リサイズ後にサイズを変更する。リサイズ後(resized)イベントは、下記サイトを参考
JavaScript で window リサイズ終了後のタイミングをハンドリングする方法
Boostrapのグリッドシステムを採用している場合、divタグに“id=Box”属性を付けて横幅を取得する。
その横幅を少し小さめにHandsonTableのwidthにセットしている。そうしないと縦スクロールバーが見えなくなってしまう。
縦幅は最低限表示するサイズ(150)と、最大で表示するサイズ(400)を指定している。
let resizeTimer; let interval = Math.floor(1000 / 60 * 10); // リサイズ完了時 window.addEventListener('resize', function (event) { if (resizeTimer !== false) { clearTimeout(resizeTimer); } resizeTimer = setTimeout(function () { resize(); }, interval); }) // リサイズ function resize() { let client_w = document.getElementById('box').clientWidth; let client_h = window.parent.screen.height; //alert("Resized client_w:" + client_w + " x client_h:" + client_h); table.updateSettings({ width: client_w - 20, height: Math.min(Math.max(150, client_h - 440), 400) }); }
columns: [ { type: 'dropdown', source: ['yellow', 'red', 'orange', 'green', 'blue', 'gray', 'black', 'white'] } ]
type: 'handsontable' とすると複数列のセレクトボックスの表示が可能
https://docs.handsontable.com/5.0.2/demo-handsontable.html
ASP.NET + MVC5
columns: [ { type: 'dropdown', source: time_list() } ] // 時間(分 00~23)のプルダウンリスト function time_list() { list = []; for (let i = 0 ; i < 24; i++) { list.push(('0' + i).slice(-2)); } return list; }
columns: [ { type: 'dropdown', source: store_list() } ] // ストアをプルダウンリスト function store_list() { let list = []; jQuery.ajax({ url: '/Home/GetStore', type: "GET", dataType: "json", contentType: 'application/json; charset=utf-8', async: true, processData: false, cache: false }).done(function (data) { for (i in data) list.push(data[i]); }); return list; }
リストが少ないとスクロールバーが付いてしまう場合の対応
Dropdown/autocomplete menu hidden with preventOverflow
.handsontableEditor.autocompleteEditor, .handsontableEditor.autocompleteEditor .ht_master .wtHolder { min-height: 138px; }
リストが多い場合の高さ制御
Handsontable in Handsontable Dropdown Height Adjustment
Handsontableのリスト表示が非常に見難い。またHandsontableの高さを指定しているとリストが下に隠れてしまうが、解消方法は見つかっていない。
最低限、色を変更することで見やすくする。
How to change default highlighted color of dropdown in handsontable to another color
.handsontable.listbox tr td.current, .handsontable.listbox tr:hover td { background: #d1ecf1; } .handsontable.listbox tr td { background: #fafaca; }
ドラッグすると固定にしたシートの行が自動で追加されてしまう。
afterCreateRowのfunctionにdata.splice(index, amount)を追加すると行が固定されるようになる。
特定の列の幅を0.1ピクセルに縮小できます。技術的にはテーブルの一部であり、.getData()は列のデータを返しますが、人間の目には見えません。※IE11では、チェックボックスにしていたためかセルが表示されてしまう不具合があった。
セル移動した際に、非表示列にもフォーカスが移動して値が見えてしまう。
handsontable.updateSettings({ colWidths: [0.1,0.1,50], });
バインドしてある前提で、幅を0.1にしなくても非表示列の値が取得できる方法がある。
let table = new Handsontable(grid,xxx); if (table.getSourceDataAtCell(row,"DBRead") === true)
※DBから読んだデータと新規入力のデータと分ける上でDBRead列がtrueで判定する場合を想定。
標準ではボタンは用意されていないが、HTMLを表示出来ることを利用する。
DateTimePickerの時間を組み合わせて、ボタンを押したら時間入力ダイアログを表示して時間をセットする。
<link href="../../Content/tempusdominus-bootstrap-4.min.css" rel="stylesheet"> <script src="../../scripts/moment-with-locales.min.js"></script> <script src="../../scripts/tempusdominus-bootstrap-4.min.js"></script> <div class="modal" id="timeModal" tabindex="-1" role="dialog" aria-labelledby="modal" aria-hidden="true"> <div class="modal-dialog modal-dialog-centered modal-sm" role="document"> <div class="modal-content"> <button type="button" class="close" data-dismiss="modal" aria-label="Close"> <span aria-hidden="true">×</span> </button> <div class='input-group justify-content-center' id='datetimepicker1'></div> </div> </div> </div>
$('#datetimepicker1').on('change.datetimepicker', function(e) { if(selectedCol === 0) return; table.setDataAtCell(selectedRow, selectedCol, e.date.format("HH:mm")); }); function myBtns(instance, td, row, col, prop, value, cellProperties) { Handsontable.renderers.TextRenderer.apply(this, arguments); if (value === null) value = "00:00"; td.innerHTML = '<button class="myBtn bt-' + row + '">' + value + '</button>' } let table = new Handsontable(grid, { cells: function(row, col) { let cellPrp = {}; if (col === 5) { cellPrp.renderer = myBtns; cellPrp.readOnly = true; } return cellPrp }, afterOnCellMouseDown: function(event, cords, TD) { if (event.realTarget.className.indexOf('myBtn') < 0) { return; } let time = table.getDataAtCell(selectedRow, selectedCol); if (time === null) time = "00:00"; $('#datetimepicker1').data("datetimepicker").defaultDate(moment(moment().format('L') + ' ' + time)); $('#timeModal').modal(); } afterSelection: function (r, c, r2, c2) { // ボタンとかおしてフォーカスがGridから離れると // getSelected() では取れなくなるので、 // このタイミングで常に保持しておいたほうが無難 selectedRow = r2; selectedCol = c2; } }
HTMLを表示できるので、リンクや画像を表示できる。
Rendering custom HTML in cells
handsontableのcolumns内にバインド名(例 data: “price”)を指定しないと表示されない。
※カラムのタイプの指定が無い場合はバインド名の指定が無くても表示される。
※カラムのタイプがチェックボックスの場合、JSONデータは true/false にしないとエラーになる。
let data = [ { "price": "1100" , "mt": "7" , "sts": "8316" }, { "price": "200" , "mt": "3" , "sts": "648" }, { "price": "1200" , "mt": "2" , "sts": "2592" } ]; $('#example').handsontable({ data: data, minSpareRows: 0, colHeaders: ["価格", "本数", "小計(税込)"], columns: [ { data: "price" , type: 'numeric' , format: '0,0' }, { data: "mt" , type: 'numeric' }, { data: "sts" , type: 'numeric' , format: '0,0' , readOnly: true }, ],
JsonデータをloadDataメソッドでHandsontableでデータにロードした場合、データを保存する時にもJsonデータのままサーバー側に渡したい場合、getDataではなくgetSorceData を使用する。
Posting Handontable JSON using Ajax to ASP.net MVC controller not working Since "0.22.0"
プログラムで設定変更することは出来そうもないので、handsontable.full.js を直接変更する。
変更後はツールを使うなどして、handsontable.full.min.js に変換するなりしておく。
Windows環境でYUI Compressorを使う
datePickerConfig で細かな変更が可能。
datePickerConfig: { // First day of the week (0: Sunday, 1: Monday, etc) firstDay: 1 }
Handsontable 使い方メモ3(カラム・セルオプション)
※各設定の意味は、PikaDay.jsメモ を参考にする。
例えば、firstDayを1にすると月曜始まりのカレンダーとなる。工場を持つ企業などで使われている。
「moment.locale('ja');」を実行すれば、monthsとweekdaysとweekdaysShortは、Momentから取得できる。
moment.locale('ja'); datePickerConfig: { // first day of week (0: Sunday, 1: Monday etc) firstDay: 0, // Additional text to append to the year in the calendar title yearSuffix: '年', // Render the month after year in the calendar title showMonthAfterYear: true, // Render days of the calendar grid that fall in the next or previous month showDaysInNextAndPreviousMonths: true, // internationalization i18n: { previousMonth : '前月', nextMonth : '次月', months: moment.localeData()._months, weekdays: moment.localeData()._weekdays, weekdaysShort: moment.localeData()._weekdaysShort } },
CSS3の擬似クラスを使用して、色を変更する。handsontable用のcssファイルに含めて置き換えてもいいし、外出しにしてもいい。
何番目系の便利なCSSまとめ
.pika-lendar th:first-child, .pika-lendar td:first-child .pika-button { color: #f00; } .pika-lendar th:last-child, .pika-lendar td:last-child .pika-button { color: #00f; }
.pika-lendar th:nth-last-child(2), .pika-lendar td:nth-last-child(2) .pika-button { color: #00f; } .pika-lendar th:last-child, .pika-lendar td:last-child .pika-button { color: #f00; }
EditorManagerを使用してカスタムエディターを作成することが出来る。