@threestarsoftware/tssgrid 0.1.27 → 0.1.29-beta.0

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
+ ## [Unreleased]
7
+
8
+ ### 追加(プラグイン)
9
+ - **`tss-calendar` に範囲選択 `range` を追加** — 連続した日(休暇申請の開始〜終了など)を**1回の編集で**選べる。`range:true` で有効、`range:(ctx)=>boolean` で**行ごとにON/OFF**(半休の行は範囲を選ばせない等)。未指定は単日=**従来動作**。1クリック目=開始・2クリック目=終了(**開始より前なら入替・同じ日を2回で単日確定**)、間の日に色が付く。1クリック目だけで閉じたら(Esc/外クリック)**値は変えない**。確定値は既定で `2026/10/13~2026/10/16`(単日は `2026/10/13`/区切りは `rangeSeparator`・既定 '~'/`rangeValue(startISO,endISO)` で完全差替可)。**入力中のセルに範囲ツールチップ**を出し、`rangeTooltip(startISO,endISO)` で文面を差し替え可能(休日を除いた日数などを外から足す口)。**休日スキップは利用側**=カレンダーは範囲を返すだけ。範囲列は `type:'date'` を付けない素の列にする(範囲文字列を日付パーサに通さないため)。例 `examples/calendar-range.html`。
10
+
11
+ ## [0.1.28] - 2026-10-20
12
+
13
+ ### 追加(プラグイン)
14
+ - **`tss-hyperlink`(セル内ハイパーリンク・安全URL)を追加** — 値を**クリックで開くリンク**にする `format` ヘルパ。値は「表示テキスト|URL」または URL 単体。`columns[c].html:true` + `format: TssHyperlink()` で使う。**URL は利用者データになり得る=XSS を最初から潰す設計**:`http`/`https`/`mailto`/`tel` のみ許可(`javascript:`/`data:`/`vbscript:` 等は**リンクにせず素のテキスト**に落とす)、スキーム無しは `https://` 補完、表示テキスト・`href` は必ずエスケープ、`target=_blank` に `rel="noopener noreferrer"`。`TssHyperlink.safeUrl(u)` 単体でも利用可。例 `examples/hyperlink.html`(攻撃例含む)。
15
+ - **`tss-cascade` に「その他」自由入力を追加** — 多段(フライアウト)プルダウンの葉に `other:true` を付けると、選んだとき**入力欄に切替**=自由入力できる(IME 合成ガード・確定/戻る付き)。**`codeField` 指定時**は、値セル=普通の葉は「大 / 中 / 小」のパス・その他は打った文字/`codeField` セル=その葉(その他ノード)の `value`。**「その他」が枝ごとにあっても(有給▸その他/午前休▸その他)コードで親が区別できる**(勤怠区分など)。`codeField` 省略時は従来どおり葉の `value` を値セルに保存=**後方互換**。`separator`/`otherPlaceholder` オプション追加。例 `examples/cascade-other.html`。
16
+
17
+ ### ドキュメント
18
+ - **sparkline の `fill` オプションを明記**(`fill` 指定=面グラフ/無し=折れ線)。新規実装ではなく既存オプションの周知(`examples/sparkline.html` は既に使用)。
19
+
6
20
  ## [0.1.27] - 2026-10-14
7
21
 
8
22
  ### セキュリティ修正
package/README.md CHANGED
@@ -729,6 +729,24 @@ columns: [
729
729
  formula: (row) => ['m4','m5','m6','m7','m8','m9'].map(k=>row[k]).filter(v=>v!=='').join(',') }
730
730
  ```
731
731
  - `type`('line'/'bar') / `width` / `height` / `color` / `strokeWidth` / `fill`(折れ線下の塗り) / `min`・`max`(既定は自動)。
732
+ - **`fill` を指定すると折れ線の下が塗られて「面グラフ(area)」**になります(=推移+量感)。`fill` 無し=ただの線(line)。別プラグイン不要で line/area を使い分け。
733
+
734
+ ### 同梱プラグイン: セル内ハイパーリンク(tss-hyperlink・安全URL)
735
+
736
+ `plugins/tss-hyperlink.js`= セルの値を**クリックで開くリンク**に(format ヘルパ)。値は **「表示テキスト|URL」** か **URL 単体**。`columns[c].html:true` + `format: TssHyperlink()` で使います。動く例: [`examples/hyperlink.html`](https://tssgrid.threestarsoftware.co.jp/examples/hyperlink.html)。
737
+
738
+ ```html
739
+ <link rel="stylesheet" href="plugins/tss-hyperlink.css">
740
+ <script src="plugins/tss-hyperlink.js"></script>
741
+ ```
742
+ ```js
743
+ columns: [
744
+ { data:'url', html:true, readOnly:true, format: TssHyperlink() },
745
+ ];
746
+ // 値: 'GitHub|https://github.com' / 'https://example.com' / 'example.com' / 'メール|mailto:a@b.jp'
747
+ ```
748
+ - **★安全URL**: `http`/`https`/`mailto`/`tel` のみリンク化。**`javascript:`・`data:` 等は素のテキストに落とす**(XSS 無効化)。スキーム無しは `https://` 補完、表示テキスト・href は HTML エスケープ、`target=_blank` に `rel="noopener noreferrer"` を付与。
749
+ - `separator`(既定 `'|'`) / `target`(既定 `'_blank'`) / `className`(既定 `'tss-link'`)。`TssHyperlink.safeUrl(u)` で URL 安全化だけ単体利用も可(許可外は `null`)。
732
750
  - 数値しか SVG に入れない=安全。`formula`/`html-cells` と同じ仕組みで、**有料グリッドの "セル内グラフ" を無料で**。
733
751
 
734
752
  ### 同梱プラグイン: セル内 横棒グラフ(bar・データバー)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threestarsoftware/tssgrid",
3
- "version": "0.1.27",
3
+ "version": "0.1.29-beta.0",
4
4
  "description": "軽量・ノービルド・日本語IME堅牢な編集グリッド(vanilla JS / 依存なし / MIT)。Excel ライクな入力画面を <script> タグ1本で。",
5
5
  "license": "MIT",
6
6
  "author": "ThreeStarSoftware (https://www.threestarsoftware.co.jp/)",
@@ -59,6 +59,10 @@
59
59
  "types": "./plugins/tss-time.d.ts",
60
60
  "default": "./plugins/tss-time.js"
61
61
  },
62
+ "./plugins/tss-hyperlink.js": {
63
+ "types": "./plugins/tss-hyperlink.d.ts",
64
+ "default": "./plugins/tss-hyperlink.js"
65
+ },
62
66
  "./plugins/*": "./plugins/*",
63
67
  "./package.json": "./package.json"
64
68
  },
@@ -37,4 +37,11 @@
37
37
  .tss-cal-day.tss-cal-today { box-shadow: inset 0 0 0 1px var(--cal-accent); }
38
38
  .tss-cal-day.tss-cal-focus { box-shadow: inset 0 0 0 2px var(--cal-accent); }
39
39
  .tss-cal-day.tss-cal-sel { background: var(--cal-accent); color: #fff; }
40
+ /* 範囲選択(range:true)。間の日は淡色、両端は濃色。 */
41
+ .tss-cal-day.tss-cal-inrange { background: var(--cal-hover); border-radius: 0; }
42
+ .tss-cal-day.tss-cal-range-start,
43
+ .tss-cal-day.tss-cal-range-end { background: var(--cal-accent); color: #fff; }
44
+ .tss-cal-day.tss-cal-range-start { border-radius: 6px 0 0 6px; }
45
+ .tss-cal-day.tss-cal-range-end { border-radius: 0 6px 6px 0; }
46
+ .tss-cal-day.tss-cal-range-start.tss-cal-range-end { border-radius: 6px; } /* 開始=終端(1日だけ選択中) */
40
47
  .tss-cal-day.tss-cal-disabled { color: var(--cal-muted); background: repeating-linear-gradient(45deg, transparent, transparent 4px, rgba(0,0,0,.04) 4px, rgba(0,0,0,.04) 8px); cursor: not-allowed; }
@@ -28,6 +28,17 @@ declare namespace TssCalendar {
28
28
  disable?: Array<'holiday' | 'weekend' | string>;
29
29
  /** 共有 input に乗る型(既定 true)=直打ち可・矢印は本体へ。false でフォーカス奪取型。 */
30
30
  inline?: boolean;
31
+ /** 範囲選択(開始〜終了を1回の編集で)。`true` で全行有効/`(ctx)=>boolean` で行ごとに切替
32
+ * (例: 半休の行は範囲を選ばせない)。未指定は単日=従来動作。1クリック目=開始・2クリック目=終了
33
+ * (開始より前なら入替・同日2回は単日確定)。確定値・ツールチップは下記で差し替え可。休日スキップは利用側。 */
34
+ range?: boolean | ((ctx: any) => boolean);
35
+ /** 範囲値・ツールチップの日付区切り(既定 '~')。 */
36
+ rangeSeparator?: string;
37
+ /** 範囲の確定値を作る(既定 `'YYYY/MM/DD~YYYY/MM/DD'`/同日は `'YYYY/MM/DD'`)。
38
+ * 引数は開始・終了の ISO('YYYY-MM-DD'・開始<=終了に整列済み)。別フィールド分割などはこの口で。 */
39
+ rangeValue?: (startISO: string, endISO: string) => string;
40
+ /** 編集中セルに出す範囲ツールチップの文面(既定は範囲文字列)。休日を除いた日数などを外から足す口。 */
41
+ rangeTooltip?: (startISO: string, endISO: string) => string;
31
42
  /** シングルクリックで開く(既定 true)。 */
32
43
  openOnClick?: boolean;
33
44
  /** セル右端の目印(既定 '📅'。'' で消す)。 */
@@ -17,6 +17,8 @@
17
17
  function parseISO(s) { var m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(s || '')); return m ? new Date(+m[1], +m[2] - 1, +m[3]) : null; }
18
18
  function sameDay(a, b) { return !!a && !!b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); }
19
19
  function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; }); }
20
+ function fmtSlash(isoStr) { return String(isoStr).replace(/-/g, '/'); } // 'YYYY-MM-DD' → 'YYYY/MM/DD'(range の既定表示)
21
+ function parseLoose(s) { var m = /^(\d{4})[/-](\d{1,2})[/-](\d{1,2})/.exec(String(s || '')); return m ? new Date(+m[1], +m[2] - 1, +m[3]) : null; } // slash/dash どちらも
20
22
 
21
23
  function TssCalendar(opts) {
22
24
  opts = opts || {};
@@ -35,6 +37,22 @@
35
37
  var pop = null, view = null, sel = null, focus = null, ref = null, done = false, outside = null;
36
38
  var inline = opts.inline !== false; // true=共有 input に乗る(直打ち可・矢印は本体に返す)/false=従来のフォーカス奪取型
37
39
 
40
+ // ── 範囲選択(range): 連続した日を1回の編集で選ぶ(休暇申請の開始〜終了 等)───────────
41
+ // range:true で有効(行ごとに切替えたい時は range:(ctx)=>boolean)。未指定は単日=従来動作。
42
+ // 1クリック目=開始、2クリック目=終了(開始より前なら入替・同日2回は単日確定)。間の日に色。
43
+ // 確定値は rangeValue(開始ISO,終了ISO)(既定 'YYYY/MM/DD~YYYY/MM/DD'・単日は 'YYYY/MM/DD')。
44
+ // 入力中はセルに range のツールチップ(rangeTooltip で文面差替=休日除く日数を外から足せる口)。
45
+ // 休日スキップは利用側(返すのは範囲だけ)。
46
+ var rangeOpt = opts.range;
47
+ var rangeSep = opts.rangeSeparator != null ? opts.rangeSeparator : '~';
48
+ var rangeValueFn = typeof opts.rangeValue === 'function' ? opts.rangeValue : null;
49
+ var rangeTooltipFn = typeof opts.rangeTooltip === 'function' ? opts.rangeTooltip : null;
50
+ function defRangeStr(aIso, bIso) { return aIso === bIso ? fmtSlash(aIso) : fmtSlash(aIso) + rangeSep + fmtSlash(bIso); }
51
+ function rangeValOf(aIso, bIso) { return (aIso !== bIso && rangeValueFn) ? rangeValueFn(aIso, bIso) : defRangeStr(aIso, bIso); }
52
+ function rangeTipOf(aIso, bIso) { return rangeTooltipFn ? rangeTooltipFn(aIso, bIso) : defRangeStr(aIso, bIso); }
53
+ var rc = null; // 現在の編集が range なら { td } を持つ(open(ctx) で ctx 判定して設定)
54
+ var rangeStart = null, hoverEnd = null; // 範囲選択の途中状態(1クリック目=rangeStart、プレビュー終端=hoverEnd)
55
+
38
56
  function disabled(d) {
39
57
  if (minD && d < minD) return true;
40
58
  if (maxD && d > maxD) return true;
@@ -71,6 +89,12 @@
71
89
  if (sameDay(d, today)) cls += ' tss-cal-today';
72
90
  if (sameDay(d, sel)) cls += ' tss-cal-sel';
73
91
  if (sameDay(d, focus)) cls += ' tss-cal-focus';
92
+ if (rc && rangeStart) { // 範囲の途中=開始〜(プレビュー)終端を塗る
93
+ var ra = rangeStart, rb = hoverEnd || rangeStart, lo = ra <= rb ? ra : rb, hi = ra <= rb ? rb : ra;
94
+ if (d >= lo && d <= hi) cls += ' tss-cal-inrange';
95
+ if (sameDay(d, ra)) cls += ' tss-cal-range-start';
96
+ if (sameDay(d, rb)) cls += ' tss-cal-range-end';
97
+ }
74
98
  if (disabled(d)) cls += ' tss-cal-disabled';
75
99
  html += '<div class="' + cls + '" data-d="' + k + '"' + (nm ? ' title="' + esc(nm) + '"' : '') + '>' + d.getDate() + '</div>';
76
100
  d.setDate(d.getDate() + 1);
@@ -102,20 +126,40 @@
102
126
  if (k === 'Escape') { e.preventDefault(); cancel(); return; }
103
127
  if (k === 'Enter') { e.preventDefault(); if (!disabled(focus)) pick(focus); return; }
104
128
  var dx = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 }[k];
105
- if (dx != null) { e.preventDefault(); focus = new Date(focus); focus.setDate(focus.getDate() + dx); view = new Date(focus.getFullYear(), focus.getMonth(), 1); render(); return; }
129
+ if (dx != null) { e.preventDefault(); focus = new Date(focus); focus.setDate(focus.getDate() + dx); view = new Date(focus.getFullYear(), focus.getMonth(), 1); if (rc && rangeStart) { hoverEnd = new Date(focus); updateTip(); } render(); return; }
106
130
  if (k === 'PageUp' || k === 'PageDown') { e.preventDefault(); view = new Date(view.getFullYear(), view.getMonth() + (k === 'PageUp' ? -1 : 1), 1); render(); }
107
131
  }
108
- function show(anchor, value, onPick, onCancel) {
132
+ // range 途中: 日の上をなぞると終端プレビューを更新(間の色+ツールチップ)。
133
+ function onOver(e) {
134
+ if (!(rc && rangeStart)) return;
135
+ var dd = e.target.getAttribute && e.target.getAttribute('data-d');
136
+ if (!dd) return;
137
+ var d = parseISO(dd); if (!d || disabled(d)) return;
138
+ if (!hoverEnd || !sameDay(hoverEnd, d)) { hoverEnd = d; render(); updateTip(); }
139
+ }
140
+ // 編集中セルに範囲ツールチップを出す(rangeTooltip で文面差替可)。
141
+ function updateTip() {
142
+ if (!(rc && rc.td)) return;
143
+ var aIso = iso(rangeStart), bIso = iso(hoverEnd || rangeStart);
144
+ if (bIso < aIso) { var t = aIso; aIso = bIso; bIso = t; }
145
+ try { rc.td.title = rangeTipOf(aIso, bIso); } catch (e) { /* noop */ }
146
+ }
147
+ function clearTip() { if (rc && rc.td) { try { rc.td.title = ''; } catch (e) { /* noop */ } } }
148
+ function show(anchor, value, onPick, onCancel, rangeCfg) {
109
149
  teardown(); // 既に開いていれば先に片付ける(前の pop 除去+outside リスナ解除・open-while-open の取り残し防止)
110
- done = false; sel = parseISO(value);
111
- view = sel ? new Date(sel.getFullYear(), sel.getMonth(), 1) : new Date();
112
- focus = sel ? new Date(sel) : new Date();
113
- pop = document.createElement('div'); pop.className = 'tss-cal' + (opts.className ? ' ' + opts.className : '');
150
+ done = false; rc = rangeCfg || null; rangeStart = null; hoverEnd = null;
151
+ sel = parseISO(value);
152
+ // range の既存値('YYYY/MM/DD~…' 等)は parseISO では取れない=先頭日を緩く読んで月を合わせる。
153
+ var initD = sel || (rc ? parseLoose(value) : null);
154
+ view = initD ? new Date(initD.getFullYear(), initD.getMonth(), 1) : new Date();
155
+ focus = initD ? new Date(initD) : new Date();
156
+ pop = document.createElement('div'); pop.className = 'tss-cal' + (opts.className ? ' ' + opts.className : '') + (rc ? ' tss-cal-range' : '');
114
157
  pop.tabIndex = -1; document.body.appendChild(pop);
115
158
  pop.addEventListener('mousedown', function (e) { if (e.target.tagName !== 'SELECT') e.preventDefault(); }); // フォーカスを奪わない(select は除く)
116
159
  pop.addEventListener('click', onClick);
117
160
  pop.addEventListener('change', onChange);
118
161
  pop.addEventListener('keydown', onKey);
162
+ if (rc) pop.addEventListener('mouseover', onOver);
119
163
  render(); place(anchor);
120
164
  ref = { pick: onPick, cancel: onCancel };
121
165
  // inline は「外側クリック/フォーカス」を本体(_commitActive / blur)が持つ=二重に閉じない・共有 input から
@@ -126,13 +170,26 @@
126
170
  setTimeout(function () { document.addEventListener('mousedown', outside, true); }, 0);
127
171
  pop.focus();
128
172
  }
129
- function teardown() { if (outside) { document.removeEventListener('mousedown', outside, true); outside = null; } if (pop) { pop.remove(); pop = null; } }
130
- function pick(d) { if (done) return; done = true; var f = ref.pick; teardown(); f(iso(d)); }
131
- function cancel() { if (done) return; done = true; var f = ref.cancel; teardown(); if (f) f(); }
173
+ function teardown() { clearTip(); if (outside) { document.removeEventListener('mousedown', outside, true); outside = null; } if (pop) { pop.remove(); pop = null; } }
174
+ function pick(d) {
175
+ if (done) return;
176
+ if (rc) {
177
+ if (!rangeStart) { rangeStart = d; hoverEnd = d; focus = new Date(d); render(); updateTip(); return; } // 1クリック目=開始(閉じない)
178
+ var aIso = iso(rangeStart), bIso = iso(d); // 2クリック目=終了
179
+ if (bIso < aIso) { var t = aIso; aIso = bIso; bIso = t; } // 逆順は入替/同日は単日
180
+ done = true; var fr = ref.pick; teardown(); fr(rangeValOf(aIso, bIso)); return;
181
+ }
182
+ done = true; var f = ref.pick; teardown(); f(iso(d));
183
+ }
184
+ function cancel() { if (done) return; done = true; var f = ref.cancel; teardown(); if (f) f(); } // range 途中の取消も値は変えない(ref.cancel)
132
185
 
133
186
  return {
134
187
  // TssGrid カスタムエディタ契約
135
- open: function (ctx) { show(ctx.td, ctx.value, function (v) { ctx.commit(v); }, function () { ctx.cancel(); }); },
188
+ open: function (ctx) {
189
+ var rangeMode = typeof rangeOpt === 'function' ? !!rangeOpt(ctx) : !!rangeOpt; // 行ごとに range を切替(半休行は単日)
190
+ var cfg = rangeMode ? { td: ctx.td } : null;
191
+ show(ctx.td, ctx.value, function (v) { ctx.commit(v); }, function () { ctx.cancel(); }, cfg);
192
+ },
136
193
  close: function () { teardown(); },
137
194
  icon: opts.icon != null ? opts.icon : '📅', // セル右端に表示する合図(opts.icon:'' で消せる)
138
195
  openOnClick: opts.openOnClick !== false, // シングルクリックで開く(既定 ON)
@@ -13,3 +13,17 @@
13
13
  .tg-cascade .tg-casc-item.active { background: var(--tg-accent, #1573c0); color: #fff; }
14
14
  .tg-cascade .tg-casc-label { flex: 1; }
15
15
  .tg-cascade .tg-casc-arrow { font-size: 10px; opacity: .7; margin-left: 6px; }
16
+ .tg-cascade .tg-casc-other { font-style: italic; opacity: .95; }
17
+ /* 「その他」モードの入力欄(codeField モードで自由入力) */
18
+ .tg-cascade.tg-casc-inputpop { overflow: visible; }
19
+ .tg-cascade .tg-casc-inputwrap { padding: 4px; min-width: 180px; }
20
+ .tg-cascade .tg-casc-input {
21
+ width: 100%; box-sizing: border-box; padding: 5px 8px; font: inherit;
22
+ border: 1px solid #c4ccd6; border-radius: 5px; outline: none;
23
+ }
24
+ .tg-cascade .tg-casc-input:focus { border-color: var(--tg-accent, #1573c0); }
25
+ .tg-cascade .tg-casc-actions { display: flex; gap: 6px; margin-top: 6px; justify-content: flex-end; }
26
+ .tg-cascade .tg-casc-actions button {
27
+ padding: 3px 12px; font: inherit; cursor: pointer; border-radius: 5px; border: 1px solid #c4ccd6; background: #f5f7fa;
28
+ }
29
+ .tg-cascade .tg-casc-actions .tg-casc-ok { background: var(--tg-accent, #1573c0); color: #fff; border-color: transparent; }
@@ -7,12 +7,22 @@ export as namespace TssCascade;
7
7
  declare function TssCascade(opts: TssCascade.Options): EditorDef;
8
8
 
9
9
  declare namespace TssCascade {
10
- /** ノード。枝は `children`、葉は `value`(未指定なら `label` を保存)。 */
11
- interface Node { label: string; value?: string | number; children?: Node[]; }
10
+ /** ノード。枝は `children`、葉は `value`(未指定なら `label` を保存)。
11
+ * `other:true` の葉は「その他」=選ぶと入力欄に切替わり、打った文字を値に確定する。
12
+ * その時 codeField へ書くコードは、その「その他」ノード自身の `value`(枝ごとに変えられる)。 */
13
+ interface Node { label: string; value?: string | number; children?: Node[]; other?: boolean; }
12
14
 
13
15
  interface Options {
14
16
  /** 入れ子の選択肢。 */
15
17
  options: Node[];
18
+ /** コードの書き戻し先の列(data キー or 列 index)。
19
+ * 指定時: 値セル=普通の葉はパス「大 / 中 / 小」・その他は打った文字/codeField=葉(その他ノード)の value。
20
+ * 省略時: 葉の value を値セルに保存(従来・表示は pathFormat)。 */
21
+ codeField?: string | number;
22
+ /** 値セルに入れるパスの区切り(既定 ' / ')。pathFormat の separator と揃えると良い。 */
23
+ separator?: string;
24
+ /** 「その他」入力欄のプレースホルダ(既定 '自由入力')。 */
25
+ otherPlaceholder?: string;
16
26
  /** シングルクリックで開く(既定 true)。 */
17
27
  openOnClick?: boolean;
18
28
  /** ポップアップに付ける任意クラス。 */
@@ -1,33 +1,54 @@
1
1
  /*! TssCascade — 多段(フライアウト)プルダウン。TssGrid カスタムエディタ(ポップアップ型)。
2
2
  *
3
3
  * メニューのように「大分類 ▸ 中分類 ▸ 小分類」とフライアウトで開き、葉を選ぶとセルに確定。
4
- * 保存はその葉の value(未指定なら label)。表示は TssCascade.pathFormat で「大 / 中 / 小」のパスにできる。
4
+ * 葉に `other:true` を付けると、その葉は「その他」=選ぶと入力欄に切替わり、打った文字をセルに確定。
5
5
  *
6
- * 使い方:
6
+ * ── 2つの保存モード ───────────────────────────────────────────────
7
+ * (1) codeField 無し(従来):
8
+ * 葉の value(未指定なら label)を**値セルに**保存。表示は TssCascade.pathFormat でパスに。
9
+ * 「その他」を選んだら、打った文字をそのまま値セルに保存。
10
+ * (2) codeField あり(コードと表示を分離・combo と同じ流儀):
11
+ * ・値セル … 普通の葉=パス「有給 / 私用」/その他=打った文字「通院」
12
+ * ・codeField セル … 普通の葉=その葉の value/その他=その「その他」ノードの value
13
+ * =「その他」が枝ごとにあっても(有給▸その他/午前休▸その他)、codeField を見れば
14
+ * どの枝の「その他」かが分かる(文字だけで親が消えない)。値は人が読む表示に使う。
15
+ *
16
+ * 使い方(勤怠の区分・枝ごとの「その他」コード):
7
17
  * const OPTS = [
8
- * { label:'営業', children:[
9
- * { label:'国内', children:[ {label:'東日本', value:'sales.jp.east'}, {label:'西日本', value:'sales.jp.west'} ] },
10
- * { label:'海外', value:'sales.global' },
18
+ * { label:'有給', children:[
19
+ * { label:'私用', value:'paid:personal' },
20
+ * { label:'その他', other:true, value:'paid:other' }, // ← この枝の「その他」コード
21
+ * ] },
22
+ * { label:'午前休', children:[
23
+ * { label:'私用', value:'am_half:personal' },
24
+ * { label:'その他', other:true, value:'am_half:other' },
11
25
  * ] },
12
- * { label:'開発', children:[ … ] },
13
26
  * ];
14
27
  * columns: [
15
- * { data:'category', editor: TssCascade({ options: OPTS }),
16
- * format: TssCascade.pathFormat(OPTS) }, // 表示を「営業 / 国内 / 東日本」に(任意)
28
+ * { data:'categoryCode' }, // コード列(paid:other 等・hidden:true でも可)
29
+ * { data:'leaveReason', editor: TssCascade({
30
+ * options: OPTS,
31
+ * codeField: 'categoryCode', // コードの書き戻し先(data キー or 列 index)
32
+ * }) },
17
33
  * ]
34
+ * // 「有給 ▸ その他」で「通院」と入力 → leaveReason='通院' / categoryCode='paid:other'
18
35
  *
19
36
  * opts:
20
- * options : 入れ子配列 [{ label, value?, children? }]。必須。value 未指定の葉は label を保存。
37
+ * options : 入れ子配列 [{ label, value?, children?, other? }]。必須。value 未指定の葉は label を保存。
38
+ * codeField : コードの書き戻し先の列(data キー or index)。指定時はモード(2)。省略でモード(1)。
39
+ * separator : 値セルに入れるパスの区切り(既定 ' / ')。pathFormat と合わせると良い。
40
+ * otherPlaceholder : 「その他」入力欄のプレースホルダ(既定 '自由入力')。
21
41
  * openOnClick : 既定 true(プルダウン同様シングルクリックで開く)。
22
42
  * className : ポップアップに付ける任意クラス。
23
43
  * 静的:
24
- * TssCascade.pathFormat(options, { separator }) => (value)=>string // 列 format 用
44
+ * TssCascade.pathFormat(options, { separator }) => (value)=>string // 列 format 用(モード1の表示)
25
45
  * TssCascade.pathOf(options, value, sep?) => string
26
46
  */
27
47
  (function (root) {
28
48
  'use strict';
29
49
  function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; }); }
30
50
  function leafVal(n) { return n.value !== undefined ? n.value : n.label; }
51
+ function isBranch(n) { return !n.other && n.children && n.children.length; }
31
52
 
32
53
  // 値 → ラベルのパス配列([大, 中, 小])。見つからなければ null。
33
54
  function findPath(nodes, value, trail) {
@@ -43,20 +64,46 @@
43
64
  function TssCascade(opts) {
44
65
  opts = opts || {};
45
66
  var roots = opts.options || [];
67
+ var codeField = opts.codeField;
68
+ var sep = opts.separator || ' / ';
69
+ var placeholder = opts.otherPlaceholder != null ? opts.otherPlaceholder : '自由入力';
70
+
46
71
  var panels = []; // 開いているパネル(div)。panels[0]=root, [1]=sub, …
47
- var ctxRef = null, done = false, onDoc = null, onKey = null;
72
+ var chosen = []; // chosen[d] = 深さ d で降りた枝ノード(葉のパス復元に使う)
73
+ var ctxRef = null, done = false, onDoc = null, onKey = null, composing = false;
48
74
 
49
75
  function teardown() {
50
76
  panels.forEach(function (p) { if (p.parentNode) p.parentNode.removeChild(p); });
51
- panels = [];
77
+ panels = []; chosen = [];
52
78
  if (onDoc) document.removeEventListener('mousedown', onDoc, true);
53
79
  if (onKey) document.removeEventListener('keydown', onKey, true);
54
- onDoc = onKey = null;
80
+ onDoc = onKey = null; composing = false;
55
81
  }
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)); }
82
+ function closeFrom(depth) { while (panels.length > depth) { var p = panels.pop(); if (p.parentNode) p.parentNode.removeChild(p); } if (chosen.length > depth) chosen.length = depth; }
58
83
  function doCancel() { if (done) return; done = true; var c = ctxRef; teardown(); c.cancel(); }
59
84
 
85
+ // 書き戻し: 値セルは commit(履歴に積む)、codeField は別列へ setValueRaw(readOnly 貫通・独立undoを作らない)。
86
+ function writeCode(code) {
87
+ var c = ctxRef;
88
+ if (codeField != null && c.grid && c.r != null) { try { c.grid.setValueRaw(c.r, codeField, code); } catch (e) {} }
89
+ }
90
+ // 普通の葉を確定。モード(2)は値=パス/code=葉value、モード(1)は値=葉value(従来)。
91
+ function commitLeaf(n, depth) {
92
+ if (done) return; done = true;
93
+ var c = ctxRef;
94
+ var path = chosen.slice(0, depth).map(function (x) { return x.label; }).concat(n.label).join(sep);
95
+ teardown();
96
+ if (codeField != null) { c.commit(path); writeCode(leafVal(n)); }
97
+ else c.commit(leafVal(n));
98
+ }
99
+ // 「その他」を確定。値=打った文字/code=その他ノードの value(枝ごとに違うコード)。
100
+ function commitOther(n, text) {
101
+ if (done) return; done = true;
102
+ var c = ctxRef; teardown();
103
+ c.commit(text);
104
+ if (codeField != null) writeCode(leafVal(n));
105
+ }
106
+
60
107
  function position(pop, anchor, depth) {
61
108
  var pw = pop.offsetWidth, ph = pop.offsetHeight, vw = window.innerWidth, vh = window.innerHeight, left, top;
62
109
  if (depth === 0) { left = anchor.left; top = anchor.bottom + 2; }
@@ -66,15 +113,43 @@
66
113
  pop.style.left = Math.max(6, left) + 'px'; pop.style.top = top + 'px';
67
114
  }
68
115
 
116
+ // 「その他」の入力パネル(submenu 相当・panels[depth+1] に積む=外側クリックで閉じない)。
117
+ function openInput(n, depth, anchor) {
118
+ closeFrom(depth + 1);
119
+ var pop = document.createElement('div');
120
+ pop.className = 'tg-cascade tg-casc-inputpop' + (opts.className ? ' ' + opts.className : '');
121
+ pop._isInput = true; pop._depth = depth + 1;
122
+ pop.innerHTML = '<div class="tg-casc-inputwrap">' +
123
+ '<input class="tg-casc-input" type="text" placeholder="' + esc(placeholder) + '">' +
124
+ '<div class="tg-casc-actions"><button type="button" class="tg-casc-ok">確定</button>' +
125
+ '<button type="button" class="tg-casc-back">戻る</button></div></div>';
126
+ document.body.appendChild(pop);
127
+ panels[depth + 1] = pop;
128
+ position(pop, anchor, depth + 1);
129
+
130
+ var input = pop.querySelector('.tg-casc-input');
131
+ function back() { closeFrom(depth + 1); var pp = panels[depth]; if (pp && pp._active != null && pp._items[pp._active]) pp._items[pp._active].focus(); }
132
+ input.addEventListener('compositionstart', function () { composing = true; });
133
+ input.addEventListener('compositionend', function () { composing = false; });
134
+ input.addEventListener('keydown', function (e) {
135
+ if (e.key === 'Enter') { if (composing || e.isComposing || e.keyCode === 229) return; e.preventDefault(); commitOther(n, input.value); }
136
+ else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); back(); }
137
+ });
138
+ pop.querySelector('.tg-casc-ok').addEventListener('mousedown', function (e) { e.preventDefault(); commitOther(n, input.value); });
139
+ pop.querySelector('.tg-casc-back').addEventListener('mousedown', function (e) { e.preventDefault(); back(); });
140
+ setTimeout(function () { input.focus(); input.setSelectionRange(input.value.length, input.value.length); }, 0);
141
+ return pop;
142
+ }
143
+
69
144
  function openPanel(nodes, depth, anchor) {
70
145
  closeFrom(depth);
71
146
  var pop = document.createElement('div');
72
147
  pop.className = 'tg-cascade' + (opts.className ? ' ' + opts.className : '');
73
148
  pop.setAttribute('role', 'menu');
74
149
  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>';
150
+ var branch = isBranch(n), other = !!n.other;
151
+ return '<div class="tg-casc-item' + (branch ? ' branch' : '') + (other ? ' tg-casc-other' : '') + '" data-i="' + i + '" role="menuitem" tabindex="-1">' +
152
+ '<span class="tg-casc-label">' + esc(n.label) + (other ? '…' : '') + '</span>' + (branch ? '<span class="tg-casc-arrow">▸</span>' : '') + '</div>';
78
153
  }).join('');
79
154
  document.body.appendChild(pop);
80
155
  panels[depth] = pop;
@@ -83,16 +158,19 @@
83
158
  var items = pop.querySelectorAll('.tg-casc-item');
84
159
  pop._items = items; pop._active = null; pop._depth = depth;
85
160
  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 を開く(ホバー/→)。葉なら深いパネルを閉じる。
161
+ pop._enter = function (idx, viaKey) { // ホバー/→: 枝なら submenu を開く。葉/その他はそれ以上開かない。
87
162
  var n = nodes[idx]; if (!n) return; pop._activate(idx);
88
- if (n.children && n.children.length) {
163
+ if (isBranch(n)) {
164
+ chosen[depth] = n;
89
165
  openPanel(n.children, depth + 1, items[idx].getBoundingClientRect());
90
166
  if (viaKey) { var sub = panels[depth + 1], f = sub._items[0]; if (f) { sub._activate(0); f.focus(); } }
91
167
  } else closeFrom(depth + 1);
92
168
  };
93
- pop._choose = function (idx) { // クリック/Enter:分岐→開く、葉→確定
169
+ pop._choose = function (idx) { // クリック/Enter: 枝→開く/その他→入力へ/葉→確定
94
170
  var n = nodes[idx]; if (!n) return;
95
- if (n.children && n.children.length) pop._enter(idx, true); else commitLeaf(n);
171
+ if (n.other) { pop._activate(idx); openInput(n, depth, items[idx].getBoundingClientRect()); }
172
+ else if (isBranch(n)) pop._enter(idx, true);
173
+ else commitLeaf(n, depth);
96
174
  };
97
175
  Array.prototype.forEach.call(items, function (el) {
98
176
  var idx = +el.dataset.i;
@@ -104,6 +182,7 @@
104
182
 
105
183
  function handleKey(e) {
106
184
  var pop = panels[panels.length - 1]; if (!pop) return;
185
+ if (pop._isInput) return; // 入力モードのキーは input 側で処理
107
186
  var items = pop._items, idx = pop._active == null ? -1 : pop._active, n;
108
187
  if (e.key === 'ArrowDown') { e.preventDefault(); n = (idx + 1) % items.length; pop._activate(n); items[n].focus(); }
109
188
  else if (e.key === 'ArrowUp') { e.preventDefault(); n = (idx - 1 + items.length) % items.length; pop._activate(n); items[n].focus(); }
@@ -0,0 +1,3 @@
1
+ /* TssHyperlink — セル内リンク(plugins/tss-hyperlink.js)。色は --tg-accent を流用。外部CSSで上書き可。 */
2
+ .tssgrid .tg-table td .tss-link { color: var(--tg-accent, #1573c0); text-decoration: underline; cursor: pointer; }
3
+ .tssgrid .tg-table td .tss-link:hover { text-decoration: none; }
@@ -0,0 +1,19 @@
1
+ // TssHyperlink — セル内ハイパーリンク(format ヘルパ)。columns[c].html:true + format に渡す。
2
+ export = TssHyperlink;
3
+ export as namespace TssHyperlink;
4
+
5
+ /** 値 "表示テキスト|URL"(または URL 単体)を安全なリンク HTML に変換する format 関数を返す。 */
6
+ declare function TssHyperlink(opts?: TssHyperlink.Options): (value: any) => string;
7
+
8
+ declare namespace TssHyperlink {
9
+ interface Options {
10
+ /** 表示テキストと URL の区切り(既定 '|')。 */
11
+ separator?: string;
12
+ /** リンクの target(既定 '_blank')。 */
13
+ target?: string;
14
+ /** <a> に付ける任意クラス(既定 'tss-link')。 */
15
+ className?: string;
16
+ }
17
+ /** URL を安全化(http/https/mailto/tel のみ許可・スキーム無しは https 補完・許可外は null)。 */
18
+ function safeUrl(u: any): string | null;
19
+ }
@@ -0,0 +1,62 @@
1
+ /*! TssHyperlink — セル内ハイパーリンク(format ヘルパ)。SVG/HTML 自前描画・無依存・コア無改変。
2
+ *
3
+ * セルの値を「クリックで開くリンク」として描く。値の形式は "表示テキスト|URL"(区切りは separator)、
4
+ * または URL 単体(その場合は URL を表示テキストにも使う)。`columns[c].html:true` + `format` の seam に乗る。
5
+ *
6
+ * ★セキュリティ: URL は利用者データになり得る=XSS を最初から潰す。
7
+ * - プロトコルは http/https/mailto/tel のみ許可(javascript: data: vbscript: 等は拒否=ただのテキスト表示に落とす)。
8
+ * - スキーム無し(example.com/path)は https:// を補完。表示テキスト・href は必ず HTML エスケープ(" ' も)。
9
+ * - target=_blank には rel="noopener noreferrer" を付与。
10
+ *
11
+ * 使い方:
12
+ * columns: [
13
+ * { data:'site', title:'リンク', html:true, readOnly:true, format: TssHyperlink() },
14
+ * ]
15
+ * // 値: "GitHub|https://github.com" / "https://example.com" / "example.com" / "問合せ|mailto:a@b.jp"
16
+ *
17
+ * opts:
18
+ * separator : 表示テキストと URL の区切り(既定 '|')
19
+ * target : リンクの target(既定 '_blank'。'_self' 等も可)
20
+ * className : <a> に付ける任意クラス(既定 'tss-link')
21
+ */
22
+ (function (root) {
23
+ 'use strict';
24
+ function esc(s) {
25
+ return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
26
+ return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
27
+ });
28
+ }
29
+ // 許可: http/https/mailto/tel。スキーム無しで安全な文字だけなら https:// 補完。それ以外(javascript: data: 等)は null。
30
+ function safeUrl(u) {
31
+ u = String(u == null ? '' : u).trim();
32
+ if (u === '') return null;
33
+ if (/^(https?:|mailto:|tel:)/i.test(u)) return u; // whitelist したスキーム
34
+ if (!/[:]/.test(u) && /^[\w./?#=&%+\-~@!$*,;()]+$/.test(u)) return 'https://' + u; // スキーム無し=ドメイン/パス→https 補完
35
+ return null; // javascript: data: vbscript: 等=拒否
36
+ }
37
+
38
+ function TssHyperlink(opts) {
39
+ opts = opts || {};
40
+ var sep = opts.separator != null ? opts.separator : '|';
41
+ var target = opts.target || '_blank';
42
+ var cls = opts.className != null ? opts.className : 'tss-link';
43
+ return function (value) {
44
+ var v = String(value == null ? '' : value);
45
+ if (v === '') return '';
46
+ var i = v.indexOf(sep);
47
+ var text = i >= 0 ? v.slice(0, i) : v;
48
+ var url = i >= 0 ? v.slice(i + 1) : v;
49
+ var safe = safeUrl(url);
50
+ var label = esc((text.trim() || url.trim()));
51
+ if (!safe) return label; // 不正/未許可 URL=リンクにせずテキスト表示(=安全)
52
+ var rel = (target === '_blank') ? ' rel="noopener noreferrer"' : '';
53
+ return '<a href="' + esc(safe) + '" target="' + esc(target) + '"' + rel +
54
+ (cls ? ' class="' + esc(cls) + '"' : '') + '>' + label + '</a>';
55
+ };
56
+ }
57
+ // URL の安全化だけ外から使いたい時用(カスタム format 内など)。許可外は null。
58
+ TssHyperlink.safeUrl = safeUrl;
59
+
60
+ if (typeof module !== 'undefined' && module.exports) module.exports = TssHyperlink;
61
+ else root.TssHyperlink = TssHyperlink;
62
+ })(typeof self !== 'undefined' ? self : this);