@threestarsoftware/tssgrid 0.0.1

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.
@@ -0,0 +1,82 @@
1
+ /*! TssRunningTotal — 残高/累計(running total)プラグイン。
2
+ *
3
+ * 「前の行の結果 + この行の増減」で縦に積む列を作る。formula(行内) でも totals(全体合計) でも出せない
4
+ * **縦方向の連鎖**を担当。出納帳/通帳の残高、在庫受払の在庫数、経費・ポイントの累計など。
5
+ *
6
+ * 使い方(2通り):
7
+ * // (a) 1列を単純累計: running += Number(row[delta])
8
+ * plugins: [ TssRunningTotal({ column: 'cum', delta: 'amount', initial: 0 }) ]
9
+ *
10
+ * // (b) step 関数で自由に(入金-出金など)
11
+ * plugins: [ TssRunningTotal({
12
+ * column: 'balance', initial: 10000, // 前残(期首)
13
+ * step: (prev, row) => prev + Number(row.deposit||0) - Number(row.withdraw||0),
14
+ * }) ]
15
+ *
16
+ * opts:
17
+ * column : 残高/累計を表示する列(dataキー)。**自動 readOnly**。
18
+ * initial : 期首値(前残)。既定 0。
19
+ * delta : (step 省略時) この列の数値を累計に足す。
20
+ * step : (prev, row, {r,grid}) => newRunning 各行で残高をどう更新するか。
21
+ * skipEmpty: 全列が空の行は連鎖を進めず残高セルも空に(既定 true。末尾の予備空行対策)。
22
+ *
23
+ * handle: recompute() / destroy()。formula と併用するなら plugins:[TssFormula, TssRunningTotal(...)](派生列の後に累計)。
24
+ * 自動追従するのは 編集 / 行の挿入・削除 / setData(+それらの Undo/Redo)。
25
+ * ※ 行ドラッグ移動(rowReorder)を併用するときは順序が変わるので onAfterRowMove で handle.recompute() を呼ぶこと。
26
+ */
27
+ (function (root) {
28
+ 'use strict';
29
+ function TssRunningTotal(opts) {
30
+ opts = opts || {};
31
+ var colKey = opts.column;
32
+ var initial = (opts.initial == null) ? 0 : opts.initial;
33
+ var deltaKey = opts.delta;
34
+ var step = (typeof opts.step === 'function') ? opts.step : null;
35
+ var skipEmpty = opts.skipEmpty !== false;
36
+
37
+ return function (grid) {
38
+ // 対象列を自動 readOnly に
39
+ for (var c = 0; c < grid.COLS; c++) if (grid.columns[c] && grid.columns[c].data === colKey) { if (grid.columns[c].readOnly == null) grid.columns[c].readOnly = true; break; }
40
+
41
+ function rowObject(r) {
42
+ var o = {};
43
+ for (var c = 0; c < grid.COLS; c++) { var k = grid.columns[c] && grid.columns[c].data; if (k != null) o[k] = grid.data[r][c]; }
44
+ return o;
45
+ }
46
+ function emptyRow(r) {
47
+ for (var c = 0; c < grid.COLS; c++) { var k = grid.columns[c] && grid.columns[c].data; if (k == null) continue; if (grid.data[r][c] !== '' && grid.data[r][c] != null) return false; }
48
+ return true;
49
+ }
50
+ var deltaIdx = -1; for (var dc = 0; dc < grid.COLS; dc++) if (grid.columns[dc] && grid.columns[dc].data === deltaKey) { deltaIdx = dc; break; }
51
+
52
+ // 全行を上から走査して残高を積む(累計は前行に依存=毎回フル再計算)。
53
+ function compute() {
54
+ var running = initial;
55
+ for (var r = 0; r < grid.ROWS; r++) {
56
+ if (skipEmpty && emptyRow(r)) { grid.setValueRaw(r, colKey, ''); continue; } // 空行は連鎖を進めず空表示
57
+ if (step) { try { running = step(running, rowObject(r), { r: r, grid: grid }); } catch (e) {} }
58
+ else if (deltaIdx >= 0) { var n = Number(grid.data[r][deltaIdx]); running = running + (isFinite(n) ? n : 0); }
59
+ grid.setValueRaw(r, colKey, running);
60
+ }
61
+ }
62
+
63
+ // formula/totals と同じく onAfterChange/setData/onStructureChange を包む(prev を先に回してから累計)。
64
+ var prevAfter = grid.onAfterChange;
65
+ grid.onAfterChange = function (ch, src) { if (prevAfter) { try { prevAfter.call(grid, ch, src); } catch (e) {} } compute(); };
66
+ var prevSetData = grid.setData.bind(grid);
67
+ grid.setData = function (rows) { prevSetData(rows); compute(); };
68
+ var prevStruct = grid.onStructureChange;
69
+ grid.onStructureChange = function (info) { if (prevStruct) { try { prevStruct.call(grid, info); } catch (e) {} } compute(); };
70
+
71
+ compute(); // 初期計算
72
+
73
+ return {
74
+ name: 'running',
75
+ recompute: compute,
76
+ destroy: function () { grid.onAfterChange = prevAfter; grid.setData = prevSetData; grid.onStructureChange = prevStruct; },
77
+ };
78
+ };
79
+ }
80
+ if (typeof module !== 'undefined' && module.exports) module.exports = TssRunningTotal;
81
+ else root.TssRunningTotal = TssRunningTotal;
82
+ })(typeof self !== 'undefined' ? self : this);
@@ -0,0 +1,114 @@
1
+ /*! TssShared — 1つのマスタ(行データ)を複数グリッドで共有(A系/B系を別フィルタ・別ソートで並べて同時編集)。
2
+ * 各ビューは同じ「行参照」を持つ=片方の編集がもう片方へ即反映(該当行を再描画)。
3
+ * **行の挿入/削除も伝播**(マスタ&他ビューに反映)。**配列データ/オブジェクト配列データ どちらも対応**。
4
+ *
5
+ * 使い方:
6
+ * const shared = TssShared.create(masterRows, { rowHeaders:true, columns:[...] }); // 共通opts(masterRows=配列 or オブジェクト配列)
7
+ * const gridA = shared.attach(hostA, {}); // 例: A系
8
+ * const gridB = shared.attach(hostB, {}); // 例: B系
9
+ * gridA.filter({ 部署:'営業' }); gridB.filter({ 勤務地:'東京' });
10
+ * // どちらかで編集 → 共有行が更新され他ビューの同じ行も再描画。行の挿入/削除 → マスタ&全ビューへ。
11
+ * shared.getMaster(); // マスタ全行(getRows 相当)。 shared.detach(gridA);
12
+ *
13
+ * v1 メモ: 値編集と行の挿入/削除/列の挿入削除を伝播。編集で「他ビューのフィルタ該当が変わる」場合は
14
+ * その値編集は再描画のみ(メンバーシップ再評価は構造変更時に実施)。構造変更は他ビューを
15
+ * master から作り直す=そのビューのソートはリセット(フィルタは再適用)。minSpareRows:0 推奨。
16
+ */
17
+ (function (root) {
18
+ 'use strict';
19
+ function getTssGrid() { return (typeof module !== 'undefined' && module.exports) ? require('../src/tssgrid.js').TssGrid : root.TssGrid; }
20
+
21
+ const TssShared = {
22
+ create: function (masterRows, baseOpts) {
23
+ const TssGrid = getTssGrid();
24
+ const shared = { master: null, masterSrc: null, grids: [], baseOpts: baseOpts || {} };
25
+
26
+ shared.attach = function (host, opts) {
27
+ opts = opts || {};
28
+ const g = new TssGrid(host, Object.assign({}, shared.baseOpts, opts, { data: masterRows }));
29
+ // 正本(master)を確定。array モードは呼び出し側の行配列参照をそのまま正本に(編集が呼び出し側 masterRows にも反映)。
30
+ // object モードは _ingest が 2D 化するので取り込んだ data を正本、元オブジェクトは _src を正本にする。
31
+ if (!shared.master) {
32
+ if (g._src) { shared.master = g.data.slice(); shared.masterSrc = g._src.slice(); }
33
+ else { shared.master = masterRows.slice(); shared.masterSrc = null; }
34
+ }
35
+ g.data = shared.master.map(function (r) { return r; }); // 行参照を共有
36
+ g._src = shared.masterSrc ? shared.masterSrc.map(function (s) { return s; }) : null; // object時は元オブジェクトも共有
37
+ g.ROWS = g.data.length;
38
+ g.buildTable(); g.setActive(0, 0);
39
+
40
+ // 値編集 → 他ビューの同じ行を再描画(値は参照共有で既に更新済み)。
41
+ const prevAfter = opts.onAfterChange || g.onAfterChange;
42
+ g.onAfterChange = function (changes, source) {
43
+ shared._propagate(g, changes);
44
+ if (prevAfter) { try { prevAfter(changes, source); } catch (_) {} }
45
+ };
46
+ // 行/列の構造変更(do/undo/redo すべて)→ source の現在の全行を新マスタにして他ビューを作り直す。
47
+ const prevStruct = opts.onStructureChange;
48
+ g.onStructureChange = function (info) {
49
+ shared._rebaseFrom(g);
50
+ if (prevStruct) { try { prevStruct(info); } catch (_) {} }
51
+ };
52
+ g._sharedRef = shared;
53
+ shared.grids.push(g);
54
+ return g;
55
+ };
56
+
57
+ shared._propagate = function (srcGrid, changes) {
58
+ for (let i = 0; i < shared.grids.length; i++) {
59
+ const other = shared.grids[i];
60
+ if (other === srcGrid) continue;
61
+ for (let k = 0; k < changes.length; k++) {
62
+ const ch = changes[k], ref = srcGrid.data[ch.r];
63
+ if (!ref) continue;
64
+ const or = other.data.indexOf(ref);
65
+ if (or >= 0) { try { other._renderCell(or, ch.c); } catch (_) {} }
66
+ }
67
+ }
68
+ };
69
+
70
+ // 構造変更を済ませた source の「全行(フィルタ中なら _allRows)」を新マスタにし、他ビューを再構成。
71
+ shared._rebaseFrom = function (srcGrid) {
72
+ const allRows = srcGrid._allRows || srcGrid.data;
73
+ const allSrc = srcGrid._allRows ? srcGrid._allSrc : srcGrid._src;
74
+ shared.master = allRows.slice();
75
+ shared.masterSrc = allSrc ? allSrc.slice() : null;
76
+ for (let i = 0; i < shared.grids.length; i++) {
77
+ const g = shared.grids[i];
78
+ if (g === srcGrid) continue;
79
+ // 列メタを source に合わせる(列の挿入削除に追従)。行は master から作り直す(フィルタは再適用・ソートはリセット)。
80
+ g.COLS = srcGrid.COLS; g.headers = srcGrid.headers.slice(); g.columns = srcGrid.columns.slice(); g.colW = srcGrid.colW.slice();
81
+ const f = (g.isFiltered && g.isFiltered()) ? g._filterFn : null;
82
+ g._allRows = g._allSrc = g._meta = g._filterFn = null;
83
+ g.data = shared.master.map(function (r) { return r; });
84
+ g._src = shared.masterSrc ? shared.masterSrc.map(function (s) { return s; }) : null;
85
+ g.ROWS = g.data.length;
86
+ if (f) { g.filter(f); }
87
+ else { g.buildTable(); g.setActive(Math.min(g.active.r, Math.max(0, g.ROWS - 1)), g.active.c); }
88
+ }
89
+ };
90
+
91
+ // マスタ全行を getRows 形式で(フィルタに関係なく全件)。
92
+ shared.getMaster = function () {
93
+ const g0 = shared.grids[0]; if (!g0) return [];
94
+ const rows = shared.master, src = shared.masterSrc;
95
+ return rows.map(function (row, r) {
96
+ const o = src ? Object.assign({}, src[r]) : {};
97
+ for (let c = 0; c < g0.COLS; c++) { const k = g0._key(c); if (k != null) g0._setPathCOW(o, k, row[c]); }
98
+ return o;
99
+ });
100
+ };
101
+
102
+ shared.detach = function (g) {
103
+ const i = shared.grids.indexOf(g);
104
+ if (i >= 0) shared.grids.splice(i, 1);
105
+ if (g) delete g._sharedRef;
106
+ };
107
+
108
+ return shared;
109
+ },
110
+ };
111
+
112
+ if (typeof module !== 'undefined' && module.exports) module.exports = TssShared;
113
+ else root.TssShared = TssShared;
114
+ })(typeof self !== 'undefined' ? self : this);
@@ -0,0 +1,57 @@
1
+ /*! TssSparkline — セル内スパークライン(ミニ折れ線/棒)。**SVG自前描画**。
2
+ *
3
+ * セルの値(カンマ/空白区切りの数列 "388,329,220,348,323,165")を**小さなグラフ**として描く。
4
+ * 仕組みは `columns[c].html:true` + `format` 関数=format が返す SVG 文字列がそのままセルに入る(html-cells と同じ seam)。
5
+ * Chart.js 等は積まない=ノービルド維持。数値しか SVG に入れないので安全(XSS なし)。
6
+ *
7
+ * 使い方:
8
+ * columns: [
9
+ * { data:'trend', title:'動向', html:true, readOnly:true, format: TssSparkline({ width:80, height:22 }) }
10
+ * ]
11
+ * // 値 "12,15,9,20,18" → ミニ折れ線。formula で月次列から数列を組み立てると、編集でライブ更新:
12
+ * { data:'trend', html:true, readOnly:true, format: TssSparkline(),
13
+ * formula: (row) => ['m4','m5','m6','m7','m8','m9'].map(k=>row[k]).filter(v=>v!=='').join(',') }
14
+ *
15
+ * opts:
16
+ * type : 'line'(既定) | 'bar'
17
+ * width/height : px(既定 80 / 22)
18
+ * color : 線/棒の色(既定 '#0d9488') strokeWidth: 線の太さ(既定 1.5)
19
+ * fill : 折れ線の下を塗る色(既定なし) padding: 余白(px・既定 2)
20
+ * min/max: スケール下限/上限(既定は値の最小/最大で自動)
21
+ */
22
+ (function (root) {
23
+ 'use strict';
24
+ function r2(n) { return Math.round(n * 100) / 100; }
25
+
26
+ function TssSparkline(opts) {
27
+ opts = opts || {};
28
+ var W = opts.width || 80, H = opts.height || 22, pad = (opts.padding != null) ? opts.padding : 2;
29
+ var color = opts.color || '#0d9488', sw = opts.strokeWidth || 1.5, type = opts.type || 'line';
30
+ var fill = opts.fill || null, barColor = opts.barColor || color;
31
+
32
+ return function (value) {
33
+ var nums = String(value == null ? '' : value).split(/[,\s]+/).map(function (s) { return parseFloat(s); }).filter(function (n) { return isFinite(n); });
34
+ if (!nums.length) return '';
35
+ var min = (opts.min != null) ? opts.min : Math.min.apply(null, nums);
36
+ var max = (opts.max != null) ? opts.max : Math.max.apply(null, nums);
37
+ var range = (max - min) || 1, innerW = W - pad * 2, innerH = H - pad * 2;
38
+ var body;
39
+ if (type === 'bar') {
40
+ var gap = innerW / nums.length, bw = gap * 0.7;
41
+ body = nums.map(function (v, i) {
42
+ var h = ((v - min) / range) * innerH, bx = pad + i * gap + (gap - bw) / 2;
43
+ return '<rect x="' + r2(bx) + '" y="' + r2(pad + innerH - h) + '" width="' + r2(bw) + '" height="' + r2(Math.max(0, h)) + '" fill="' + barColor + '"/>';
44
+ }).join('');
45
+ } else {
46
+ var x = function (i) { return pad + (nums.length <= 1 ? innerW / 2 : (i / (nums.length - 1)) * innerW); };
47
+ var y = function (v) { return pad + innerH - ((v - min) / range) * innerH; };
48
+ var pts = nums.map(function (v, i) { return r2(x(i)) + ',' + r2(y(v)); }).join(' ');
49
+ var area = fill ? '<polygon points="' + r2(x(0)) + ',' + r2(H - pad) + ' ' + pts + ' ' + r2(x(nums.length - 1)) + ',' + r2(H - pad) + '" fill="' + fill + '" stroke="none"/>' : '';
50
+ body = area + '<polyline points="' + pts + '" fill="none" stroke="' + color + '" stroke-width="' + sw + '" stroke-linejoin="round" stroke-linecap="round"/>';
51
+ }
52
+ return '<svg width="' + W + '" height="' + H + '" viewBox="0 0 ' + W + ' ' + H + '" style="display:block">' + body + '</svg>';
53
+ };
54
+ }
55
+ if (typeof module !== 'undefined' && module.exports) module.exports = TssSparkline;
56
+ else root.TssSparkline = TssSparkline;
57
+ })(typeof self !== 'undefined' ? self : this);
@@ -0,0 +1,95 @@
1
+ /*! TssSyncScroll — 複数グリッドの同期スクロール(Excel「並べて比較+同時にスクロール」相当)。
2
+ *
3
+ * 左右(or 上下)に並べた 2枚以上のグリッドのスクロールを連動。A系/B系で別々に絞り込んだ表を
4
+ * 突き合わせながら編集する用途に。`grid.wrap`(overflow:auto のスクロール枠) の scroll を相手へ転写。
5
+ *
6
+ * 使い方:
7
+ * const link = TssSyncScroll.link([gridA, gridB]); // 既定: 相対・縦横ピクセル
8
+ * const link = TssSyncScroll.link([gA, gB], { by:'row' }); // 行で揃える
9
+ * const link = TssSyncScroll.link([gA, gB], { relative:false }); // 絶対(スナップして一致)
10
+ * link.unlink(); // 連動解除(リスナ除去)
11
+ *
12
+ * opts:
13
+ * relative: true(既定) — **ONにした時点の位置差を保ったまま、移動量(delta)だけ連動**(Excel流)。
14
+ * 連動OFF→片方スクロール→ONでも"その場から"続けて同期できる。
15
+ * false — 絶対。常に相手をこちらに合わせてスナップ(pixel:同じ量 / row:同じ行index)。
16
+ * axis: 'both'(既定) | 'vertical' | 'horizontal'
17
+ * by: 'pixel'(既定・スクロール量) | 'row'(相手の同じ行を基準に揃える。コアの _rowTops を使用=行高が違っても可)
18
+ *
19
+ * メモ: 縦の連動量は by='row' なら行index、'pixel' なら px。横は常に px。フィードバックは
20
+ * 「自分が動かした相手の echo は一度無視+値が変わった時だけ動かす」で防止+収束。
21
+ */
22
+ (function (root) {
23
+ 'use strict';
24
+
25
+ // scrollTop から「ヘッダ直下に見えている本体行」index(二分探索)。
26
+ function rowAtTop(g) {
27
+ var T = g._rowTops; if (!T) return 0;
28
+ var target = (g.wrap.scrollTop || 0) + (g._theadH || 0);
29
+ var lo = 0, hi = g.ROWS;
30
+ while (lo < hi) { var mid = (lo + hi) >> 1; if (T[mid] < target) lo = mid + 1; else hi = mid; }
31
+ return Math.max(0, Math.min(lo, g.ROWS - 1));
32
+ }
33
+ // 行 r をヘッダ直下に揃える scrollTop(クランプ)。
34
+ function topForRow(g, r) {
35
+ var T = g._rowTops; if (!T) return null;
36
+ r = Math.max(0, Math.min(Math.round(r), g.ROWS - 1));
37
+ var top = (T[r] || 0) - (g._theadH || 0);
38
+ var max = g.wrap.scrollHeight - g.wrap.clientHeight;
39
+ return Math.max(0, Math.min(top, max < 0 ? 0 : max));
40
+ }
41
+
42
+ var TssSyncScroll = {
43
+ link: function (grids, opts) {
44
+ grids = (grids || []).filter(Boolean);
45
+ opts = opts || {};
46
+ var axis = opts.axis || 'both';
47
+ var byRow = opts.by === 'row';
48
+ var relative = opts.relative !== false; // 既定 true
49
+ var vert = axis === 'both' || axis === 'vertical';
50
+ var horiz = axis === 'both' || axis === 'horizontal';
51
+
52
+ // 縦の「同期座標」: row なら行index、pixel なら scrollTop。横は常に scrollLeft。
53
+ function readV(g) { return byRow ? rowAtTop(g) : g.wrap.scrollTop; }
54
+ // px 代入はブラウザが範囲内に自動クランプするので手動クランプ不要(値が変わる時だけ ignore を立てる)。
55
+ function setScrollTop(st, px) { px = Math.round(px); if (st.g.wrap.scrollTop !== px) { st.ignore = true; st.g.wrap.scrollTop = px; } }
56
+ function setScrollLeft(st, px) { px = Math.round(px); if (st.g.wrap.scrollLeft !== px) { st.ignore = true; st.g.wrap.scrollLeft = px; } }
57
+ function writeV(st, v) { if (byRow) { var t = topForRow(st.g, v); if (t != null) setScrollTop(st, t); } else setScrollTop(st, v); }
58
+
59
+ var states = grids.map(function (g) { return { g: g, ignore: false, v: readV(g), left: g.wrap.scrollLeft, onScroll: null }; });
60
+
61
+ states.forEach(function (st) {
62
+ st.onScroll = function () {
63
+ if (st.ignore) { st.ignore = false; st.v = readV(st.g); st.left = st.g.wrap.scrollLeft; return; } // 自分が動かされた echo
64
+ if (relative) {
65
+ var dV = readV(st.g) - st.v, dL = st.g.wrap.scrollLeft - st.left; // 移動量だけ伝える=位置差を保つ
66
+ st.v = readV(st.g); st.left = st.g.wrap.scrollLeft;
67
+ states.forEach(function (dst) {
68
+ if (dst === st) return;
69
+ if (vert && dV) { writeV(dst, dst.v + dV); dst.v = readV(dst.g); }
70
+ if (horiz && dL) { setScrollLeft(dst, dst.g.wrap.scrollLeft + dL); dst.left = dst.g.wrap.scrollLeft; }
71
+ });
72
+ } else {
73
+ var v = readV(st.g), l = st.g.wrap.scrollLeft; // 絶対=相手をこちらに合わせる
74
+ states.forEach(function (dst) {
75
+ if (dst === st) return;
76
+ if (vert) writeV(dst, v);
77
+ if (horiz) setScrollLeft(dst, l);
78
+ });
79
+ }
80
+ };
81
+ st.g.wrap.addEventListener('scroll', st.onScroll, { passive: true });
82
+ });
83
+
84
+ return {
85
+ grids: grids,
86
+ // 現在位置を基準として取り直す(相対モードで明示的に"今の差"を基準化したい時に)。
87
+ rebase: function () { states.forEach(function (st) { st.v = readV(st.g); st.left = st.g.wrap.scrollLeft; }); },
88
+ unlink: function () { states.forEach(function (st) { st.g.wrap.removeEventListener('scroll', st.onScroll); }); states = []; },
89
+ };
90
+ },
91
+ };
92
+
93
+ if (typeof module !== 'undefined' && module.exports) module.exports = TssSyncScroll;
94
+ else root.TssSyncScroll = TssSyncScroll;
95
+ })(typeof self !== 'undefined' ? self : this);
@@ -0,0 +1,23 @@
1
+ /* TssTime — スタイル。業務側は下の CSS 変数を上書きするだけでテーマを変えられる。
2
+ 例: .tss-time { --time-accent:#c0392b; --time-radius:10px; } */
3
+ .tss-time {
4
+ --time-bg: #fff;
5
+ --time-fg: #222;
6
+ --time-border: #c3ccd6;
7
+ --time-accent: #2aa0d8;
8
+ --time-radius: 8px;
9
+ --time-font: 13px "Segoe UI", "Meiryo", sans-serif;
10
+
11
+ position: fixed; z-index: 10000; background: var(--time-bg); color: var(--time-fg);
12
+ border: 1px solid var(--time-border); border-radius: var(--time-radius);
13
+ box-shadow: 0 6px 24px rgba(0,0,0,.18); padding: 10px; font: var(--time-font); user-select: none; outline: none;
14
+ }
15
+ .tss-time-row { display: flex; align-items: center; gap: 4px; }
16
+ .tss-time-sep { font-weight: 600; padding: 0 1px; }
17
+ .tss-time select { font: inherit; padding: 4px 6px; border: 1px solid var(--time-border); border-radius: 5px; background: var(--time-bg); color: var(--time-fg); cursor: pointer; }
18
+ .tss-time select:focus { outline: 2px solid var(--time-accent); outline-offset: -1px; }
19
+ .tss-time-btns { display: flex; justify-content: flex-end; gap: 6px; margin-top: 8px; }
20
+ .tss-time-btns button { font: inherit; padding: 4px 12px; border-radius: 6px; border: 1px solid var(--time-border); background: var(--time-bg); color: var(--time-fg); cursor: pointer; }
21
+ .tss-time-btns button:hover { background: #eef4ff; }
22
+ .tss-time-btns .tss-time-ok { border-color: var(--time-accent); background: var(--time-accent); color: #fff; }
23
+ .tss-time-btns .tss-time-ok:hover { filter: brightness(1.05); }
@@ -0,0 +1,91 @@
1
+ /*! TssTime — 時刻ピッカー(TssGrid のカスタムエディタ / 単体ウィジェット両用)
2
+ * 時/分プルダウン方式(業務の任意時刻入力向き)。保存値は常に 24h 'HH:MM'(type:'time' と互換)。
3
+ * 使い方(グリッド): columns: [{ type:'time', editor: TssTime({ step:15 }) }]
4
+ * opts: step(分刻み,既定1) / hour12(AM/PM表示,既定false) / icon(既定 '🕐') / openOnClick(既定true) / className
5
+ */
6
+ (function (root) {
7
+ 'use strict';
8
+ function pad(n) { return String(n).padStart(2, '0'); }
9
+ function parseHM(s) { var m = /^(\d{1,2}):(\d{2})$/.exec(String(s || '')); if (!m) return null; var h = +m[1], mi = +m[2]; return (h <= 23 && mi <= 59) ? { h: h, m: mi } : null; }
10
+
11
+ function TssTime(opts) {
12
+ opts = opts || {};
13
+ var step = Math.max(1, opts.step || 1);
14
+ var hour12 = !!opts.hour12;
15
+ var pop = null, ref = null, done = false, outside = null;
16
+
17
+ function opt(val, label, sel) { return '<option value="' + val + '"' + (sel ? ' selected' : '') + '>' + label + '</option>'; }
18
+ function render(cur) {
19
+ var h = cur ? cur.h : null, mi = cur ? cur.m : null;
20
+ // 時
21
+ var hSel = '<select class="tss-time-h" aria-label="時"><option value=""></option>';
22
+ if (hour12) { for (var i = 1; i <= 12; i++) { var d24 = (h == null) ? null : ((h % 12) || 12); hSel += opt(i, i, d24 === i); } }
23
+ else { for (var j = 0; j <= 23; j++) hSel += opt(j, pad(j), h === j); }
24
+ hSel += '</select>';
25
+ // 分(step 刻み。現在値が刻みに無ければ補う)
26
+ var mins = []; for (var k = 0; k < 60; k += step) mins.push(k);
27
+ if (mi != null && mins.indexOf(mi) < 0) { mins.push(mi); mins.sort(function (a, b) { return a - b; }); }
28
+ var mSel = '<select class="tss-time-m" aria-label="分"><option value=""></option>';
29
+ for (var n = 0; n < mins.length; n++) mSel += opt(mins[n], pad(mins[n]), mi === mins[n]);
30
+ mSel += '</select>';
31
+ // AM/PM
32
+ var aSel = '';
33
+ if (hour12) { var pm = h != null && h >= 12; aSel = '<select class="tss-time-ap" aria-label="午前/午後">' + opt('AM', 'AM', !pm) + opt('PM', 'PM', pm) + '</select>'; }
34
+ pop.innerHTML = '<div class="tss-time-row">' + hSel + '<span class="tss-time-sep">:</span>' + mSel + aSel + '</div>'
35
+ + '<div class="tss-time-btns"><button type="button" class="tss-time-now">現在</button><button type="button" class="tss-time-ok">決定</button></div>';
36
+ }
37
+ function readValue() {
38
+ var hS = pop.querySelector('.tss-time-h'), mS = pop.querySelector('.tss-time-m'), aS = pop.querySelector('.tss-time-ap');
39
+ if (hS.value === '' || mS.value === '') return '';
40
+ var h = +hS.value, m = +mS.value;
41
+ if (hour12 && aS) { var ap = aS.value; h = ap === 'PM' ? (h === 12 ? 12 : h + 12) : (h === 12 ? 0 : h); }
42
+ return pad(h) + ':' + pad(m);
43
+ }
44
+ function setNow() {
45
+ var d = new Date(), m = Math.round(d.getMinutes() / step) * step; if (m >= 60) m = 60 - step;
46
+ render({ h: d.getHours(), m: m });
47
+ }
48
+ function place(anchor) {
49
+ var r = anchor.getBoundingClientRect();
50
+ pop.style.left = Math.max(2, Math.min(r.left, window.innerWidth - pop.offsetWidth - 6)) + 'px';
51
+ var top = r.bottom + 2;
52
+ if (top + pop.offsetHeight > window.innerHeight) top = Math.max(2, r.top - pop.offsetHeight - 2);
53
+ pop.style.top = top + 'px';
54
+ }
55
+ function onClick(e) {
56
+ if (e.target.classList.contains('tss-time-now')) { setNow(); return; }
57
+ if (e.target.classList.contains('tss-time-ok')) { commit(readValue()); }
58
+ }
59
+ function onKey(e) {
60
+ if (e.key === 'Escape') { e.preventDefault(); cancel(); }
61
+ else if (e.key === 'Enter') { e.preventDefault(); commit(readValue()); }
62
+ }
63
+ function show(anchor, value, onPick, onCancel) {
64
+ done = false;
65
+ pop = document.createElement('div'); pop.className = 'tss-time' + (opts.className ? ' ' + opts.className : ''); pop.tabIndex = -1;
66
+ document.body.appendChild(pop);
67
+ pop.addEventListener('mousedown', function (e) { if (e.target.tagName !== 'SELECT') e.preventDefault(); });
68
+ pop.addEventListener('click', onClick);
69
+ pop.addEventListener('keydown', onKey);
70
+ render(parseHM(value)); place(anchor);
71
+ ref = { pick: onPick, cancel: onCancel };
72
+ // 外側クリックは「確定」(時/分を選んで離す=採用)。未変更なら同値 commit=実質no-op。アンカーは除外(再クリックはグリッドがトグル)
73
+ outside = function (e) { if (pop && !pop.contains(e.target) && !(anchor && anchor.contains && anchor.contains(e.target))) commit(readValue()); };
74
+ setTimeout(function () { document.addEventListener('mousedown', outside, true); }, 0);
75
+ pop.focus();
76
+ }
77
+ function teardown() { if (outside) { document.removeEventListener('mousedown', outside, true); outside = null; } if (pop) { pop.remove(); pop = null; } }
78
+ function commit(v) { if (done) return; done = true; var f = ref.pick; teardown(); f(v); }
79
+ function cancel() { if (done) return; done = true; var f = ref.cancel; teardown(); if (f) f(); }
80
+
81
+ return {
82
+ open: function (ctx) { show(ctx.td, ctx.value, function (v) { ctx.commit(v); }, function () { ctx.cancel(); }); },
83
+ close: function () { teardown(); },
84
+ icon: opts.icon != null ? opts.icon : '🕐',
85
+ openOnClick: opts.openOnClick !== false,
86
+ openAt: function (anchorEl, value, onPick) { show(anchorEl, value, function (v) { onPick(v); }, null); },
87
+ };
88
+ }
89
+ if (typeof module !== 'undefined' && module.exports) module.exports = TssTime;
90
+ else root.TssTime = TssTime;
91
+ })(typeof self !== 'undefined' ? self : this);
@@ -0,0 +1,7 @@
1
+ /* TssToggle — 2択セグメント。色は変数で上書き可。 */
2
+ .tg-toggle { display: inline-flex; border: 1px solid var(--tg-toggle-border, #cfd7e0); border-radius: 999px; overflow: hidden; font-size: .92em; line-height: 1.7; vertical-align: middle; }
3
+ .tg-toggle .tg-seg { padding: 0 11px; cursor: pointer; color: var(--tg-toggle-fg, #64748b); user-select: none; transition: background .12s; }
4
+ .tg-toggle .tg-seg + .tg-seg { border-left: 1px solid var(--tg-toggle-border, #cfd7e0); }
5
+ .tg-toggle .tg-seg:hover { background: var(--tg-toggle-hover, #f0f3f7); }
6
+ .tg-toggle .tg-seg.active { background: var(--tg-toggle-accent, #0d9488); color: #fff; }
7
+ .tg-toggle .tg-seg.active:hover { background: var(--tg-toggle-accent, #0d9488); }
@@ -0,0 +1,86 @@
1
+ /*! TssToggle — 2択セグメント・トグル(有効|無効・公開|非公開・男|女 …)。
2
+ *
3
+ * ラジオの2択を「並んだピル(セグメント)」で表示し、クリックでどちらかに切替。値は dropdown と同じく
4
+ * 選んだ option の value を保存(getData も value)。html:true+format で描画、クリックは委譲で拾う。
5
+ *
6
+ * 使い方:
7
+ * plugins: [ TssToggle.plugin({ columns: {
8
+ * status: ['有効', '無効'], // 文字列2つ(value=label)
9
+ * pub: [{value:'1',label:'公開'},{value:'0',label:'非公開'}] // value/label 分離も可
10
+ * } }) ]
11
+ * // 列定義側で直接: { data:'status', html:true, format: TssToggle.format(['有効','無効']) } + plugins:[TssToggle.plugin()]
12
+ *
13
+ * opts(plugin): columns({dataキー: 2要素配列}) / accent(選択側の色・CSS変数 --tg-toggle-accent でも)。
14
+ * 挙動: クリックでその側に確定(setValue=検証/Undo を通る)。アクティブセルで Space は反対側へトグル。
15
+ * 他値を打ち込めないよう、対象列に「2値のみ許可」の validator を自動付与。
16
+ */
17
+ (function (root) {
18
+ 'use strict';
19
+ function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; }); }
20
+ function normOpts(arr) { return (arr || []).slice(0, 2).map(function (o) { return (o && typeof o === 'object') ? { value: String(o.value), label: String(o.label) } : { value: String(o), label: String(o) }; }); }
21
+
22
+ function makeFormat(options) {
23
+ var opts = normOpts(options);
24
+ return function (value) {
25
+ var v = String(value == null ? '' : value);
26
+ return '<span class="tg-toggle">' + opts.map(function (o) {
27
+ return '<span class="tg-seg' + (o.value === v ? ' active' : '') + '" data-tg-v="' + esc(o.value) + '">' + esc(o.label) + '</span>';
28
+ }).join('') + '</span>';
29
+ };
30
+ }
31
+
32
+ function TssToggle() { /* 名前空間。format / plugin を使う */ }
33
+ TssToggle.format = makeFormat;
34
+
35
+ TssToggle.plugin = function (opts) {
36
+ opts = opts || {};
37
+ var cols = opts.columns || {};
38
+ return function (grid) {
39
+ var byCol = {}; // 列index → 正規化オプション
40
+ Object.keys(cols).forEach(function (key) {
41
+ var ci = -1; for (var c = 0; c < grid.COLS; c++) if (grid.columns[c] && grid.columns[c].data === key) { ci = c; break; }
42
+ if (ci < 0) return;
43
+ var o = normOpts(cols[key]); if (o.length < 2) return;
44
+ var cfg = grid.columns[ci];
45
+ cfg.html = true;
46
+ if (typeof cfg.format !== 'function') cfg.format = makeFormat(o);
47
+ var vals = o.map(function (x) { return x.value; });
48
+ if (typeof cfg.validator !== 'function') cfg.validator = function (v) { return v === '' || vals.indexOf(v) >= 0; }; // 2値以外を弾く
49
+ byCol[ci] = vals;
50
+ });
51
+ // それ自体は無効でも format 直指定の列をクリックで拾えるよう、format 由来の列も拾う
52
+ function valsOf(c) {
53
+ if (byCol[c]) return byCol[c];
54
+ return null; // 設定外の列は対象外
55
+ }
56
+ function editable(r, c) { var cfg = grid.columns[c]; return !grid.readOnly && !(cfg && cfg.readOnly === true); }
57
+
58
+ function onClick(e) {
59
+ var seg = e.target.closest && e.target.closest('.tg-seg[data-tg-v]'); if (!seg) return;
60
+ var td = seg.closest('td[data-c]'); if (!td) return;
61
+ var c = +td.dataset.c, r = +td.dataset.r;
62
+ if (!valsOf(c) || !editable(r, c)) return;
63
+ grid.setValue(r, c, seg.getAttribute('data-tg-v')); // 検証/Undo を通る
64
+ }
65
+ function onKey(e) {
66
+ if (e.key !== ' ' && e.key !== 'Spacebar') return;
67
+ var r = grid.active.r, c = grid.active.c, vals = valsOf(c);
68
+ if (!vals || !editable(r, c) || grid.mode === 'edit') return;
69
+ e.preventDefault();
70
+ var cur = grid.getValue(r, c), i = vals.indexOf(String(cur));
71
+ grid.setValue(r, c, vals[(i + 1) % vals.length]); // 反対側へトグル
72
+ }
73
+ grid.wrap.addEventListener('click', onClick);
74
+ grid.editor.addEventListener('keydown', onKey);
75
+ grid.buildTable(); // format/html を反映
76
+
77
+ return {
78
+ name: 'toggle',
79
+ destroy: function () { grid.wrap.removeEventListener('click', onClick); grid.editor.removeEventListener('keydown', onKey); },
80
+ };
81
+ };
82
+ };
83
+
84
+ if (typeof module !== 'undefined' && module.exports) module.exports = TssToggle;
85
+ else root.TssToggle = TssToggle;
86
+ })(typeof self !== 'undefined' ? self : this);