@threestarsoftware/tssgrid 0.1.27 → 0.1.28

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,15 @@
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.28] - 2026-10-20
7
+
8
+ ### 追加(プラグイン)
9
+ - **`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`(攻撃例含む)。
10
+ - **`tss-cascade` に「その他」自由入力を追加** — 多段(フライアウト)プルダウンの葉に `other:true` を付けると、選んだとき**入力欄に切替**=自由入力できる(IME 合成ガード・確定/戻る付き)。**`codeField` 指定時**は、値セル=普通の葉は「大 / 中 / 小」のパス・その他は打った文字/`codeField` セル=その葉(その他ノード)の `value`。**「その他」が枝ごとにあっても(有給▸その他/午前休▸その他)コードで親が区別できる**(勤怠区分など)。`codeField` 省略時は従来どおり葉の `value` を値セルに保存=**後方互換**。`separator`/`otherPlaceholder` オプション追加。例 `examples/cascade-other.html`。
11
+
12
+ ### ドキュメント
13
+ - **sparkline の `fill` オプションを明記**(`fill` 指定=面グラフ/無し=折れ線)。新規実装ではなく既存オプションの周知(`examples/sparkline.html` は既に使用)。
14
+
6
15
  ## [0.1.27] - 2026-10-14
7
16
 
8
17
  ### セキュリティ修正
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.28",
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
  },
@@ -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);