@threestarsoftware/tssgrid 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,20 @@
3
3
  TssGrid の各リリースの変更点。日付は JST。形式は [Keep a Changelog](https://keepachangelog.com/ja/1.1.0/) 準拠(ゆるめ)。
4
4
  数値は特記なき限り**自社調べ**(headless Chrome / Windows 11・環境差あり)。
5
5
 
6
+ ## [0.1.8] - 2026-09-02
7
+
8
+ ### 追加
9
+ - **多段(フライアウト)プルダウン プラグイン `tss-cascade`**(プラグイン・無依存・コア無改変)— メニューのように「大分類 ▸ 中分類 ▸ 小分類」とフライアウトで開き、**葉を選ぶとセルに確定**するカスタムエディタ(`columns[c].editor` 契約に乗る)。`options` は入れ子配列 `[{ label, value?, children? }]`、保存は葉の `value`(未指定なら label)、表示は **`TssCascade.pathFormat(options)`** で「大 / 中 / 小」のパスに。クリック展開+キーボード(↑↓/→←/Enter/Esc)+画面端フリップ。動く例 `examples/cascade-dropdown.html`。
10
+
11
+ ### 修正
12
+ - **右クリックメニューのカスタム項目が `key`(組込キー)だけ・`callback` 無しで無反応だったのを修正**(コア)— カスタム項目 `{ name, key:'row_above' }` は `callback` を実行する仕様で、`key` だけ書いても組込アクションが起動せず**何も起きなかった**(位置に依らず/組込文字列 `'row_above'` は最下行でも正常)。**`callback` 無しで `key` が組込項目を指す時はその組込アクションを実行**するようにし、「**組込動作のまま `name`/`disabled` だけカスタムしたい**」を可能に。`callback` 付き・未知 `key`・組込文字列・`disabled` は不変(後方互換)。
13
+
14
+ ## [0.1.7] - 2026-08-01
15
+
16
+ ### 追加
17
+ - **`format` に第3引数 `row`(その行の値配列=`grid.data[r]`)**(コア)— `format(value, { r, c }, row)` の第3引数 `row` を追加(`options` の `(r, row)` と同形)。**行の状態でセル内容(ボタン/アイコン等)を出し分ける**時、`format` の中で `grid` を参照せず `row[列index]` から状態を読める=**初回描画(`const grid = new TssGrid(...)` の構築中に `format` が走る時点で `grid` 未代入=TDZ で例外)を回避**できる。既存の2引数 `format` はそのまま(後方互換)。
18
+ - **`examples/cell-button.html` を「行の状態で切替」に刷新**+**`examples/file-upload.html` を追加**(コア無改修のサンプル)— 前者は 未申請/申請中/編集中 で出すボタンを切替(申請/コピー・編集/削除・保存/取消)、click 委譲で状態遷移→`redraw`、複製・削除も。後者は**行ごとのファイル添付**(セル内ボタン+ドラッグ&ドロップ/落とした行を `closest('tr[data-r]')` で特定して `setValue`)。
19
+
6
20
  ## [0.1.6] - 2026-07-30
7
21
 
8
22
  ### 追加
package/README.md CHANGED
@@ -286,18 +286,27 @@ onAfterChange: (chs) => chs.forEach(ch => { if (ch.c === 0 && ch.oldValue !== ch
286
286
 
287
287
  ### セル内ボタン(`html:true` + クリック委譲)
288
288
 
289
- 専用のボタン列 API はありません。**`columns[c].html:true` + `format` が返す HTML** でセルにボタンを描き、**`grid.table` に click 委譲**して `data-*` で拾うのが定石です(→ 動く例: [`examples/cell-button.html`](https://tssgrid.threestarsoftware.co.jp/examples/cell-button.html))。
289
+ 専用のボタン列 API はありません。**`columns[c].html:true` + `format` が返す HTML** でセルにボタンを描き、**`grid.table` に click 委譲**して `data-*` で拾うのが定石です。**行の状態でボタンを出し分け**たいときは、状態を**列に持たせて** `format` の**第3引数 `row`(その行の値配列)**から読みます(→ 動く例: [`examples/cell-button.html`](https://tssgrid.threestarsoftware.co.jp/examples/cell-button.html)=未申請/申請中/編集中でボタン切替)。
290
290
 
291
291
  ```js
292
- columns: [ /* … */,
293
- { html:true, readOnly:true, format: () => '<button class="mybtn" data-act="del">削除</button>' } ]
292
+ // format の引数は (value, { r, c }, row)。row = その行の値配列(grid.data[r])
293
+ columns: [ /* …, 状態列(index 2), … */,
294
+ { html:true, readOnly:true, format: (_v, _cell, row) => {
295
+ const st = row[2]; // 状態を「行の値配列」から読む(grid は参照しない)
296
+ if (st === '編集中') return '<button class="mybtn" data-act="save">保存</button>';
297
+ if (st === '申請中') return '<button class="mybtn" data-act="edit">編集</button>';
298
+ return '<button class="mybtn" data-act="submit">申請</button>';
299
+ } } ]
294
300
  grid.table.addEventListener('click', e => {
295
301
  const b = e.target.closest('.mybtn'); if (!b) return;
296
302
  const r = +b.closest('tr[data-r]').dataset.r;
297
- if (b.dataset.act === 'del') grid.deleteRows(r, r);
303
+ grid.setValue(r, '状態', '申請中', true); // 状態遷移(readOnly列は force=true)
304
+ grid.redraw(); // ← format 再実行でボタンが変わる
298
305
  });
299
306
  ```
300
- - ボタン列は `readOnly:true` にして編集モードに入らないように。行番号は `closest('tr[data-r]')` から。**`html:true` はエスケープを外す**ので、ラベルにユーザー入力を入れる時は自分でエスケープを(XSS 責任は HTML 提供側)。空セルでも `format` は走ります(飾り列を全行に描ける)。
307
+ - ボタン列は `readOnly:true` にして編集モードに入らないように。行番号は `closest('tr[data-r]')` から。空セルでも `format` は走ります(飾り列を全行に描ける)。
308
+ - **⚠️ 初回描画は `new TssGrid(...)` の構築中に走る**ので、`format` の中で **`grid` 変数を参照しない**(まだ未代入=TDZ で例外)。状態は**第3引数 `row`** から読むのが安全(`grid.getValue` を使いたくなるが初回で落ちる)。状態を変えたら **`grid.redraw()`** で `format` を再実行してボタンを更新します。
309
+ - **`html:true` はエスケープを外す**ので、ラベルにユーザー入力を入れる時は自分でエスケープを(XSS 責任は HTML 提供側)。
301
310
 
302
311
  ### 非破壊フィルタ(`filter` / `clearFilter`)
303
312
 
@@ -1205,6 +1214,7 @@ new TssGrid(el, {
1205
1214
 
1206
1215
  - **組込キー**: `row_above` / `row_below` / `remove_row` / `col_left` / `col_right` / `remove_col` / `copy` / `cut` / `paste` / `clear` / `undo` / `redo`、区切りは `'---------'`(ダッシュのみ)。
1207
1216
  - **カスタム項目**: `{ name, callback, disabled?, hidden?, key?, submenu? }`。`name`/`disabled`/`hidden` は**関数**も可=引数は `(range, ctx)`(`range`=選択範囲 `{r0,c0,r1,c1}`)。`callback` の `this` はグリッド、引数は `{ range, key, ctx }`。
1217
+ - **組込動作のまま name/disabled だけ変えたい**: カスタム項目に `callback` を**書かず** `key` に組込キー(`row_above` 等)を指定すると、**その組込アクションを実行**します(`disabled`/`name` はカスタム側を尊重)。例: `{ name: '行を上に追加', key: 'row_above', disabled: (s) => … }`=ラベルと活性条件だけ差し替え、挿入動作は組込のまま。
1208
1218
  - **`ctx`=右クリックの対象** `{ on, r, c }`。`on` は `'col'`(列ヘッダ)/`'row'`(行ヘッダ)/`'cell'`(データセル)/`'corner'`(隅)。例: **列ヘッダを右クリックした時だけ項目を出す**なら `hidden: (range, ctx) => ctx.on !== 'col'`。
1209
1219
  - `submenu` に配列を渡すとフライアウトの**サブメニュー**になります(同じ組込キー/カスタム項目が使えます)。
1210
1220
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threestarsoftware/tssgrid",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "軽量・ノービルド・日本語IME堅牢な編集グリッド(vanilla JS / 依存なし / MIT)。Excel ライクな入力画面を <script> タグ1本で。",
5
5
  "license": "MIT",
6
6
  "author": "ThreeStarSoftware (https://www.threestarsoftware.co.jp/)",
@@ -0,0 +1,15 @@
1
+ /* TssCascade — 多段(フライアウト)プルダウン(plugins/tss-cascade.js)。色は --tg-accent を流用。 */
2
+ .tg-cascade {
3
+ position: fixed; z-index: 1000; min-width: 150px; max-height: 60vh; overflow: auto;
4
+ background: #fff; color: #1f2733; border: 1px solid #c4ccd6; border-radius: 8px;
5
+ box-shadow: 0 6px 22px rgba(20, 40, 80, .22);
6
+ font: 13px "Meiryo UI", "Segoe UI", sans-serif; padding: 4px; box-sizing: border-box;
7
+ }
8
+ .tg-cascade .tg-casc-item {
9
+ display: flex; align-items: center; gap: 10px; padding: 5px 10px; border-radius: 5px;
10
+ cursor: pointer; white-space: nowrap; outline: none;
11
+ }
12
+ .tg-cascade .tg-casc-item:hover,
13
+ .tg-cascade .tg-casc-item.active { background: var(--tg-accent, #1573c0); color: #fff; }
14
+ .tg-cascade .tg-casc-label { flex: 1; }
15
+ .tg-cascade .tg-casc-arrow { font-size: 10px; opacity: .7; margin-left: 6px; }
@@ -0,0 +1,139 @@
1
+ /*! TssCascade — 多段(フライアウト)プルダウン。TssGrid カスタムエディタ(ポップアップ型)。
2
+ *
3
+ * メニューのように「大分類 ▸ 中分類 ▸ 小分類」とフライアウトで開き、葉を選ぶとセルに確定。
4
+ * 保存はその葉の value(未指定なら label)。表示は TssCascade.pathFormat で「大 / 中 / 小」のパスにできる。
5
+ *
6
+ * 使い方:
7
+ * const OPTS = [
8
+ * { label:'営業', children:[
9
+ * { label:'国内', children:[ {label:'東日本', value:'sales.jp.east'}, {label:'西日本', value:'sales.jp.west'} ] },
10
+ * { label:'海外', value:'sales.global' },
11
+ * ] },
12
+ * { label:'開発', children:[ … ] },
13
+ * ];
14
+ * columns: [
15
+ * { data:'category', editor: TssCascade({ options: OPTS }),
16
+ * format: TssCascade.pathFormat(OPTS) }, // 表示を「営業 / 国内 / 東日本」に(任意)
17
+ * ]
18
+ *
19
+ * opts:
20
+ * options : 入れ子配列 [{ label, value?, children? }]。必須。value 未指定の葉は label を保存。
21
+ * openOnClick : 既定 true(プルダウン同様シングルクリックで開く)。
22
+ * className : ポップアップに付ける任意クラス。
23
+ * 静的:
24
+ * TssCascade.pathFormat(options, { separator }) => (value)=>string // 列 format 用
25
+ * TssCascade.pathOf(options, value, sep?) => string
26
+ */
27
+ (function (root) {
28
+ 'use strict';
29
+ function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; }); }
30
+ function leafVal(n) { return n.value !== undefined ? n.value : n.label; }
31
+
32
+ // 値 → ラベルのパス配列([大, 中, 小])。見つからなければ null。
33
+ function findPath(nodes, value, trail) {
34
+ trail = trail || [];
35
+ for (var i = 0; i < (nodes || []).length; i++) {
36
+ var n = nodes[i], t = trail.concat(n.label);
37
+ if (n.children && n.children.length) { var r = findPath(n.children, value, t); if (r) return r; }
38
+ else if (String(leafVal(n)) === String(value)) return t;
39
+ }
40
+ return null;
41
+ }
42
+
43
+ function TssCascade(opts) {
44
+ opts = opts || {};
45
+ var roots = opts.options || [];
46
+ var panels = []; // 開いているパネル(div)。panels[0]=root, [1]=sub, …
47
+ var ctxRef = null, done = false, onDoc = null, onKey = null;
48
+
49
+ function teardown() {
50
+ panels.forEach(function (p) { if (p.parentNode) p.parentNode.removeChild(p); });
51
+ panels = [];
52
+ if (onDoc) document.removeEventListener('mousedown', onDoc, true);
53
+ if (onKey) document.removeEventListener('keydown', onKey, true);
54
+ onDoc = onKey = null;
55
+ }
56
+ function closeFrom(depth) { while (panels.length > depth) { var p = panels.pop(); if (p.parentNode) p.parentNode.removeChild(p); } }
57
+ function commitLeaf(n) { if (done) return; done = true; var c = ctxRef; teardown(); c.commit(leafVal(n)); }
58
+ function doCancel() { if (done) return; done = true; var c = ctxRef; teardown(); c.cancel(); }
59
+
60
+ function position(pop, anchor, depth) {
61
+ var pw = pop.offsetWidth, ph = pop.offsetHeight, vw = window.innerWidth, vh = window.innerHeight, left, top;
62
+ if (depth === 0) { left = anchor.left; top = anchor.bottom + 2; }
63
+ else { left = anchor.right - 2; top = anchor.top - 4; } // 親アイテムの右へフライアウト
64
+ if (left + pw > vw - 6) left = depth === 0 ? Math.max(6, vw - pw - 6) : Math.max(6, anchor.left - pw + 2); // 右端で左へフリップ
65
+ if (top + ph > vh - 6) top = Math.max(6, vh - ph - 6);
66
+ pop.style.left = Math.max(6, left) + 'px'; pop.style.top = top + 'px';
67
+ }
68
+
69
+ function openPanel(nodes, depth, anchor) {
70
+ closeFrom(depth);
71
+ var pop = document.createElement('div');
72
+ pop.className = 'tg-cascade' + (opts.className ? ' ' + opts.className : '');
73
+ pop.setAttribute('role', 'menu');
74
+ pop.innerHTML = nodes.map(function (n, i) {
75
+ var branch = n.children && n.children.length;
76
+ return '<div class="tg-casc-item' + (branch ? ' branch' : '') + '" data-i="' + i + '" role="menuitem" tabindex="-1">' +
77
+ '<span class="tg-casc-label">' + esc(n.label) + '</span>' + (branch ? '<span class="tg-casc-arrow">▸</span>' : '') + '</div>';
78
+ }).join('');
79
+ document.body.appendChild(pop);
80
+ panels[depth] = pop;
81
+ position(pop, anchor, depth);
82
+
83
+ var items = pop.querySelectorAll('.tg-casc-item');
84
+ pop._items = items; pop._active = null; pop._depth = depth;
85
+ pop._activate = function (idx) { Array.prototype.forEach.call(items, function (el, k) { el.classList.toggle('active', k === idx); }); pop._active = idx; };
86
+ pop._enter = function (idx, viaKey) { // 分岐なら submenu を開く(ホバー/→)。葉なら深いパネルを閉じる。
87
+ var n = nodes[idx]; if (!n) return; pop._activate(idx);
88
+ if (n.children && n.children.length) {
89
+ openPanel(n.children, depth + 1, items[idx].getBoundingClientRect());
90
+ if (viaKey) { var sub = panels[depth + 1], f = sub._items[0]; if (f) { sub._activate(0); f.focus(); } }
91
+ } else closeFrom(depth + 1);
92
+ };
93
+ pop._choose = function (idx) { // クリック/Enter:分岐→開く、葉→確定
94
+ var n = nodes[idx]; if (!n) return;
95
+ if (n.children && n.children.length) pop._enter(idx, true); else commitLeaf(n);
96
+ };
97
+ Array.prototype.forEach.call(items, function (el) {
98
+ var idx = +el.dataset.i;
99
+ el.addEventListener('mouseenter', function () { pop._enter(idx, false); });
100
+ el.addEventListener('click', function (e) { e.stopPropagation(); pop._choose(idx); });
101
+ });
102
+ return pop;
103
+ }
104
+
105
+ function handleKey(e) {
106
+ var pop = panels[panels.length - 1]; if (!pop) return;
107
+ var items = pop._items, idx = pop._active == null ? -1 : pop._active, n;
108
+ if (e.key === 'ArrowDown') { e.preventDefault(); n = (idx + 1) % items.length; pop._activate(n); items[n].focus(); }
109
+ else if (e.key === 'ArrowUp') { e.preventDefault(); n = (idx - 1 + items.length) % items.length; pop._activate(n); items[n].focus(); }
110
+ else if (e.key === 'ArrowRight') { e.preventDefault(); if (idx >= 0) pop._enter(idx, true); }
111
+ else if (e.key === 'ArrowLeft') { e.preventDefault(); if (panels.length > 1) { closeFrom(panels.length - 1); var pp = panels[panels.length - 1]; if (pp._active != null) pp._items[pp._active].focus(); } }
112
+ else if (e.key === 'Enter') { e.preventDefault(); if (idx >= 0) pop._choose(idx); }
113
+ else if (e.key === 'Escape') { e.preventDefault(); doCancel(); }
114
+ }
115
+
116
+ return {
117
+ openOnClick: opts.openOnClick !== false,
118
+ open: function (ctx) {
119
+ ctxRef = ctx; done = false;
120
+ openPanel(roots, 0, ctx.td.getBoundingClientRect());
121
+ onDoc = function (e) { for (var i = 0; i < panels.length; i++) if (panels[i].contains(e.target)) return; doCancel(); };
122
+ onKey = handleKey;
123
+ document.addEventListener('mousedown', onDoc, true);
124
+ document.addEventListener('keydown', onKey, true);
125
+ var f = panels[0]._items[0]; if (f) { panels[0]._activate(0); f.focus(); }
126
+ },
127
+ close: function () { teardown(); },
128
+ };
129
+ }
130
+
131
+ TssCascade.pathFormat = function (options, o) {
132
+ o = o || {}; var sep = o.separator || ' / ';
133
+ return function (value) { if (value === '' || value == null) return ''; var p = findPath(options || [], value); return p ? p.join(sep) : String(value); };
134
+ };
135
+ TssCascade.pathOf = function (options, value, sep) { var p = findPath(options || [], value); return p ? p.join(sep || ' / ') : (value == null ? '' : String(value)); };
136
+
137
+ if (typeof module !== 'undefined' && module.exports) module.exports = TssCascade;
138
+ else root.TssCascade = TssCascade;
139
+ })(typeof self !== 'undefined' ? self : this);
package/src/tssgrid.d.ts CHANGED
@@ -65,8 +65,10 @@ export interface ColumnDef {
65
65
  editor?: EditorDef | (() => EditorDef);
66
66
  /** 入力の正規化(保存値へ変換)。 */
67
67
  parse?: (input: string, cell: CellCoord) => any;
68
- /** 表示の整形(保存値→表示)。文字列 or 宣言的 CellFormat。 */
69
- format?: string | object | ((value: any, cell: CellCoord) => string);
68
+ /** 表示の整形(保存値→表示)。文字列 or 宣言的 CellFormat or 関数。
69
+ * 関数は `(value, { r, c }, row)`=第3引数 `row` はその行の値配列(`grid.data[r]`・options と同形)。
70
+ * html:true 併用でセル内にボタン/アイコン等の HTML を描ける(空セルでも実行される)。 */
71
+ format?: string | object | ((value: any, cell: CellCoord, row: any[]) => string);
70
72
  /** 数値型: 千区切り表示。 */
71
73
  thousands?: boolean;
72
74
  /** 数値型: 接頭辞(例 '¥')。 */
package/src/tssgrid.js CHANGED
@@ -1093,7 +1093,7 @@
1093
1093
  const cfg = this.colCfg(c), v = this.data[r][c];
1094
1094
  if (v === '') return v;
1095
1095
  if (typeof cfg.format === 'function') {
1096
- try { const out = cfg.format(v, { r, c }); return out == null ? '' : String(out); } catch (_) { return v; }
1096
+ try { const out = cfg.format(v, { r, c }, this.data[r]); return out == null ? '' : String(out); } catch (_) { return v; } // 第3引数=その行の値配列(options と同形)
1097
1097
  }
1098
1098
  if (typeof cfg.format === 'string') return TssGrid._applyDatePattern(v, cfg.format); // 日付パターン表示
1099
1099
  if (cfg.type === 'number') return TssGrid._formatNumber(v, cfg); // 数値書式(カンマ/小数/前後綴り)
@@ -1128,7 +1128,7 @@
1128
1128
  // 空セルでも format を走らせる=ボタン/アイコン等の"飾り"を全行に描ける(_displayValue は空を素通しするので html+関数formatだけ特別扱い)。
1129
1129
  if (this.colCfg(c).html) {
1130
1130
  const fmt = this.colCfg(c).format;
1131
- if (typeof fmt === 'function' && this.data[r][c] === '') { try { const o = fmt('', { r, c }); return o == null ? '' : String(o); } catch (_) { return ''; } }
1131
+ if (typeof fmt === 'function' && this.data[r][c] === '') { try { const o = fmt('', { r, c }, this.data[r]); return o == null ? '' : String(o); } catch (_) { return ''; } }
1132
1132
  return this._displayValue(r, c);
1133
1133
  }
1134
1134
  const _h = TssGrid.esc(this._displayValue(r, c));
@@ -2545,7 +2545,10 @@
2545
2545
  const disabled = typeof entry.disabled === 'function' ? !!entry.disabled.call(this, s, ctx) : !!entry.disabled;
2546
2546
  const it = { label: String(name == null ? '' : name), disabled, danger: !!entry.danger };
2547
2547
  if (Array.isArray(entry.submenu)) it.submenu = entry.submenu;
2548
- else it.act = () => { if (typeof entry.callback === 'function') { try { entry.callback.call(this, { range: { ...s }, key: entry.key, ctx }); } catch (_) {} } };
2548
+ else if (typeof entry.callback === 'function') it.act = () => { try { entry.callback.call(this, { range: { ...s }, key: entry.key, ctx }); } catch (_) {} };
2549
+ // callback 無しで key が組込項目(row_above 等)を指すなら、その組込アクションを実行=
2550
+ // 「組込動作のまま name/disabled だけカスタムしたい」を可能に(key だけ書いて無反応だった不具合の解消)。
2551
+ else if (entry.key) { const bi = this._builtinItem(entry.key, s); if (bi && typeof bi.act === 'function') it.act = bi.act; }
2549
2552
  items.push(it);
2550
2553
  }
2551
2554
  return items;