@threestarsoftware/tssgrid 0.0.3 → 0.0.5

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/README.md CHANGED
@@ -238,7 +238,8 @@ grid.moveRows(r0, r1, to); // 連続する複数行をま
238
238
 
239
239
  ### 非破壊フィルタ(`filter` / `clearFilter`)
240
240
 
241
- **コア内蔵**の非破壊フィルタ(行インデックスマッパ)。全行は内部の `_allRows`(マスタ)に保持したまま、**可視部分集合だけ描画**します。**10万行でも絞り込んだ数百行だけ描く**ので、仮想スクロール無しで軽い → 動く例: [`examples/filter-live.html`](https://tssgrid.threestarsoftware.co.jp/examples/filter-live.html)。
241
+ **コア内蔵**の非破壊フィルタ(行インデックスマッパ)。全行は内部の `_allRows`(マスタ)に保持したまま、**絞り込んだ可視部分集合だけを描画**します。要点は**絞り込み後の描画・ライブ検索が軽い**こと(自社調べ:10万行を保持して約200行に絞り込み → 初回描画 ≈ 0.5 秒、絞り込み済みのライブ検索 ≈ 数 ms)→ 動く例: [`examples/filter-live.html`](https://tssgrid.threestarsoftware.co.jp/examples/filter-live.html)。
242
+ なお**全件をそのままスクロール表示**したい大量行は、仮想スクロール `virtual: true` を使います(下記)。
242
243
 
243
244
  ```js
244
245
  grid.filter(row => row[2] >= 100); // 述語: (row, masterIndex, src) => bool
@@ -254,9 +255,21 @@ grid.getAllRows(); // マスタ全行(フィルタ中で
254
255
  - 制限(v1): フィルタ中は **列の挿入削除・行移動・セル結合**はロック(解除してから)。`filter`/`clearFilter` は**Undo履歴をクリア**(ビューindex基準の履歴が境界を跨ぐと不整合になるため)。
255
256
  - **フィルタUI**(ヘッダの絞り込み等)はコアに入れず**プラグイン** `tss-filter`(下記)で。素朴な `setData` 差し替え方式の旧サンプルは [`examples/filter.html`](https://tssgrid.threestarsoftware.co.jp/examples/filter.html)。
256
257
 
258
+ ### 仮想スクロール(`virtual: true`)
259
+
260
+ 大量行を**全件そのままスクロール表示**したい時に。全行は `data` に保持したまま、**画面に見える窓ぶん+バッファだけを描画**します(固定行高ウィンドウイング)。全件を一度に DOM 化しないので**初期描画が一瞬・スクロールも軽い**。有効化は**オプション一行**だけ。
261
+
262
+ ```js
263
+ new TssGrid(el, { virtual: true, data, /* … */ });
264
+ new TssGrid(el, { virtual: { buffer: 10 }, data }); // 窓の上下バッファ行数(既定6)
265
+ ```
266
+ - **自社調べ**(headless Chrome / Win11): 10 万行で**初回描画 ≈ 46 ms**(全件描画 ≈ 10.5 秒に対して)。スクロール 1 コマの窓差し替え ≈ 3 ms。動く例: [`examples/virtual.html`](https://tssgrid.threestarsoftware.co.jp/examples/virtual.html)。
267
+ - 制約(v1): **行高一定**/**固定行列・折り返し・セル結合は非対応**(指定時は自動で無効化+警告)。行番号の桁数が増える場合は `rowHeaderWidth` で行ヘッダ幅を広げられます。
268
+ - フィルタとの関係: `filter()` は「絞り込んだ部分集合だけ描く」軽さ、`virtual` は「絞らず全件をスクロールする」軽さ。用途で使い分け(v1 では併用は未対応)。
269
+
257
270
  ### 同梱プラグイン: ヘッダ オートフィルタ(filter UI)
258
271
 
259
- `plugins/tss-filter.js`+`.css`= 各見出しの**ロート(漏斗)アイコン**をクリックで**値チェックリスト+検索**のポップアップ(Excel風。ソートの▲▼と区別できるアイコン)。複数列の条件は **AND**。中身は上の**コア `filter()` を呼ぶだけ**。動く例: [`examples/filter-ui.html`](https://tssgrid.threestarsoftware.co.jp/examples/filter-ui.html)。
272
+ `plugins/tss-filter.js`+`.css`<!--DEV-->(**無依存・コア無改変**)<!--/DEV-->= 各見出しの**ロート(漏斗)アイコン**をクリックで**値チェックリスト+検索**のポップアップ(Excel風。ソートの▲▼と区別できるアイコン)。複数列の条件は **AND**。中身は上の**コア `filter()` を呼ぶだけ**。動く例: [`examples/filter-ui.html`](https://tssgrid.threestarsoftware.co.jp/examples/filter-ui.html)。
260
273
 
261
274
  ```html
262
275
  <link rel="stylesheet" href="plugins/tss-filter.css">
@@ -271,7 +284,7 @@ grid.getPlugin('filter').clearAll(); // 全条件クリア
271
284
 
272
285
  ### 同梱プラグイン: 共有マスタ(複数グリッドで1データ)
273
286
 
274
- `plugins/tss-shared.js`= **1つのマスタ(同じ行データ)を複数グリッドで共有**。左右を**別フィルタ/別ソート**で表示でき、**片方の編集がもう片方の同じ行へ即反映**(行参照を共有)。A系/B系を並べて突き合わせ編集する用途。動く例: [`examples/shared-master.html`](https://tssgrid.threestarsoftware.co.jp/examples/shared-master.html)。
287
+ `plugins/tss-shared.js`<!--DEV-->(**無依存・コア無改変**)<!--/DEV-->= **1つのマスタ(同じ行データ)を複数グリッドで共有**。左右を**別フィルタ/別ソート**で表示でき、**片方の編集がもう片方の同じ行へ即反映**(行参照を共有)。A系/B系を並べて突き合わせ編集する用途。動く例: [`examples/shared-master.html`](https://tssgrid.threestarsoftware.co.jp/examples/shared-master.html)。
275
288
 
276
289
  ```html
277
290
  <script src="plugins/tss-shared.js"></script>
@@ -322,7 +335,7 @@ TssGrid.registerPlugin('autosave', autoSavePlugin); // 名前登録 → plugin
322
335
 
323
336
  ## カスタムエディタ
324
337
 
325
- 組込エディタ(text/dropdown/checkbox/date/time/number)以外に、**独自の編集UI**を列に差せます。`columns[c].editor` に**薄いオブジェクト契約**を渡すだけ。
338
+ 組込エディタ(text/dropdown/checkbox/date/time/number)以外に、**独自の編集UI**を列に差せます。`columns[c].editor` に**薄いオブジェクト契約**を渡すだけ<!--DEV-->(無依存)<!--/DEV-->。
326
339
 
327
340
  ```js
328
341
  const myEditor = {
@@ -349,7 +362,7 @@ new TssGrid(el, { columns: [{ editor: myEditor }] });
349
362
 
350
363
  ### 同梱プラグイン: 休日カレンダー対応の日付ピッカー
351
364
 
352
- `plugins/tss-calendar.js`= ネイティブ `<input type=date>` では出来ない**会社の休日カレンダー(JSON)対応**の日付ピッカー。勤怠・シフト・予約などで効きます(動く例: [`examples/calendar.html`](https://tssgrid.threestarsoftware.co.jp/examples/calendar.html))。
365
+ `plugins/tss-calendar.js`<!--DEV-->(**無依存**)<!--/DEV-->= ネイティブ `<input type=date>` では出来ない**会社の休日カレンダー(JSON)対応**の日付ピッカー。勤怠・シフト・予約などで効きます(動く例: [`examples/calendar.html`](https://tssgrid.threestarsoftware.co.jp/examples/calendar.html))。
353
366
 
354
367
  ```html
355
368
  <link rel="stylesheet" href="plugins/tss-calendar.css">
@@ -370,7 +383,7 @@ new TssGrid(el, { columns: [{ type: 'date', editor: cal, format: 'yyyy/mm/dd' }]
370
383
 
371
384
  ### 同梱プラグイン: 時刻ピッカー(時/分プルダウン)
372
385
 
373
- `plugins/tss-time.js`= 業務の時刻入力向けの**時/分プルダウン**ピッカー(クロックフェイスのようなリッチさは省き、任意時刻を素早く)。保存値は常に **24h `'HH:MM'`**(組込 `type:'time'` と互換)。動く例: [`examples/time.html`](https://tssgrid.threestarsoftware.co.jp/examples/time.html)。
386
+ `plugins/tss-time.js`<!--DEV-->(**無依存**)<!--/DEV-->= 業務の時刻入力向けの**時/分プルダウン**ピッカー(クロックフェイスのようなリッチさは省き、任意時刻を素早く)。保存値は常に **24h `'HH:MM'`**(組込 `type:'time'` と互換)。動く例: [`examples/time.html`](https://tssgrid.threestarsoftware.co.jp/examples/time.html)。
374
387
 
375
388
  ```html
376
389
  <link rel="stylesheet" href="plugins/tss-time.css">
@@ -385,7 +398,7 @@ new TssGrid(el, { columns: [{ type: 'time', editor: t }] });
385
398
 
386
399
  ### 同梱プラグイン: 派生列(formula / computed column)
387
400
 
388
- `plugins/tss-formula.js`=「その行の値だけから計算する列」を `columns[c].formula` 関数で書けるようにする。金額=数量×単価、税込=金額×1.1 のような**派生列**を宣言的に。動く例: [`examples/formula.html`](https://tssgrid.threestarsoftware.co.jp/examples/formula.html)。
401
+ `plugins/tss-formula.js`<!--DEV-->(**無依存・コア無改変**)<!--/DEV-->=「その行の値だけから計算する列」を `columns[c].formula` 関数で書けるようにする。金額=数量×単価、税込=金額×1.1 のような**派生列**を宣言的に。動く例: [`examples/formula.html`](https://tssgrid.threestarsoftware.co.jp/examples/formula.html)。
389
402
 
390
403
  ```html
391
404
  <script src="plugins/tss-formula.js"></script>
@@ -408,7 +421,7 @@ new TssGrid(el, {
408
421
 
409
422
  ### 同梱プラグイン: 列の集計(totals / 縦の SUM・平均…)
410
423
 
411
- `plugins/tss-totals.js`= 列方向の集計(`sum`/`avg`/`count`/`min`/`max`/独自関数)を担う。**計算だけで描画はしない**=結果を `onTotals` で受けて好きな場所へ(フッタ・ラベル・**別の TssGrid の合計表**でも)。`formula` と併用すると派生列の後に集計が連動。動く例: [`examples/formula.html`](https://tssgrid.threestarsoftware.co.jp/examples/formula.html)(明細=formula/合計=totals)。
424
+ `plugins/tss-totals.js`<!--DEV-->(**無依存・コア無改変**)<!--/DEV-->= 列方向の集計(`sum`/`avg`/`count`/`min`/`max`/独自関数)を担う。**計算だけで描画はしない**=結果を `onTotals` で受けて好きな場所へ(フッタ・ラベル・**別の TssGrid の合計表**でも)。`formula` と併用すると派生列の後に集計が連動。動く例: [`examples/formula.html`](https://tssgrid.threestarsoftware.co.jp/examples/formula.html)(明細=formula/合計=totals)。
412
425
 
413
426
  ```html
414
427
  <script src="plugins/tss-totals.js"></script>
@@ -429,7 +442,7 @@ new TssGrid(el, {
429
442
 
430
443
  ### 同梱プラグイン: 条件付き合計(tss-sum / ラベル付き合計行・整列表示)
431
444
 
432
- `plugins/tss-sum.js`= **ラベル付きの"合計行"**(小計・消費税10%・消費税8%・合計 など)を、**条件(`when`)+対象(`of`)を宣言**で定義して作る。`tss-totals` が「列ごと1値」なのに対し、こちらは**同じ列を条件別に何本でも集計**でき(消費税10%と8%は両方 amount 由来=per-column では衝突する)、**ソースと同一列構成の readonly 集計グリッドを生成**するので**金額が縦にピッタリ揃う**(画面でも印刷でも)。請求書・見積などの合計欄に。動く例: [`examples/invoice.html`](https://tssgrid.threestarsoftware.co.jp/examples/invoice.html)(インボイス請求書の合計欄)。
445
+ `plugins/tss-sum.js`<!--DEV-->(**無依存・コア無改変**/ラベル横結合に TssMerge を任意利用)<!--/DEV-->= **ラベル付きの"合計行"**(小計・消費税10%・消費税8%・合計 など)を、**条件(`when`)+対象(`of`)を宣言**で定義して作る。`tss-totals` が「列ごと1値」なのに対し、こちらは**同じ列を条件別に何本でも集計**でき(消費税10%と8%は両方 amount 由来=per-column では衝突する)、**ソースと同一列構成の readonly 集計グリッドを生成**するので**金額が縦にピッタリ揃う**(画面でも印刷でも)。請求書・見積などの合計欄に。動く例: [`examples/invoice.html`](https://tssgrid.threestarsoftware.co.jp/examples/invoice.html)(インボイス請求書の合計欄)。
433
446
 
434
447
  ```html
435
448
  <script src="plugins/tss-merge.js"></script> <!-- ラベルの横結合に利用 -->
@@ -453,7 +466,7 @@ const sum = TssSum.attach(grid, document.getElementById('sumgrid'), {
453
466
 
454
467
  ### 同梱プラグイン: 残高/累計(running total・縦の連鎖)
455
468
 
456
- `plugins/tss-running.js`=「**前の行の結果 + この行の増減**」で縦に積む列。`formula`(行内)でも `totals`(全体合計)でも出せない**縦方向の連鎖**を担う。出納帳/通帳の**残高**、在庫受払の**在庫数**、経費・ポイントの**累計**など。動く例: [`examples/running.html`](https://tssgrid.threestarsoftware.co.jp/examples/running.html)(現金出納帳=入金・出金→残高)。
469
+ `plugins/tss-running.js`<!--DEV-->(**無依存・コア無改変**)<!--/DEV-->=「**前の行の結果 + この行の増減**」で縦に積む列。`formula`(行内)でも `totals`(全体合計)でも出せない**縦方向の連鎖**を担う。出納帳/通帳の**残高**、在庫受払の**在庫数**、経費・ポイントの**累計**など。動く例: [`examples/running.html`](https://tssgrid.threestarsoftware.co.jp/examples/running.html)(現金出納帳=入金・出金→残高)。
457
470
 
458
471
  ```html
459
472
  <script src="plugins/tss-running.js"></script>
@@ -472,11 +485,11 @@ new TssGrid(el, {
472
485
  - `column`(残高列) / `initial`(前残) / `delta` か `step` / `skipEmpty`(全列空の行は連鎖を進めず空表示、既定true)。
473
486
  - **行ドラッグ移動(rowReorder)併用時**は順序が変わるので `onAfterRowMove` で `handle.recompute()` を呼ぶ。
474
487
 
475
- > 「**行=formula/列=totals/縦=running**」の3点が揃う。いずれも `setValueRaw`+`onAfterChange` ラップで動く**プラグイン**。
488
+ > 「**行=formula/列=totals/縦=running**」の3点が揃う。いずれも `setValueRaw`+`onAfterChange` ラップで動く**<!--DEV-->コア無改変<!--/DEV-->プラグイン**。
476
489
 
477
490
  ### 同梱プラグイン: 氏名→フリガナ 自動入力(furigana・辞書不要)
478
491
 
479
- `plugins/tss-furigana.js`(**辞書不要**)= 漢字を入力(IME変換)すると**読みが別列に自動で入る**(スマホ連絡先の よみがな 自動入力と同じ)。辞書引きではなく、**IME変換の composition イベントから「変換前のかな」を拾う**方式。TssGrid は**アクティブセルに本物の `<input>` を重ねる**設計なので composition が確実に取れる=「**IMEに強い**」を機能で体現。動く例: [`examples/furigana.html`](https://tssgrid.threestarsoftware.co.jp/examples/furigana.html)。
492
+ `plugins/tss-furigana.js`(**<!--DEV-->無依存・<!--/DEV-->辞書不要<!--DEV-->・コア無改変<!--/DEV-->**)= 漢字を入力(IME変換)すると**読みが別列に自動で入る**(スマホ連絡先の よみがな 自動入力と同じ)。辞書引きではなく、**IME変換の composition イベントから「変換前のかな」を拾う**方式。TssGrid は**アクティブセルに本物の `<input>` を重ねる**設計なので composition が確実に取れる=「**IMEに強い**」を機能で体現。動く例: [`examples/furigana.html`](https://tssgrid.threestarsoftware.co.jp/examples/furigana.html)。
480
493
 
481
494
  ```html
482
495
  <script src="plugins/tss-furigana.js"></script>
@@ -493,7 +506,7 @@ new TssGrid(el, {
493
506
 
494
507
  ### 同梱プラグイン: セル内ミニ計算式(calc・=12*3)
495
508
 
496
- `plugins/tss-calc.js`(**eval/Function 不使用**)= セルに `=12*3+5` と打つと確定時に **41** に。四則 `+ - * /`・括弧・小数対応の**自前再帰下降パーサ**(CSP安全)。`columns[c].parse`(入力変換)seam に乗るだけ。動く例: [`examples/calc.html`](https://tssgrid.threestarsoftware.co.jp/examples/calc.html)。
509
+ `plugins/tss-calc.js`(**<!--DEV-->無依存・<!--/DEV-->eval/Function 不使用<!--DEV-->・コア無改変<!--/DEV-->**)= セルに `=12*3+5` と打つと確定時に **41** に。四則 `+ - * /`・括弧・小数対応の**自前再帰下降パーサ**(CSP安全)。`columns[c].parse`(入力変換)seam に乗るだけ。動く例: [`examples/calc.html`](https://tssgrid.threestarsoftware.co.jp/examples/calc.html)。
497
510
 
498
511
  ```html
499
512
  <script src="plugins/tss-calc.js"></script>
@@ -509,7 +522,7 @@ plugins: [ TssCalc.plugin({ columns: ['quantity', 'price'] }) ] // columns 省
509
522
 
510
523
  ### 同梱プラグイン: オートコンプリート(autocomplete エディタ)
511
524
 
512
- `plugins/tss-autocomplete.js`= 自由入力+**候補の絞り込み**。固定 dropdown の上位版で「だいたい決まってるが新規もある」項目(取引先・商品名)向け。**IME変換中の Enter は奪わない**(変換確定が最優先)。動く例: [`examples/autocomplete.html`](https://tssgrid.threestarsoftware.co.jp/examples/autocomplete.html)。
525
+ `plugins/tss-autocomplete.js`<!--DEV-->(**無依存・コア無改変**)<!--/DEV-->= 自由入力+**候補の絞り込み**。固定 dropdown の上位版で「だいたい決まってるが新規もある」項目(取引先・商品名)向け。**IME変換中の Enter は奪わない**(変換確定が最優先)。動く例: [`examples/autocomplete.html`](https://tssgrid.threestarsoftware.co.jp/examples/autocomplete.html)。
513
526
 
514
527
  ```html
515
528
  <link rel="stylesheet" href="plugins/tss-autocomplete.css">
@@ -527,7 +540,7 @@ columns: [
527
540
 
528
541
  ### 同梱プラグイン: セル内スパークライン(sparkline・ミニグラフ)
529
542
 
530
- `plugins/tss-sparkline.js`(**SVG自前描画**)= セルの数列を**ミニ折れ線/棒**で描く。`columns[c].html:true` + `format`(format が返す SVG がセルに入る)seam に乗るだけ。Chart.js 等は積まない。動く例: [`examples/sparkline.html`](https://tssgrid.threestarsoftware.co.jp/examples/sparkline.html)(月次売上→動向グラフ・編集でライブ更新)。
543
+ `plugins/tss-sparkline.js`(**<!--DEV-->無依存・<!--/DEV-->SVG自前描画<!--DEV-->・コア無改変<!--/DEV-->**)= セルの数列を**ミニ折れ線/棒**で描く。`columns[c].html:true` + `format`(format が返す SVG がセルに入る)seam に乗るだけ。Chart.js 等は積まない。動く例: [`examples/sparkline.html`](https://tssgrid.threestarsoftware.co.jp/examples/sparkline.html)(月次売上→動向グラフ・編集でライブ更新)。
531
544
 
532
545
  ```html
533
546
  <script src="plugins/tss-sparkline.js"></script>
@@ -541,11 +554,11 @@ columns: [
541
554
  formula: (row) => ['m4','m5','m6','m7','m8','m9'].map(k=>row[k]).filter(v=>v!=='').join(',') }
542
555
  ```
543
556
  - `type`('line'/'bar') / `width` / `height` / `color` / `strokeWidth` / `fill`(折れ線下の塗り) / `min`・`max`(既定は自動)。
544
- - 数値しか SVG に入れない=安全。`formula`/`html-cells` と同じ仕組みで、**有料グリッドの "セル内グラフ" を無料で**。
557
+ - 数値しか SVG に入れない=安全。`formula`/`html-cells` と同じ仕組みで、**有料グリッドの "セル内グラフ" を無料<!--DEV-->・無依存<!--/DEV-->で**。
545
558
 
546
559
  ### 同梱プラグイン: セル内 横棒グラフ(bar・データバー)
547
560
 
548
- `plugins/tss-bar.js`= セルの数値を**横棒**で描く(認知率・進捗%・スコア)。`html:true`+`format` seam。`type:'number'` 併用で**バーのまま編集**(値を打つと伸びる)。動く例: [`examples/bar.html`](https://tssgrid.threestarsoftware.co.jp/examples/bar.html)。
561
+ `plugins/tss-bar.js`<!--DEV-->(**無依存・コア無改変**)<!--/DEV-->= セルの数値を**横棒**で描く(認知率・進捗%・スコア)。`html:true`+`format` seam。`type:'number'` 併用で**バーのまま編集**(値を打つと伸びる)。動く例: [`examples/bar.html`](https://tssgrid.threestarsoftware.co.jp/examples/bar.html)。
549
562
 
550
563
  ```html
551
564
  <script src="plugins/tss-bar.js"></script>
@@ -560,7 +573,7 @@ columns: [
560
573
 
561
574
  ### 同梱プラグイン: ヒートマップ / 背景データバー(cellStyle)
562
575
 
563
- `plugins/tss-heatmap.js`= コアの `cellStyle` フックに乗せる**連続色(ヒートマップ)**と**背景データバー**。`tss-bar.js`(前景の棒+ラベル)と違い、**セル背景**を塗る=**数値はそのまま見せて編集できる**。動く例: [`examples/heatmap.html`](https://tssgrid.threestarsoftware.co.jp/examples/heatmap.html)。
576
+ `plugins/tss-heatmap.js`<!--DEV-->(**無依存・コア無改変**)<!--/DEV-->= コアの `cellStyle` フックに乗せる**連続色(ヒートマップ)**と**背景データバー**。`tss-bar.js`(前景の棒+ラベル)と違い、**セル背景**を塗る=**数値はそのまま見せて編集できる**。動く例: [`examples/heatmap.html`](https://tssgrid.threestarsoftware.co.jp/examples/heatmap.html)。
564
577
 
565
578
  ```html
566
579
  <script src="plugins/tss-heatmap.js"></script>
@@ -574,7 +587,7 @@ columns: [
574
587
  - `TssHeatmap`: `min`/`max`(明示。セル単位呼び出しで列範囲を知らないため)/ `colors`(2色以上のストップ) or `low`/`high` / `text`(自動文字色, 既定on)。
575
588
  - `TssDataBar`: `min`/`max` / `color` / `align`('left'/'right')。編集で即更新。
576
589
 
577
- > 「セル内グラフ」3点: **前景の折れ線/棒=`html:true`+`format`(tss-sparkline / tss-bar)**、**背景の連続色/バー=`cellStyle`(tss-heatmap)**。どれもノービルド。
590
+ > 「セル内グラフ」3点: **前景の折れ線/棒=`html:true`+`format`(tss-sparkline / tss-bar)**、**背景の連続色/バー=`cellStyle`(tss-heatmap)**。どれも<!--DEV-->無依存・<!--/DEV-->ノービルド。
578
591
 
579
592
  ### グラフ連携(Chart.js などと)— 編集でライブ更新
580
593
 
@@ -588,7 +601,7 @@ new TssGrid(el, { columns, data, onAfterChange: () => { chart.data = toChartData
588
601
 
589
602
  ### 同梱プラグイン: 2択トグル(toggle・有効|無効)
590
603
 
591
- `plugins/tss-toggle.js`+`.css`= ラジオの2択を**並んだセグメント(有効|無効)**で表示、クリックで切替。チェックボックスより**2ラベルが見える**。値は dropdown 同様 **value を保存**。動く例: [`examples/toggle.html`](https://tssgrid.threestarsoftware.co.jp/examples/toggle.html)。
604
+ `plugins/tss-toggle.js`+`.css`<!--DEV-->(**無依存・コア無改変**)<!--/DEV-->= ラジオの2択を**並んだセグメント(有効|無効)**で表示、クリックで切替。チェックボックスより**2ラベルが見える**。値は dropdown 同様 **value を保存**。動く例: [`examples/toggle.html`](https://tssgrid.threestarsoftware.co.jp/examples/toggle.html)。
592
605
 
593
606
  ```html
594
607
  <link rel="stylesheet" href="plugins/tss-toggle.css">
@@ -605,7 +618,7 @@ plugins: [ TssToggle.plugin({ columns: {
605
618
 
606
619
  ### 同梱プラグイン: セル結合(merge・横/縦/矩形)
607
620
 
608
- `plugins/tss-merge.js`= データセルの**矩形結合**(`colspan`×`rowspan`)。左上の**アンカーが値・編集・選択の対象**で、覆われた従属セルは**描画スキップ+読み取り専用**(貼り付け・フィルは弾く)。動く例: [`examples/merge.html`](https://tssgrid.threestarsoftware.co.jp/examples/merge.html)。(見出しの結合は `nestedHeaders` を参照)
621
+ `plugins/tss-merge.js`<!--DEV-->(**無依存**)<!--/DEV-->= データセルの**矩形結合**(`colspan`×`rowspan`)。左上の**アンカーが値・編集・選択の対象**で、覆われた従属セルは**描画スキップ+読み取り専用**(貼り付け・フィルは弾く)。動く例: [`examples/merge.html`](https://tssgrid.threestarsoftware.co.jp/examples/merge.html)。(見出しの結合は `nestedHeaders` を参照)
609
622
 
610
623
  ```html
611
624
  <script src="plugins/tss-merge.js"></script>
@@ -631,7 +644,7 @@ plugins: [ TssMerge.plugin({ merges: [
631
644
 
632
645
  ### 同梱プラグイン: 同期スクロール(syncscroll・左右2表を並べて比較)
633
646
 
634
- `plugins/tss-syncscroll.js`= 並べた複数グリッドのスクロールを連動(Excel「並べて比較+同時にスクロール」相当)。A系/B系で別々に絞り込んだ表を**突き合わせながら編集**する用途に。動く例: [`examples/sync-scroll.html`](https://tssgrid.threestarsoftware.co.jp/examples/sync-scroll.html)。
647
+ `plugins/tss-syncscroll.js`<!--DEV-->(**無依存・コア無改変**)<!--/DEV-->= 並べた複数グリッドのスクロールを連動(Excel「並べて比較+同時にスクロール」相当)。A系/B系で別々に絞り込んだ表を**突き合わせながら編集**する用途に。動く例: [`examples/sync-scroll.html`](https://tssgrid.threestarsoftware.co.jp/examples/sync-scroll.html)。
635
648
 
636
649
  ```html
637
650
  <script src="plugins/tss-syncscroll.js"></script>
@@ -898,7 +911,7 @@ new TssGrid(el, {
898
911
 
899
912
  ### 入力の正規化 / 表示の整形(`parse` / `format`)
900
913
 
901
- **保存値**(`getData()` が返す内部値)と、**入力**・**表示**を分離できます。外部ライブラリは同梱せず、必要なら列のフックに差し込む方針です。
914
+ **保存値**(`getData()` が返す内部値)と、**入力**・**表示**を分離できます。外部ライブラリは同梱せず、必要なら列のフックに差し込む方針です<!--DEV-->(コアは無依存のまま)<!--/DEV-->。
902
915
 
903
916
  | フック | 向き | 役割 |
904
917
  |---|---|---|
@@ -1108,7 +1121,7 @@ command は `{ apply(), revert(), label }`。
1108
1121
  ## 制限
1109
1122
 
1110
1123
  - 外部(Excel等)→グリッドの貼り付けはブラウザのクリップボード読取許可が必要。`file://` で読めない場合は http(ローカルサーバ)で開く。グリッド間・グリッド内のコピペは内部バッファで常に動く。
1111
- - 仮想スクロール未対応(大量行が必要なら今後)。日付の連番フィルはコピー扱い(数値のみ連番)。
1124
+ - 仮想スクロールは `virtual: true` で対応(固定行高・固定行列/折り返し/セル結合は非対応)。日付の連番フィルはコピー扱い(数値のみ連番)。
1112
1125
 
1113
1126
  ## License
1114
1127
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threestarsoftware/tssgrid",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "description": "軽量・ノービルド・日本語IME堅牢な編集グリッド(vanilla JS / 依存なし / MIT)。Excel ライクな入力画面を <script> タグ1本で。",
5
5
  "license": "MIT",
6
6
  "author": "ThreeStarSoftware (https://www.threestarsoftware.co.jp/)",
@@ -1,4 +1,5 @@
1
1
  /*! TssAutocomplete — 候補サジェスト付きテキスト入力(TssGrid カスタムエディタ)。
2
+ * DEV: 無依存・コア無改変(dev専用メモ・公開時strip)
2
3
  *
3
4
  * 自由入力+候補の絞り込み。固定 dropdown の上位版(取引先名・商品名など「だいたい決まってるが新規もある」項目向け)。
4
5
  * **IME変換中の Enter は奪わない**(変換確定が最優先=TssGrid の流儀)。
@@ -1,4 +1,5 @@
1
1
  /*! TssBar — セル内 横棒グラフ(データバー)。
2
+ * DEV: 無依存・コア無改変
2
3
  *
3
4
  * セルの数値を**横棒**で描く(アンケートの認知率バー、進捗%、スコアなど)。
4
5
  * `columns[c].html:true` + `format` 関数 seam に乗るだけ(format が返す <div> がセルに入る)。
@@ -1,4 +1,5 @@
1
1
  /*! TssCalc — セル内ミニ計算式(=12*3+5 → 41)。**eval/Function 不使用**。
2
+ * DEV: 無依存・コア無改変
2
3
  *
3
4
  * セルに数式を打つと確定時に計算結果へ。四則 + - * / と括弧 () と小数に対応。
4
5
  * パーサは再帰下降の自前実装=**eval を使わない**(CSP安全)。`columns[c].parse` seam に乗るだけ。
@@ -1,4 +1,5 @@
1
1
  /*! TssCalendar — 日付ピッカー(TssGrid のカスタムエディタ / 単体ウィジェット両用)
2
+ * DEV: 無依存
2
3
  * 休日カレンダーを JSON で渡せる(勤怠・シフト等向け)。CSS は tss-calendar.css をクラス/変数で自由に。
3
4
  * 使い方(グリッド): columns: [{ type:'date', editor: TssCalendar({ holidays }) }]
4
5
  * 使い方(単体) : TssCalendar({holidays}).openAt(anchorEl, isoValue, onPick)
@@ -0,0 +1,36 @@
1
+ /* TssDatetime — 日付+時刻 同時ピッカーのレイアウト。
2
+ カレンダー部は tss-calendar.css(.tss-cal-*)、時刻部は tss-time.css(.tss-time-*)のスタイルを再利用するので、
3
+ この3枚を読み込む(または .tss-dt 配下に必要分を内包)。ここは「合体ポップアップ」の枠だけ定義。 */
4
+ .tss-dt {
5
+ --dt-bg: #fff;
6
+ --dt-fg: #222;
7
+ --dt-border: #c3ccd6;
8
+ --dt-accent: #2aa0d8;
9
+ --dt-radius: 8px;
10
+ --dt-font: 13px "Segoe UI", "Meiryo", sans-serif;
11
+
12
+ position: fixed; z-index: 10000; background: var(--dt-bg); color: var(--dt-fg);
13
+ border: 1px solid var(--dt-border); border-radius: var(--dt-radius);
14
+ box-shadow: 0 6px 24px rgba(0,0,0,.18); padding: 8px; font: var(--dt-font); user-select: none; outline: none;
15
+ }
16
+ /* 選択中の値リードアウト=標準の日時に無い「曜日」をここで必ず見せる */
17
+ .tss-dt-readout { font-size: 13px; padding: 3px 6px 7px; margin-bottom: 4px; border-bottom: 1px dashed var(--dt-border); font-variant-numeric: tabular-nums; }
18
+ .tss-dt-readout .tss-dt-ro-wd { color: var(--dt-accent); margin: 0 2px; }
19
+ .tss-dt-readout .tss-dt-ro-empty { color: #99a; }
20
+
21
+ .tss-dt-body { display: flex; gap: 12px; align-items: flex-start; }
22
+ .tss-dt-time { padding-top: 2px; }
23
+ .tss-dt-time-lbl { font-size: 11px; color: #889; margin: 0 0 4px 2px; }
24
+
25
+ /* 合体ポップアップ内ではカレンダー/時刻の各ポップアップ枠(影・border・fixed)を無効化=中身だけ使う */
26
+ .tss-dt .tss-cal, .tss-dt .tss-time { position: static; box-shadow: none; border: 0; padding: 0; z-index: auto; }
27
+ .tss-dt .tss-time-row { display: flex; align-items: center; gap: 4px; }
28
+ .tss-dt .tss-time-row select { font: inherit; padding: 2px 4px; border: 1px solid var(--dt-border); border-radius: 5px; background: var(--dt-bg); color: var(--dt-fg); cursor: pointer; }
29
+ .tss-dt .tss-time-sep { font-weight: 600; }
30
+
31
+ .tss-dt-btns { display: flex; align-items: center; gap: 6px; margin-top: 8px; }
32
+ .tss-dt-btns .tss-dt-spacer { flex: 1 1 auto; }
33
+ .tss-dt-btns button { font: inherit; padding: 4px 12px; border: 1px solid var(--dt-border); background: var(--dt-bg); color: var(--dt-fg); border-radius: 6px; cursor: pointer; }
34
+ .tss-dt-btns button:hover { background: #eef4ff; }
35
+ .tss-dt-btns .tss-dt-ok { border-color: var(--dt-accent); background: var(--dt-accent); color: #fff; }
36
+ .tss-dt-btns .tss-dt-clear { color: #b6473e; }
@@ -0,0 +1,184 @@
1
+ /*! TssDatetime — 日付+時刻を同時に設定するピッカー(TssGrid カスタムエディタ / 単体ウィジェット両用)
2
+ * DEV: 無依存。カレンダー部は tss-calendar.css、時刻部は tss-time.css のクラスを再利用+薄い tss-datetime.css。
3
+ * 保存値は常に 'YYYY-MM-DDTHH:MM'(kintone DATETIME と相互変換しやすい正規形。表示は列 format で整形)。
4
+ * 使い方(グリッド): columns: [{ type:'text', editor: TssDatetime({ step:5 }), format: TssDatetime.format }]
5
+ * 使い方(単体) : TssDatetime({step:15}).openAt(anchorEl, 'YYYY-MM-DDTHH:MM', onPick)
6
+ * opts: holidays / weekend / weekLabels / min / max(日付) / step(分刻み,既定1) / hour12 / icon(既定 '📅') / openOnClick(既定true) / className
7
+ */
8
+ (function (root) {
9
+ 'use strict';
10
+ function pad(n) { return String(n).padStart(2, '0'); }
11
+ function isoD(d) { return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); }
12
+ function parseISOd(s) { var m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(s || '')); return m ? new Date(+m[1], +m[2] - 1, +m[3]) : null; }
13
+ // 'YYYY-MM-DD[ T]HH:MM'(時刻省略可)→ { d:Date, h:int|null, mi:int|null }
14
+ function parseDT(s) {
15
+ var m = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{1,2}):(\d{2}))?/.exec(String(s || '').trim());
16
+ if (!m) return null;
17
+ return { d: new Date(+m[1], +m[2] - 1, +m[3]), h: m[4] != null ? +m[4] : null, mi: m[5] != null ? +m[5] : null };
18
+ }
19
+ // 'HH:MM'(日付無し=時刻のみ。kintone TIME フィールド互換)→ { h, m }
20
+ function parseTimeOnly(s) { var m = /^(\d{1,2}):(\d{2})$/.exec(String(s || '').trim()); if (!m) return null; var h = +m[1], mi = +m[2]; return (h <= 23 && mi <= 59) ? { h: h, m: mi } : null; }
21
+ function sameDay(a, b) { return !!a && !!b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); }
22
+ function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; }); }
23
+ var WD = ['日', '月', '火', '水', '木', '金', '土']; // 既定の曜日ラベル(標準の日時に無い「曜日」を補う=差別化点)
24
+
25
+ function TssDatetime(opts) {
26
+ opts = opts || {};
27
+ var holidays = opts.holidays || {};
28
+ var weekend = opts.weekend || [0, 6];
29
+ var weekLabels = opts.weekLabels || ['日', '月', '火', '水', '木', '金', '土'];
30
+ var step = Math.max(1, opts.step || 1);
31
+ var hour12 = !!opts.hour12;
32
+ var minD = parseISOd(opts.min), maxD = parseISOd(opts.max);
33
+ var holName = function (h) { return h == null ? null : (typeof h === 'object' ? h.name : h); };
34
+ var holType = function (h) { return (h && typeof h === 'object' && h.type) ? h.type : null; };
35
+ var ty = new Date().getFullYear();
36
+ var yrLo = opts.yearMin || (minD ? minD.getFullYear() : ty - 12);
37
+ var yrHi = opts.yearMax || (maxD ? maxD.getFullYear() : ty + 12);
38
+
39
+ var pop = null, view = null, selD = null, selH = null, selM = null, ref = null, done = false, outside = null;
40
+
41
+ function dDisabled(d) { if (minD && d < minD) return true; if (maxD && d > maxD) return true; return false; }
42
+
43
+ function calHTML() {
44
+ var y = view.getFullYear(), m = view.getMonth(), start = new Date(y, m, 1).getDay(), today = new Date();
45
+ var lo = Math.min(yrLo, y), hi = Math.max(yrHi, y), yrSel = '<select class="tss-cal-sel-yr" aria-label="年">';
46
+ for (var yy = lo; yy <= hi; yy++) yrSel += '<option value="' + yy + '"' + (yy === y ? ' selected' : '') + '>' + yy + '年</option>';
47
+ yrSel += '</select>';
48
+ var monSel = '<select class="tss-cal-sel-mon" aria-label="月">';
49
+ for (var mm = 0; mm < 12; mm++) monSel += '<option value="' + mm + '"' + (mm === m ? ' selected' : '') + '>' + (mm + 1) + '月</option>';
50
+ monSel += '</select>';
51
+ var html = '<div class="tss-cal-head">'
52
+ + '<button type="button" class="tss-cal-nav" data-nav="-1" aria-label="前の月">‹</button>'
53
+ + '<span class="tss-cal-sels">' + yrSel + monSel + '</span>'
54
+ + '<button type="button" class="tss-cal-nav" data-nav="1" aria-label="次の月">›</button></div>'
55
+ + '<div class="tss-cal-grid">';
56
+ for (var w = 0; w < 7; w++) { var wc = w === 0 ? ' tss-cal-sun' : (w === 6 ? ' tss-cal-sat' : ''); html += '<div class="tss-cal-wl' + wc + '">' + esc(weekLabels[w]) + '</div>'; }
57
+ var d = new Date(y, m, 1 - start);
58
+ for (var i = 0; i < 42; i++) {
59
+ var cls = 'tss-cal-day', dow = d.getDay(), k = isoD(d), h = holidays[k], nm = holName(h), tp = holType(h);
60
+ if (d.getMonth() !== m) cls += ' tss-cal-other';
61
+ if (dow === 0) cls += ' tss-cal-sun'; else if (dow === 6) cls += ' tss-cal-sat';
62
+ if (h) { cls += ' tss-cal-holiday'; if (tp) cls += ' tss-cal-hol-' + tp; }
63
+ if (sameDay(d, today)) cls += ' tss-cal-today';
64
+ if (sameDay(d, selD)) cls += ' tss-cal-sel';
65
+ if (dDisabled(d)) cls += ' tss-cal-disabled';
66
+ html += '<div class="' + cls + '" data-d="' + k + '"' + (nm ? ' title="' + esc(nm) + '"' : '') + '>' + d.getDate() + '</div>';
67
+ d.setDate(d.getDate() + 1);
68
+ }
69
+ return html + '</div>';
70
+ }
71
+ function timeHTML() {
72
+ var hSel = '<select class="tss-time-h" aria-label="時"><option value=""></option>';
73
+ if (hour12) { for (var i = 1; i <= 12; i++) { var d24 = (selH == null) ? null : ((selH % 12) || 12); hSel += '<option value="' + i + '"' + (d24 === i ? ' selected' : '') + '>' + i + '</option>'; } }
74
+ else { for (var j = 0; j <= 23; j++) hSel += '<option value="' + j + '"' + (selH === j ? ' selected' : '') + '>' + pad(j) + '</option>'; }
75
+ hSel += '</select>';
76
+ var mins = []; for (var k = 0; k < 60; k += step) mins.push(k);
77
+ if (selM != null && mins.indexOf(selM) < 0) { mins.push(selM); mins.sort(function (a, b) { return a - b; }); }
78
+ var mSel = '<select class="tss-time-m" aria-label="分"><option value=""></option>';
79
+ for (var n = 0; n < mins.length; n++) mSel += '<option value="' + mins[n] + '"' + (selM === mins[n] ? ' selected' : '') + '>' + pad(mins[n]) + '</option>';
80
+ mSel += '</select>';
81
+ var aSel = '';
82
+ if (hour12) { var pm = selH != null && selH >= 12; aSel = '<select class="tss-time-ap" aria-label="午前/午後"><option value="AM"' + (!pm ? ' selected' : '') + '>AM</option><option value="PM"' + (pm ? ' selected' : '') + '>PM</option></select>'; }
83
+ return '<div class="tss-dt-time-lbl">時刻</div><div class="tss-time-row">' + hSel + '<span class="tss-time-sep">:</span>' + mSel + aSel + '</div>';
84
+ }
85
+ function readoutHTML() {
86
+ var hasT = selH != null || selM != null;
87
+ var t = hasT ? pad(selH == null ? 0 : selH) + ':' + pad(selM == null ? 0 : selM) : '';
88
+ if (!selD) return hasT ? '<b class="tss-dt-ro-wd">' + t + '</b><span class="tss-dt-ro-empty">(日付なし)</span>' : '<span class="tss-dt-ro-empty">日付/時刻を選択</span>';
89
+ var wd = (opts.weekLabels || WD)[selD.getDay()];
90
+ return isoD(selD).replace(/-/g, '/') + '<b class="tss-dt-ro-wd">(' + esc(wd) + ')</b>' + (hasT ? ' ' + t : '');
91
+ }
92
+ function render() {
93
+ pop.innerHTML = '<div class="tss-dt-readout">' + readoutHTML() + '</div>'
94
+ + '<div class="tss-dt-body"><div class="tss-dt-cal">' + calHTML() + '</div><div class="tss-dt-time">' + timeHTML() + '</div></div>'
95
+ + '<div class="tss-dt-btns"><button type="button" class="tss-dt-clear">クリア</button><span class="tss-dt-spacer"></span>'
96
+ + '<button type="button" class="tss-dt-now">現在</button><button type="button" class="tss-dt-ok">決定</button></div>';
97
+ }
98
+ function readTime() {
99
+ var hS = pop.querySelector('.tss-time-h'), mS = pop.querySelector('.tss-time-m'), aS = pop.querySelector('.tss-time-ap');
100
+ selH = hS && hS.value !== '' ? +hS.value : null;
101
+ selM = mS && mS.value !== '' ? +mS.value : null;
102
+ if (hour12 && aS && selH != null) { selH = aS.value === 'PM' ? (selH === 12 ? 12 : selH + 12) : (selH === 12 ? 0 : selH); }
103
+ }
104
+ function value() {
105
+ readTime();
106
+ var hasT = selH != null || selM != null;
107
+ var t = hasT ? pad(selH == null ? 0 : selH) + ':' + pad(selM == null ? 0 : selM) : '';
108
+ if (selD && hasT) return isoD(selD) + 'T' + t; // 日付+時刻
109
+ if (selD) return isoD(selD); // 日付のみ
110
+ if (hasT) return t; // 時刻のみ(日付無し='HH:MM')
111
+ return '';
112
+ }
113
+ function place(anchor) {
114
+ var r = anchor.getBoundingClientRect();
115
+ pop.style.left = Math.max(2, Math.min(r.left, window.innerWidth - pop.offsetWidth - 6)) + 'px';
116
+ var top = r.bottom + 2;
117
+ if (top + pop.offsetHeight > window.innerHeight) top = Math.max(2, r.top - pop.offsetHeight - 2);
118
+ pop.style.top = top + 'px';
119
+ }
120
+ function onClick(e) {
121
+ var t = e.target;
122
+ var nav = t.getAttribute && t.getAttribute('data-nav');
123
+ if (nav) { view = new Date(view.getFullYear(), view.getMonth() + (+nav), 1); render(); return; }
124
+ var dd = t.getAttribute && t.getAttribute('data-d');
125
+ if (dd) { readTime(); var d = parseISOd(dd); if (!dDisabled(d)) { selD = d; render(); } return; } // 日付選択しても閉じない(時刻も決めるため)
126
+ if (t.classList.contains('tss-dt-now')) { var now = new Date(); selD = now; selH = now.getHours(); selM = Math.round(now.getMinutes() / step) * step; if (selM >= 60) selM = 60 - step; view = new Date(now.getFullYear(), now.getMonth(), 1); render(); return; }
127
+ if (t.classList.contains('tss-dt-clear')) { commit(''); return; }
128
+ if (t.classList.contains('tss-dt-ok')) { commit(value()); return; }
129
+ }
130
+ function onChange(e) {
131
+ var t = e.target;
132
+ if (t.classList.contains('tss-cal-sel-yr')) { view = new Date(+t.value, view.getMonth(), 1); render(); }
133
+ else if (t.classList.contains('tss-cal-sel-mon')) { view = new Date(view.getFullYear(), +t.value, 1); render(); }
134
+ else if (t.classList.contains('tss-time-h') || t.classList.contains('tss-time-m') || t.classList.contains('tss-time-ap')) { readTime(); var ro = pop.querySelector('.tss-dt-readout'); if (ro) ro.innerHTML = readoutHTML(); }
135
+ }
136
+ function onKey(e) {
137
+ if (e.key === 'Escape') { e.preventDefault(); cancel(); }
138
+ else if (e.key === 'Enter') { e.preventDefault(); commit(value()); }
139
+ }
140
+ function show(anchor, val, onPick, onCancel) {
141
+ done = false;
142
+ var p = parseDT(val), tp = p ? null : parseTimeOnly(val); // 日付付き or 時刻のみ
143
+ selD = p ? p.d : null;
144
+ selH = p ? p.h : (tp ? tp.h : null);
145
+ selM = p ? p.mi : (tp ? tp.m : null);
146
+ view = selD ? new Date(selD.getFullYear(), selD.getMonth(), 1) : new Date();
147
+ pop = document.createElement('div'); pop.className = 'tss-dt' + (opts.className ? ' ' + opts.className : ''); pop.tabIndex = -1;
148
+ document.body.appendChild(pop);
149
+ pop.addEventListener('mousedown', function (e) { if (e.target.tagName !== 'SELECT') e.preventDefault(); }); // フォーカスを奪わない(select 除く)
150
+ pop.addEventListener('click', onClick);
151
+ pop.addEventListener('change', onChange);
152
+ pop.addEventListener('keydown', onKey);
153
+ render(); place(anchor);
154
+ ref = { pick: onPick, cancel: onCancel };
155
+ outside = function (e) { if (pop && !pop.contains(e.target) && !(anchor && anchor.contains && anchor.contains(e.target))) commit(value()); }; // 外側クリックは確定(time と同方針)
156
+ setTimeout(function () { document.addEventListener('mousedown', outside, true); }, 0);
157
+ pop.focus();
158
+ }
159
+ function teardown() { if (outside) { document.removeEventListener('mousedown', outside, true); outside = null; } if (pop) { pop.remove(); pop = null; } }
160
+ function commit(v) { if (done) return; done = true; var f = ref.pick; teardown(); f(v); }
161
+ function cancel() { if (done) return; done = true; var f = ref.cancel; teardown(); if (f) f(); }
162
+
163
+ return {
164
+ open: function (ctx) { show(ctx.td, ctx.value, function (v) { ctx.commit(v); }, function () { ctx.cancel(); }); },
165
+ close: function () { teardown(); },
166
+ icon: opts.icon != null ? opts.icon : '📅',
167
+ openOnClick: opts.openOnClick !== false,
168
+ openAt: function (anchorEl, val, onPick) { show(anchorEl, val, function (v) { onPick(v); }, null); },
169
+ };
170
+ }
171
+
172
+ // 表示整形ヘルパ: 'YYYY-MM-DDTHH:MM' → 'YYYY/MM/DD(曜) HH:MM'(列 format に渡せる)。
173
+ // 標準の日時表示で空欄になる「曜日」を必ず埋める=本プラグインの目玉。weekday:false で曜日を消せる。
174
+ TssDatetime.format = function (v, o) {
175
+ var p = parseDT(v);
176
+ if (!p) return parseTimeOnly(v) ? v : (v || ''); // 時刻のみ('HH:MM')はそのまま表示/不正値は素通し
177
+ var d = p.d, wd = (o && o.weekday === false) ? '' : '(' + (o && o.weekLabels || WD)[d.getDay()] + ')';
178
+ var t = (p.h != null) ? ' ' + pad(p.h) + ':' + pad(p.mi == null ? 0 : p.mi) : '';
179
+ return d.getFullYear() + '/' + pad(d.getMonth() + 1) + '/' + pad(d.getDate()) + wd + t;
180
+ };
181
+
182
+ if (typeof module !== 'undefined' && module.exports) module.exports = TssDatetime;
183
+ else root.TssDatetime = TssDatetime;
184
+ })(typeof self !== 'undefined' ? self : this);
@@ -1,4 +1,5 @@
1
1
  /*! TssFilter — ヘッダのオートフィルタUI(Excel風)。コアの非破壊 filter() を呼ぶだけ。
2
+ * DEV: 無依存・コア無改変
2
3
  *
3
4
  * 各リーフ見出しにロート(漏斗)アイコンを出し(ソートの▲▼と区別)、クリックで「値チェックリスト+絞り込み検索」ポップアップ。
4
5
  * 複数列の条件は AND。適用は grid.filter(pred)/全解除で grid.clearFilter()。フィルタ済み列は印が付く。
@@ -1,4 +1,5 @@
1
1
  /*! TssFormula — 行スコープの派生列(computed column)プラグイン。
2
+ * DEV: コア無改変
2
3
  *
3
4
  * 「その行の値だけから計算する列」を columns 定義に formula 関数で書けるようにする。
4
5
  * 例: 金額 = 数量 × 単価 / 税込 = 金額 × 1.1 / 勤怠の勤務時間 など。
@@ -1,4 +1,5 @@
1
1
  /*! TssFurigana — 氏名→フリガナ 自動入力プラグイン。**辞書不要**。
2
+ * DEV: コア無改変
2
3
  *
3
4
  * スマホの連絡先のように「漢字を入力すると よみ が別列に自動で入る」。仕組みは**辞書引きではなく**、
4
5
  * IME変換の **composition イベントから「変換前の読み(かな)」を拾う**だけ。だから辞書データを持たない。
@@ -1,4 +1,5 @@
1
1
  /*! TssHeatmap / TssDataBar — セル背景の連続ビジュアライズ(cellStyle フック用)。
2
+ * DEV: 無依存・コア無改変
2
3
  *
3
4
  * コアの `cellStyle`(セルにインラインCSSを差すフック)に乗せる。cellClass(クラス=離散色)では出せない
4
5
  * **値に応じた連続色(ヒートマップ)/背景データバー**を、**編集できる数値セルのまま**実現する。
@@ -1,4 +1,5 @@
1
1
  /*! TssMerge — 矩形セル結合(colspan×rowspan)の軽量プラグイン。
2
+ * DEV: コアの薄い継ぎ目を使う
2
3
  *
3
4
  * アンカー=(r,c)=左上が値・編集・選択の対象。覆われた従属セルは描画スキップ+読取専用(貼付/フィルは弾く)。
4
5
  * 行/列の挿入削除では結合座標がコア側で縦横対称に追従(undo 対応)。
@@ -1,4 +1,5 @@
1
1
  /*! TssMessages — システム共通メッセージのカタログ作成ヘルパー(CSV/行配列 → grid.messages 用マップ)。
2
+ * DEV: 無依存
2
3
  *
3
4
  * エラー本文をコードで一元管理(トーン統一・多言語・差し替え)。validator は { code, params } を返すだけにし、
4
5
  * 本文は外部の設定(CSV/JSON/XML→行配列)に置く。ここはその設定を grid.messages の形に変換する係。
@@ -1,4 +1,5 @@
1
1
  /*! TssRunningTotal — 残高/累計(running total)プラグイン。
2
+ * DEV: コア無改変
2
3
  *
3
4
  * 「前の行の結果 + この行の増減」で縦に積む列を作る。formula(行内) でも totals(全体合計) でも出せない
4
5
  * **縦方向の連鎖**を担当。出納帳/通帳の残高、在庫受払の在庫数、経費・ポイントの累計など。
@@ -1,4 +1,5 @@
1
1
  /*! TssShared — 1つのマスタ(行データ)を複数グリッドで共有(A系/B系を別フィルタ・別ソートで並べて同時編集)。
2
+ * DEV: 無依存・コア無改変
2
3
  * 各ビューは同じ「行参照」を持つ=片方の編集がもう片方へ即反映(該当行を再描画)。
3
4
  * **行の挿入/削除も伝播**(マスタ&他ビューに反映)。**配列データ/オブジェクト配列データ どちらも対応**。
4
5
  *
@@ -1,4 +1,5 @@
1
1
  /*! TssSparkline — セル内スパークライン(ミニ折れ線/棒)。**SVG自前描画**。
2
+ * DEV: 無依存・コア無改変
2
3
  *
3
4
  * セルの値(カンマ/空白区切りの数列 "388,329,220,348,323,165")を**小さなグラフ**として描く。
4
5
  * 仕組みは `columns[c].html:true` + `format` 関数=format が返す SVG 文字列がそのままセルに入る(html-cells と同じ seam)。
@@ -1,4 +1,5 @@
1
1
  /*! TssSum — 条件付き合計グリッドの軽量プラグイン。
2
+ * DEV: コア無改変・無依存(ラベルの横結合に TssMerge を任意利用)
2
3
  *
3
4
  * tss-totals との住み分け: tss-totals は「列ごとの集計(sum/avg/min/max/count)」を onTotals で返す。
4
5
  * tss-sum は「同じ列を条件別に複数集計=ラベル付きの"合計行"(小計/消費税10%/消費税8%/合計 など)」+
@@ -1,4 +1,5 @@
1
1
  /*! TssSyncScroll — 複数グリッドの同期スクロール(Excel「並べて比較+同時にスクロール」相当)。
2
+ * DEV: 無依存・コア無改変
2
3
  *
3
4
  * 左右(or 上下)に並べた 2枚以上のグリッドのスクロールを連動。A系/B系で別々に絞り込んだ表を
4
5
  * 突き合わせながら編集する用途に。`grid.wrap`(overflow:auto のスクロール枠) の scroll を相手へ転写。
@@ -1,4 +1,5 @@
1
1
  /*! TssTime — 時刻ピッカー(TssGrid のカスタムエディタ / 単体ウィジェット両用)
2
+ * DEV: 無依存
2
3
  * 時/分プルダウン方式(業務の任意時刻入力向き)。保存値は常に 24h 'HH:MM'(type:'time' と互換)。
3
4
  * 使い方(グリッド): columns: [{ type:'time', editor: TssTime({ step:15 }) }]
4
5
  * opts: step(分刻み,既定1) / hour12(AM/PM表示,既定false) / icon(既定 '🕐') / openOnClick(既定true) / className
@@ -1,4 +1,5 @@
1
1
  /*! TssToggle — 2択セグメント・トグル(有効|無効・公開|非公開・男|女 …)。
2
+ * DEV: 無依存・コア無改変
2
3
  *
3
4
  * ラジオの2択を「並んだピル(セグメント)」で表示し、クリックでどちらかに切替。値は dropdown と同じく
4
5
  * 選んだ option の value を保存(getData も value)。html:true+format で描画、クリックは委譲で拾う。
@@ -1,4 +1,5 @@
1
1
  /*! TssTotals — 列方向の集計(合計/平均/件数/最小/最大)プラグイン。**計算だけ=描画は利用者側**。
2
+ * DEV: コア無改変
2
3
  *
3
4
  * 「縦の合計」をコアに埋めると合計行の置き場が minSpareRows/ソート/空行処理と衝突して肥大化する。
4
5
  * そこで **集計ロジックだけ** を担い、結果は onTotals コールバック / getTotals() で返す。
package/src/tssgrid.css CHANGED
@@ -60,6 +60,14 @@
60
60
  border-right: 1px solid var(--tg-border); border-bottom: 1px solid var(--tg-border); padding: 0 6px;
61
61
  font-size: var(--tg-font-size); box-sizing: border-box; white-space: nowrap; overflow: hidden;
62
62
  }
63
+ /* 外周の二重線を解消=最終行の下罫線・最終列の右罫線は描かない(外枠は .tg-wrap の単線が担当)。
64
+ 右下だけ「セル罫線+枠線」が重なって昔のHTMLTable風に見えるのを防ぐ=上下左右が均一な単線になる。 */
65
+ .tssgrid .tg-table tr:last-child > td,
66
+ .tssgrid .tg-table tr:last-child > th { border-bottom: none; }
67
+ .tssgrid .tg-table tr > td:last-child,
68
+ .tssgrid .tg-table tr > th:last-child { border-right: none; }
69
+ /* 仮想スクロールのスペーサ行(窓の上下を埋めて総高を保つだけの不可視行)。罫線/背景/余白を持たない。 */
70
+ .tssgrid .tg-table tr.tg-vspace > td { border: none; padding: 0; background: transparent; }
63
71
  .tssgrid .tg-table th { background: var(--tg-colheader-bg); color: var(--tg-header-fg); font-weight: 600; text-align: center; }
64
72
  /* z-index 階層(.tg-wrap 内): 通常セル 0 < 本体sticky(rowhead/frozen) 2 < 選択オーバーレイ 3
65
73
  < editor/select 4 < fillhandle 5 < ヘッダ thead 6 < 角/固定列ヘッダ 7 < リサイズ線 8。
package/src/tssgrid.d.ts CHANGED
@@ -126,6 +126,11 @@ export interface TssGridOptions {
126
126
  cursor?: boolean;
127
127
  stretchH?: 'none' | 'last' | 'all';
128
128
  rowHeaders?: boolean;
129
+ /** 行番号列の幅(px)。既定 40。大量行で桁数が増える時に広げる。 */
130
+ rowHeaderWidth?: number;
131
+ /** 仮想スクロール(固定行高ウィンドウイング)。大量行を可視窓だけ描画。true か { buffer:窓上下バッファ行数(既定6) }。
132
+ * 制約(v1): 行高一定/固定行列・折り返し・セル結合は非対応(指定時は自動無効化)。 */
133
+ virtual?: boolean | { buffer?: number };
129
134
  colHeaders?: boolean;
130
135
  hiddenColumns?: number[];
131
136
  wordWrap?: boolean;
package/src/tssgrid.js CHANGED
@@ -148,6 +148,7 @@
148
148
  this.mode = 'nav'; this.dragging = false; this.filling = null;
149
149
  this.pendingCut = null;
150
150
  this.headerDrag = null; this._colAnchor = 0; this._rowAnchor = 0; // 行/列ヘッダ選択
151
+ this._headerSel = null; // 'col'/'row'/'all'=列/行/全選択モード(結合に拡張せず実列/行にクランプ=Excel流)。null=通常セル選択
151
152
  // リサイズ(列幅・行高)
152
153
  // 列幅 colW[c]=px(未指定は既定)。指定方法は3通り、優先度=実行時リサイズ/明示colW > columns[c].width > 既定:
153
154
  // 1) colWidths: [px, …] … 位置指定(従来)
@@ -182,6 +183,7 @@
182
183
  this.resizeRows = opts.resizeRows !== false;
183
184
  // 行/列ヘッダの表示(false で行番号列・ヘッダ行を隠す)
184
185
  this.rowHeaders = opts.rowHeaders !== false;
186
+ this.rowHeaderW = (opts.rowHeaderWidth | 0) || 40; // 行番号列の幅(px)。大量行で桁数が増える時に広げる
185
187
  this.colHeaders = opts.colHeaders !== false;
186
188
  // 行ヘッダの「つまみ」をドラッグして行を並べ替える(行番号列が必要)
187
189
  this.rowReorder = !!opts.rowReorder;
@@ -210,6 +212,17 @@
210
212
  this.minSpareRows = opts.minSpareRows | 0; // 末尾に常に確保する空行数
211
213
  this.autoWrapRow = !!opts.autoWrapRow; // Tab で行末→次行頭に折り返し
212
214
  this.autoWrapCol = !!opts.autoWrapCol; // Enter で列末→次列頭に折り返し
215
+ this.navSkipReadOnly = !!opts.navSkipReadOnly; // Tab/Enter 送りで readonly セルを飛ばす(伝票入力向け=編集する所だけ移動)
216
+ // 仮想スクロール(簡易版・固定行高ウィンドウイング): 大量行で「可視窓+バッファ」だけ描画。
217
+ // 全行は data に保持・行座標は絶対のまま、tbody は窓ぶん+上下スペーサ行のみ。幾何はモデル(一定行高)。
218
+ // 制約(v1): 行高一定/セル結合・固定行列・折り返し非対応。指定時は無効化して警告。OFF時は一切作動せず従来通り。
219
+ this.virtual = !!opts.virtual;
220
+ this._vBuf = (opts.virtual && typeof opts.virtual === 'object' && opts.virtual.buffer) || 6; // 窓の上下バッファ行数
221
+ this._vStart = 0; this._vEnd = 0; this._vRAF = 0; this._vRendered = false;
222
+ if (this.virtual && (this.frozenRows || this.frozenCols || this.wordWrap)) {
223
+ try { console.warn('[TssGrid] virtual:true は固定行列/折り返し非対応のため、それらを無効化しました'); } catch (_) {}
224
+ this.frozenRows = 0; this.frozenCols = 0; this.wordWrap = false;
225
+ }
213
226
  // 行/列数の下限・上限(挿入/削除の境界。min は初期パディングにも使う)
214
227
  this.minRows = opts.minRows | 0; this.maxRows = opts.maxRows | 0;
215
228
  this.minCols = opts.minCols | 0; this.maxCols = opts.maxCols | 0;
@@ -508,6 +521,7 @@
508
521
  destroy() {
509
522
  if (this._destroyed) return;
510
523
  this._destroyed = true;
524
+ if (this._vRAF) { try { cancelAnimationFrame(this._vRAF); } catch (_) {} this._vRAF = 0; } // 仮想スクロールの保留フレームを破棄
511
525
  for (const p of this._plugins) { if (p.handle && typeof p.handle.destroy === 'function') { try { p.handle.destroy(); } catch (_) {} } }
512
526
  this._plugins = [];
513
527
  if (this._ro) { try { this._ro.disconnect(); } catch (_) {} this._ro = null; }
@@ -968,7 +982,7 @@
968
982
 
969
983
  _colWidth(c) { return this.colW[c] != null ? this.colW[c] : this.defColW; }
970
984
  _rowHeight(r) { return this.rowH[r] != null ? this.rowH[r] : this.defRowH; }
971
- _rhW() { return this.rowHeaders ? 40 : 0; } // 行ヘッダ列の幅(非表示なら 0)
985
+ _rhW() { return this.rowHeaders ? this.rowHeaderW : 0; } // 行ヘッダ列の幅(非表示なら 0)
972
986
  // ---- 列の非表示 ----
973
987
  _isHidden(c) { return this.hiddenCols.has(c); }
974
988
  _visCols() { const a = []; for (let c = 0; c < this.COLS; c++) if (!this.hiddenCols.has(c)) a.push(c); return a; }
@@ -1094,10 +1108,101 @@
1094
1108
  top += tr.offsetHeight;
1095
1109
  });
1096
1110
  }
1111
+ // 1行ぶんの <tr>…</tr> を生成(buildTable の本体ループと、仮想スクロールの窓再描画 _renderWindow で共用)。
1112
+ _rowHTML(r) {
1113
+ const rh = this.rowHeaders;
1114
+ const rgrip = (rh && this.resizeRows) ? '<div class="tg-rowgrip"></div>' : '';
1115
+ const dgrip = (rh && this.rowReorder && !this._hasVMerge()) ? '<span class="tg-rowmove" title="ドラッグで行を移動">⠿</span>' : ''; // 横結合は行と一緒に移動可/縦結合中は不可
1116
+ const rcls = (r < this.frozenRows) ? (' tg-frozen-row' + (r === this.frozenRows - 1 ? ' tg-frozen-row-edge' : '')) : ''; // 行固定
1117
+ let html = '<tr data-r="' + r + '" class="tg-row' + rcls + '" style="height:' + this._rowHeight(r) + 'px">' + (rh ? '<th class="rowhead" data-r="' + r + '">' + dgrip + (r + 1) + rgrip + '</th>' : '');
1118
+ for (let c = 0; c < this.COLS; c++) {
1119
+ if (this._merges && this._isCovered(r, c)) continue; // 結合に覆われた従属セルは td を出さない(アンカーが colspan で覆う)
1120
+ const cs = this._merges ? this._colspanAt(r, c) : 1; // 結合アンカーは colspan で従属列ぶんを覆う
1121
+ const rs = this._merges ? this._rowspanAt(r, c) : 1; // 縦結合は rowspan で従属行ぶんを覆う
1122
+ const cls = [];
1123
+ if (cs > 1 || rs > 1) cls.push('tg-merged'); // 結合アンカー(CSS で中央寄せ等のフック)
1124
+ if (c < this.frozenCols) { cls.push('tg-frozen'); if (c === this.frozenCols - 1) cls.push('tg-frozen-edge'); }
1125
+ if (this._isHidden(c)) cls.push('tg-hidden'); // 非表示列
1126
+ if (this.colType(c) === 'dropdown') cls.push('tg-dropdown'); // ▾ キャレット表示用
1127
+ if (this.colType(c) === 'number') cls.push('tg-num'); // 数値は右寄せ
1128
+ if (this._isReadOnly(r, c)) cls.push('tg-readonly'); // 読み取り専用(淡色+カーソル既定)
1129
+ if (this.colCfg(c).wordWrap) cls.push('tg-wrap-cell'); // 列単位の折り返し
1130
+ if (this.data[r][c] === '' && this._placeholder(c) != null) cls.push('tg-placeholder'); // 空セルのヒント
1131
+ const al = this._cellAlign(r, c); // 整列(列既定+セル上書き)
1132
+ if (al.h) cls.push('tg-h-' + al.h); if (al.v) cls.push('tg-v-' + al.v);
1133
+ let titleAttr = '';
1134
+ if (this._marksInvalid) { const _iv = this._coerceCell(r, c, this.data[r][c]); if (!_iv.ok) { cls.push('tg-invalid'); titleAttr = ' data-err-level="' + TssGrid.esc(_iv.level || 'error') + '"'; if (this.invalidTitle) titleAttr += ' title="' + TssGrid.esc(_iv.message || '入力値が不正です') + '"'; } }
1135
+ const ed = this.colCfg(c).editor; // カスタムエディタが icon を申告していれば右端に表示
1136
+ const ico = (ed && ed.icon) ? ' data-ico="' + TssGrid.esc(ed.icon) + '"' : '';
1137
+ if (ico) cls.push('tg-haspicker');
1138
+ const ccls = (this.cellClass || this.colCfg(c).cellClass) ? this._cellClasses(r, c) : []; // 条件付き書式
1139
+ for (const k of ccls) cls.push(k);
1140
+ const dccls = ccls.length ? ' data-ccls="' + TssGrid.esc(ccls.join(' ')) + '"' : '';
1141
+ let sty = ''; // セル単位インラインスタイル(連続色・データバー幅などCSS変数も)
1142
+ if (this.cellStyle || this.colCfg(c).cellStyle) {
1143
+ const so = this._cellStyleObj(r, c), sk = Object.keys(so);
1144
+ if (sk.length) { let css = ''; for (const k of sk) css += k + ':' + so[k] + ';'; sty = ' style="' + TssGrid.esc(css) + '" data-cstyle="' + TssGrid.esc(sk.join(' ')) + '"'; }
1145
+ }
1146
+ const a = cls.length ? ' class="' + cls.join(' ') + '"' : '';
1147
+ const csAttr = (cs > 1 ? ' colspan="' + cs + '"' : '') + (rs > 1 ? ' rowspan="' + rs + '"' : '');
1148
+ html += '<td' + a + ico + dccls + sty + titleAttr + csAttr + ' data-r="' + r + '" data-c="' + c + '">' + this._cellHTML(r, c) + '</td>';
1149
+ }
1150
+ return html + '</tr>';
1151
+ }
1152
+ // 仮想スクロール: 現在の scrollTop から描画すべき窓 [start,end)(絶対行)を算出。バッファ込み。
1153
+ _vWindow() {
1154
+ const rowH = this.defRowH;
1155
+ const viewH = (this.wrap && this.wrap.clientHeight) || parseInt(this.height, 10) || 400;
1156
+ const visible = Math.ceil(viewH / rowH) + 1;
1157
+ const scrollTop = this.wrap ? this.wrap.scrollTop : 0;
1158
+ let start = Math.floor(scrollTop / rowH) - this._vBuf;
1159
+ if (start < 0) start = 0;
1160
+ let end = start + visible + this._vBuf * 2;
1161
+ if (end > this.ROWS) end = this.ROWS;
1162
+ return { start, end };
1163
+ }
1164
+ // 仮想スクロール: tbody を窓ぶん+上下スペーサ行に差し替え(buildTable せず軽量再描画)。
1165
+ _renderWindow(win, force) {
1166
+ if (!force && this._vRendered && win.start === this._vStart && win.end === this._vEnd) return false;
1167
+ const tbody = this.table && this.table.querySelector('tbody');
1168
+ if (!tbody) return false;
1169
+ const rh = this.rowHeaders, colsSpan = this.COLS + (rh ? 1 : 0);
1170
+ const topH = win.start * this.defRowH, botH = (this.ROWS - win.end) * this.defRowH;
1171
+ let html = '';
1172
+ if (topH > 0) html += '<tr class="tg-vspace" aria-hidden="true" style="height:' + topH + 'px"><td colspan="' + colsSpan + '"></td></tr>';
1173
+ for (let r = win.start; r < win.end; r++) html += this._rowHTML(r);
1174
+ if (botH > 0) html += '<tr class="tg-vspace" aria-hidden="true" style="height:' + botH + 'px"><td colspan="' + colsSpan + '"></td></tr>';
1175
+ tbody.innerHTML = html;
1176
+ this._vStart = win.start; this._vEnd = win.end; this._vRendered = true;
1177
+ this._syncHeaderCheckboxes();
1178
+ this.updateSelectionUI(); // セルが作り直されたので窓内の選択シェード/オーバーレイを再適用
1179
+ return true;
1180
+ }
1181
+ // 仮想スクロール: スクロールで窓がずれたら再描画(rAF スロットル)。
1182
+ _vSync() {
1183
+ if (!this.virtual || this._vRAF) return;
1184
+ this._vRAF = requestAnimationFrame(() => {
1185
+ this._vRAF = 0;
1186
+ if (this._destroyed) return;
1187
+ this._renderWindow(this._vWindow(), false);
1188
+ });
1189
+ }
1190
+ // 仮想スクロール: 行 r を可視域に入れる(scrollTop 調整→窓を同期描画)。setActive/setExtent から。
1191
+ _vEnsureVisible(r) {
1192
+ if (!this.virtual || !this.wrap) return;
1193
+ const rowH = this.defRowH, head = this._theadH || 0;
1194
+ const rowTop = head + r * rowH;
1195
+ const cur = this.wrap.scrollTop, viewH = this.wrap.clientHeight || 400;
1196
+ let st = cur;
1197
+ if (rowTop < cur + head) st = rowTop - head; // 上のヘッダ下に隠れてる→上端へ
1198
+ else if (rowTop + rowH > cur + viewH) st = rowTop + rowH - viewH; // 下に隠れてる→下端へ
1199
+ if (st !== cur) this.wrap.scrollTop = st;
1200
+ this._renderWindow(this._vWindow(), false); // 窓を即同期(place 前に DOM を確定)
1201
+ }
1097
1202
  buildTable() {
1098
1203
  this._selhdrFg = null; // 選択ヘッダ文字色キャッシュ破棄(テーマ変更に追従)
1099
1204
  const rh = this.rowHeaders;
1100
- let html = '<colgroup>' + (rh ? '<col style="width:40px">' : '');
1205
+ let html = '<colgroup>' + (rh ? '<col style="width:' + this.rowHeaderW + 'px">' : '');
1101
1206
  for (let c = 0; c < this.COLS; c++) html += '<col data-c="' + c + '"' + (this._isHidden(c) ? ' class="tg-hidden"' : '') + ' style="width:' + (this._isHidden(c) ? 0 : this._colWidth(c)) + 'px">';
1102
1207
  html += '</colgroup>';
1103
1208
  if (this.colHeaders) {
@@ -1126,44 +1231,15 @@
1126
1231
  html += '</thead>';
1127
1232
  }
1128
1233
  html += '<tbody>';
1129
- for (let r = 0; r < this.ROWS; r++) {
1130
- const rgrip = (rh && this.resizeRows) ? '<div class="tg-rowgrip"></div>' : '';
1131
- const dgrip = (rh && this.rowReorder && !this._hasVMerge()) ? '<span class="tg-rowmove" title="ドラッグで行を移動">⠿</span>' : ''; // 横結合は行と一緒に移動可/縦結合中は不可
1132
- const rcls = (r < this.frozenRows) ? (' tg-frozen-row' + (r === this.frozenRows - 1 ? ' tg-frozen-row-edge' : '')) : ''; // 行固定
1133
- html += '<tr data-r="' + r + '" class="tg-row' + rcls + '" style="height:' + this._rowHeight(r) + 'px">' + (rh ? '<th class="rowhead" data-r="' + r + '">' + dgrip + (r + 1) + rgrip + '</th>' : '');
1134
- for (let c = 0; c < this.COLS; c++) {
1135
- if (this._merges && this._isCovered(r, c)) continue; // 結合に覆われた従属セルは td を出さない(アンカーが colspan で覆う)
1136
- const cs = this._merges ? this._colspanAt(r, c) : 1; // 結合アンカーは colspan で従属列ぶんを覆う
1137
- const rs = this._merges ? this._rowspanAt(r, c) : 1; // 縦結合は rowspan で従属行ぶんを覆う
1138
- const cls = [];
1139
- if (cs > 1 || rs > 1) cls.push('tg-merged'); // 結合アンカー(CSS で中央寄せ等のフック)
1140
- if (c < this.frozenCols) { cls.push('tg-frozen'); if (c === this.frozenCols - 1) cls.push('tg-frozen-edge'); }
1141
- if (this._isHidden(c)) cls.push('tg-hidden'); // 非表示列
1142
- if (this.colType(c) === 'dropdown') cls.push('tg-dropdown'); // ▾ キャレット表示用
1143
- if (this.colType(c) === 'number') cls.push('tg-num'); // 数値は右寄せ
1144
- if (this._isReadOnly(r, c)) cls.push('tg-readonly'); // 読み取り専用(淡色+カーソル既定)
1145
- if (this.colCfg(c).wordWrap) cls.push('tg-wrap-cell'); // 列単位の折り返し
1146
- if (this.data[r][c] === '' && this._placeholder(c) != null) cls.push('tg-placeholder'); // 空セルのヒント
1147
- const al = this._cellAlign(r, c); // 整列(列既定+セル上書き)
1148
- if (al.h) cls.push('tg-h-' + al.h); if (al.v) cls.push('tg-v-' + al.v);
1149
- let titleAttr = '';
1150
- if (this._marksInvalid) { const _iv = this._coerceCell(r, c, this.data[r][c]); if (!_iv.ok) { cls.push('tg-invalid'); titleAttr = ' data-err-level="' + TssGrid.esc(_iv.level || 'error') + '"'; if (this.invalidTitle) titleAttr += ' title="' + TssGrid.esc(_iv.message || '入力値が不正です') + '"'; } }
1151
- const ed = this.colCfg(c).editor; // カスタムエディタが icon を申告していれば右端に表示
1152
- const ico = (ed && ed.icon) ? ' data-ico="' + TssGrid.esc(ed.icon) + '"' : '';
1153
- if (ico) cls.push('tg-haspicker');
1154
- const ccls = (this.cellClass || this.colCfg(c).cellClass) ? this._cellClasses(r, c) : []; // 条件付き書式
1155
- for (const k of ccls) cls.push(k);
1156
- const dccls = ccls.length ? ' data-ccls="' + TssGrid.esc(ccls.join(' ')) + '"' : '';
1157
- let sty = ''; // セル単位インラインスタイル(連続色・データバー幅などCSS変数も)
1158
- if (this.cellStyle || this.colCfg(c).cellStyle) {
1159
- const so = this._cellStyleObj(r, c), sk = Object.keys(so);
1160
- if (sk.length) { let css = ''; for (const k of sk) css += k + ':' + so[k] + ';'; sty = ' style="' + TssGrid.esc(css) + '" data-cstyle="' + TssGrid.esc(sk.join(' ')) + '"'; }
1161
- }
1162
- const a = cls.length ? ' class="' + cls.join(' ') + '"' : '';
1163
- const csAttr = (cs > 1 ? ' colspan="' + cs + '"' : '') + (rs > 1 ? ' rowspan="' + rs + '"' : '');
1164
- html += '<td' + a + ico + dccls + sty + titleAttr + csAttr + ' data-r="' + r + '" data-c="' + c + '">' + this._cellHTML(r, c) + '</td>';
1165
- }
1166
- html += '</tr>';
1234
+ if (this.virtual) {
1235
+ const win = this._vWindow(); this._vStart = win.start; this._vEnd = win.end; this._vRendered = true;
1236
+ const colsSpan = this.COLS + (rh ? 1 : 0);
1237
+ const topH = win.start * this.defRowH, botH = (this.ROWS - win.end) * this.defRowH;
1238
+ if (topH > 0) html += '<tr class="tg-vspace" aria-hidden="true" style="height:' + topH + 'px"><td colspan="' + colsSpan + '"></td></tr>';
1239
+ for (let r = win.start; r < win.end; r++) html += this._rowHTML(r);
1240
+ if (botH > 0) html += '<tr class="tg-vspace" aria-hidden="true" style="height:' + botH + 'px"><td colspan="' + colsSpan + '"></td></tr>';
1241
+ } else {
1242
+ for (let r = 0; r < this.ROWS; r++) html += this._rowHTML(r);
1167
1243
  }
1168
1244
  html += '</tbody>';
1169
1245
  this.table.innerHTML = html;
@@ -1260,16 +1336,27 @@
1260
1336
  // 通常は buildTable/_reflow(ResizeObserver が table/wrap を監視)で自動更新される)。
1261
1337
  refreshGeometry() { this._buildGeomCache(); this.updateSelectionUI(); }
1262
1338
  place(elem, td) {
1263
- const r = +td.dataset.r, c = +td.dataset.c, R = this._cellRectAuto(r, c);
1339
+ // 仮想スクロールで active が窓外だと td=null。その場合は active 座標+モデル幾何で配置(窓外なら overflow で隠れる)。
1340
+ const r = td ? +td.dataset.r : this.active.r, c = td ? +td.dataset.c : this.active.c, R = this._cellRectAuto(r, c);
1264
1341
  elem.style.left = R.left + 'px'; elem.style.top = R.top + 'px';
1265
1342
  elem.style.width = R.width + 'px'; elem.style.height = R.height + 'px';
1266
1343
  this._clipOverlay(elem, R.left, R.top, c, r);
1267
1344
  }
1268
1345
  placeRect(elem, r0, c0, r1, c1) {
1269
- const A = this._cellRectAuto(r0, c0), B = this._cellRectAuto(r1, c1);
1270
- elem.style.left = A.left + 'px'; elem.style.top = A.top + 'px';
1271
- elem.style.width = (B.left + B.width - A.left) + 'px'; elem.style.height = (B.top + B.height - A.top) + 'px';
1272
- this._clipOverlay(elem, A.left, A.top, c0, r0);
1346
+ const L = this._colLefts, T = this._rowTops;
1347
+ let left, top, w, h;
1348
+ // 列/行/全選択(_headerSel)時は、角セルの結合アンカー解決で枠が範囲外へ膨らむのを避け、
1349
+ // 列左/行上の累積で「実列/行ピッタリ」にクランプ(非固定領域のみ。固定行列が絡む場合は従来へフォールバック)。
1350
+ if (this._headerSel && L && T && r0 >= this.frozenRows && c0 >= this.frozenCols) {
1351
+ left = L[c0]; top = T[r0];
1352
+ w = L[Math.min(c1 + 1, this.COLS)] - left; h = T[Math.min(r1 + 1, this.ROWS)] - top;
1353
+ } else {
1354
+ const A = this._cellRectAuto(r0, c0), B = this._cellRectAuto(r1, c1);
1355
+ left = A.left; top = A.top; w = B.left + B.width - A.left; h = B.top + B.height - A.top;
1356
+ }
1357
+ elem.style.left = left + 'px'; elem.style.top = top + 'px';
1358
+ elem.style.width = w + 'px'; elem.style.height = h + 'px';
1359
+ this._clipOverlay(elem, left, top, c0, r0);
1273
1360
  }
1274
1361
  // ---- 幾何キャッシュ(step2: DOM非依存幾何の土台)----
1275
1362
  // buildTable / _reflow で1回だけ実測して列左・行上の累積を作る。
@@ -1278,9 +1365,15 @@
1278
1365
  // 行高は wordWrap で、列幅は stretch の DOM 改変で可変なので、モデル計算でなく実測が要る。
1279
1366
  _buildGeomCache() {
1280
1367
  if (!this.wrap || !this.table) { this._rowTops = this._colLefts = null; return; }
1281
- const trs = this.table.querySelectorAll('tbody tr');
1282
- const RT = new Array(this.ROWS + 1); RT[0] = this._theadH || 0;
1283
- for (let r = 0; r < this.ROWS; r++) { const tr = trs[r]; RT[r + 1] = RT[r] + (tr ? tr.getBoundingClientRect().height : this._rowHeight(r)); }
1368
+ const RT = new Array(this.ROWS + 1);
1369
+ if (this.virtual) {
1370
+ const base = this._theadH || 0, rowH = this.defRowH; // 仮想モードは一定行高=モデルで全行(窓外も算出可)
1371
+ for (let r = 0; r <= this.ROWS; r++) RT[r] = base + r * rowH;
1372
+ } else {
1373
+ const trs = this.table.querySelectorAll('tbody tr');
1374
+ RT[0] = this._theadH || 0;
1375
+ for (let r = 0; r < this.ROWS; r++) { const tr = trs[r]; RT[r + 1] = RT[r] + (tr ? tr.getBoundingClientRect().height : this._rowHeight(r)); }
1376
+ }
1284
1377
  this._rowTops = RT;
1285
1378
  // 列幅: リーフヘッダ th(マージ無しで確実)→ 無ければ colgroup col。stretch 後の実幅を拾う。
1286
1379
  const colEl = c => (this.colHeaders ? this.table.querySelector('thead th[data-c="' + c + '"]') : null) || this.table.querySelector('colgroup col[data-c="' + c + '"]');
@@ -1335,12 +1428,22 @@
1335
1428
  highlightRange() {
1336
1429
  this.table.querySelectorAll('td.sel, th.selhdr').forEach(el => el.classList.remove('sel', 'selhdr'));
1337
1430
  const { r0, r1, c0, c1 } = this.rectRange();
1338
- // 選択範囲が及ぶ行/列ヘッダをハイライト(Excel 風のアフォーダンス)
1431
+ // 選択範囲が及ぶ行/列ヘッダをハイライト(Excel 風のアフォーダンス)。仮想時は行ヘッダを窓内にクランプ(大量行で querySelector を無駄打ちしない)。
1432
+ const hlo = this.virtual ? Math.max(r0, this._vStart) : r0;
1433
+ const hhi = this.virtual ? Math.min(r1, this._vEnd - 1) : r1;
1339
1434
  for (let c = c0; c <= c1; c++) { const th = this.table.querySelector('thead th[data-c="' + c + '"]'); if (th) th.classList.add('selhdr'); }
1340
- for (let r = r0; r <= r1; r++) { const th = this.table.querySelector('th.rowhead[data-r="' + r + '"]'); if (th) th.classList.add('selhdr'); }
1435
+ for (let r = hlo; r <= hhi; r++) { const th = this.table.querySelector('th.rowhead[data-r="' + r + '"]'); if (th) th.classList.add('selhdr'); }
1341
1436
  this.wrap.style.setProperty('--tg-selhdr-fg', this._selhdrTextColor()); // 選択ヘッダ文字色を背景輝度から自動(暗背景→白)
1342
1437
  if (r0 === r1 && c0 === c1) return;
1343
- for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) this.cellEl(r, c).classList.add('sel');
1438
+ const clampMerge = this._headerSel && this._merges; // 列/行選択では「範囲に収まらない結合」は塗らない(帯を塗り広げない)
1439
+ // 仮想スクロール: 窓内の行だけシェード(範囲枠 selrange は placeRect がモデル幾何で全域を描く)。
1440
+ for (let r = hlo; r <= hhi; r++) for (let c = c0; c <= c1; c++) {
1441
+ if (clampMerge) {
1442
+ const a = this._anchorOf(r, c), mc1 = a.c + this._colspanAt(a.r, a.c) - 1, mr1 = a.r + this._rowspanAt(a.r, a.c) - 1;
1443
+ if (a.r < r0 || mr1 > r1 || a.c < c0 || mc1 > c1) continue; // 結合が選択範囲に収まらない=塗らない
1444
+ }
1445
+ const el = this.cellEl(r, c); if (el) el.classList.add('sel');
1446
+ }
1344
1447
  }
1345
1448
  // 選択ヘッダの文字色を --tg-selhdr-bg の輝度から自動決定(暗背景→白/明背景→濃紺)。
1346
1449
  // 既定の明るい選択色は従来どおり濃紺、暗い選択ヘッダ(slate等)でも文字が読める。buildTable でキャッシュ破棄。
@@ -1416,39 +1519,45 @@
1416
1519
  const a = { r: this.clampR(r), c: this._snapVisCol(this.clampC(c)) }; // 隠し列にはカーソルを置かない
1417
1520
  if (this._merges) { const an = this._anchorOf(a.r, a.c); a.r = an.r; a.c = an.c; } // 結合内はアンカーへ
1418
1521
  if (!this._beforeSel(a, a)) return;
1522
+ this._headerSel = null;
1419
1523
  this.active = a; this.extent = { ...a };
1524
+ if (this.virtual) this._vEnsureVisible(a.r); // 窓に入れてから placement(窓外なら activeTd=null になるため)
1420
1525
  this.place(this.editor, this.activeTd()); this.toNav(); this.editor.focus(); this.updateSelectionUI();
1421
- this.activeTd().scrollIntoView({ block: 'nearest', inline: 'nearest' });
1526
+ if (!this.virtual) this.activeTd().scrollIntoView({ block: 'nearest', inline: 'nearest' }); // 仮想は _vEnsureVisible が可視化済み
1422
1527
  this._fireSelection();
1423
1528
  }
1424
1529
  setExtent(r, c) {
1425
1530
  const e = { r: this.clampR(r), c: this.clampC(c) };
1426
1531
  if (!this._beforeSel(this.active, e)) return;
1427
- this.extent = e; this.updateSelectionUI();
1428
- this.cellEl(this.extent.r, this.extent.c).scrollIntoView({ block: 'nearest', inline: 'nearest' });
1532
+ this.extent = e;
1533
+ if (this.virtual) this._vEnsureVisible(e.r);
1534
+ this.updateSelectionUI();
1535
+ if (!this.virtual) this.cellEl(this.extent.r, this.extent.c).scrollIntoView({ block: 'nearest', inline: 'nearest' });
1429
1536
  this._fireSelection();
1430
1537
  }
1431
1538
  selectRect(r0, c0, r1, c1) {
1432
1539
  const a = { r: r0, c: c0 }, e = { r: r1, c: c1 };
1433
1540
  if (!this._beforeSel(a, e)) return;
1541
+ this._headerSel = null;
1434
1542
  this.active = a; this.extent = e;
1435
1543
  this.place(this.editor, this.activeTd()); this.toNav(); this.editor.focus(); this.updateSelectionUI();
1436
1544
  this._fireSelection();
1437
1545
  }
1438
1546
  focusAndSelect(sel) {
1439
1547
  this.active = { ...sel.active }; this.extent = { ...sel.extent };
1548
+ if (this.virtual) this._vEnsureVisible(this.active.r);
1440
1549
  this.place(this.editor, this.activeTd()); this.toNav(); this.editor.focus(); this.updateSelectionUI();
1441
- this.activeTd().scrollIntoView({ block: 'nearest', inline: 'nearest' });
1550
+ if (!this.virtual) this.activeTd().scrollIntoView({ block: 'nearest', inline: 'nearest' });
1442
1551
  this._fireSelection();
1443
1552
  }
1444
1553
 
1445
1554
  // ---- 行 / 列 / 全体 の選択(ヘッダクリック) ----
1446
- selectCol(c) { c = this.clampC(c); this._colAnchor = c; this.selectRect(0, c, this.ROWS - 1, c); }
1447
- selectRow(r) { r = this.clampR(r); this._rowAnchor = r; this.selectRect(r, 0, r, this.COLS - 1); }
1448
- selectAll() { this._colAnchor = 0; this._rowAnchor = 0; this.selectRect(0, 0, this.ROWS - 1, this.COLS - 1); }
1449
- _extendCol(c) { const a = { r: 0, c: this._colAnchor }, e = { r: this.ROWS - 1, c: this.clampC(c) }; if (!this._beforeSel(a, e)) return; this.active = a; this.extent = e;
1555
+ selectCol(c) { c = this.clampC(c); this._colAnchor = c; this.selectRect(0, c, this.ROWS - 1, c); this._headerSel = 'col'; this.updateSelectionUI(); }
1556
+ selectRow(r) { r = this.clampR(r); this._rowAnchor = r; this.selectRect(r, 0, r, this.COLS - 1); this._headerSel = 'row'; this.updateSelectionUI(); }
1557
+ selectAll() { this._colAnchor = 0; this._rowAnchor = 0; this.selectRect(0, 0, this.ROWS - 1, this.COLS - 1); this._headerSel = 'all'; this.updateSelectionUI(); }
1558
+ _extendCol(c) { const a = { r: 0, c: this._colAnchor }, e = { r: this.ROWS - 1, c: this.clampC(c) }; if (!this._beforeSel(a, e)) return; this._headerSel = 'col'; this.active = a; this.extent = e;
1450
1559
  this.place(this.editor, this.activeTd()); this.toNav(); this.updateSelectionUI(); this._fireSelection(); }
1451
- _extendRow(r) { const a = { r: this._rowAnchor, c: 0 }, e = { r: this.clampR(r), c: this.COLS - 1 }; if (!this._beforeSel(a, e)) return; this.active = a; this.extent = e;
1560
+ _extendRow(r) { const a = { r: this._rowAnchor, c: 0 }, e = { r: this.clampR(r), c: this.COLS - 1 }; if (!this._beforeSel(a, e)) return; this._headerSel = 'row'; this.active = a; this.extent = e;
1452
1561
  this.place(this.editor, this.activeTd()); this.toNav(); this.updateSelectionUI(); this._fireSelection(); }
1453
1562
  // ---- 列幅 / 行高 のリサイズ ----
1454
1563
  setColWidth(c, w) {
@@ -2093,6 +2202,7 @@
2093
2202
  _reflow() {
2094
2203
  if (this.stretchH !== 'none') this._applyStretch(); // 枠幅が変わったら余白を再配分
2095
2204
  this._buildGeomCache(); // 幅/枠が変わったので実測幾何を取り直す(リサイズ/ResizeObserver/window resize)
2205
+ if (this.virtual) this._renderWindow(this._vWindow(), false); // 枠高が変われば窓行数も変わる=窓を同期
2096
2206
  const td = this.activeTd(); if (!td) return;
2097
2207
  this.place(this.editor, td);
2098
2208
  if (this.select && this.select.style.display !== 'none') this.place(this.select, td);
@@ -2100,6 +2210,7 @@
2100
2210
  }
2101
2211
 
2102
2212
  _bind() {
2213
+ if (this.virtual) this.wrap.addEventListener('scroll', () => this._vSync(), { passive: true }); // 仮想スクロール: 窓再描画
2103
2214
  this.editor.addEventListener('keydown', (e) => this._onKey(e));
2104
2215
  this.editor.addEventListener('compositionstart', () => {
2105
2216
  if (this.mode !== 'nav') return;
@@ -2366,17 +2477,26 @@
2366
2477
  let [dr, dc] = D[dir] || D.down;
2367
2478
  if (reverse) { dr = -dr; dc = -dc; }
2368
2479
  let r = this.active.r, c = this.active.c;
2369
- if (dc !== 0) { // 横移動は隠し列をスキップ+結合幅を跨ぐ
2370
- const nc = this._stepColM(r, c, dc > 0 ? 1 : -1);
2371
- if (nc !== c) c = nc;
2372
- else if (this.autoWrapRow) { c = dc > 0 ? this._firstVisCol() : this._lastVisCol(); r += dc > 0 ? 1 : -1; }
2373
- }
2374
- if (dr !== 0) {
2375
- r = this._stepRowM(r, c, dr > 0 ? 1 : -1); // 縦移動は結合高を跨ぐ
2376
- if (this.autoWrapCol) { // 縦の端で列送り(送り先も可視列)
2377
- if (r >= this.ROWS) { r = 0; c = this._stepVisCol(c, 1); }
2378
- else if (r < 0) { r = this.ROWS - 1; c = this._stepVisCol(c, -1); }
2480
+ // navSkipReadOnly 時は readonly セルを飛ばして次の編集可能セルまで送る(伝票入力向け)。
2481
+ // OFF 時はループは1回で抜け=従来挙動と完全同一(回帰ゼロ)。
2482
+ for (let g = 0, lim = this.ROWS * this.COLS + 2; g < lim; g++) {
2483
+ let nr = r, nc = c;
2484
+ if (dc !== 0) { // 横移動は隠し列をスキップ+結合幅を跨ぐ
2485
+ const stepc = this._stepColM(r, c, dc > 0 ? 1 : -1);
2486
+ if (stepc !== c) nc = stepc;
2487
+ else if (this.autoWrapRow) { nc = dc > 0 ? this._firstVisCol() : this._lastVisCol(); nr = r + (dc > 0 ? 1 : -1); }
2488
+ }
2489
+ if (dr !== 0) {
2490
+ nr = this._stepRowM(r, c, dr > 0 ? 1 : -1); // 縦移動は結合高を跨ぐ
2491
+ if (this.autoWrapCol) { // 縦の端で列送り(送り先も可視列)
2492
+ if (nr >= this.ROWS) { nr = 0; nc = this._stepVisCol(c, 1); }
2493
+ else if (nr < 0) { nr = this.ROWS - 1; nc = this._stepVisCol(c, -1); }
2494
+ }
2379
2495
  }
2496
+ if (nr === r && nc === c) break; // 端で動かない=終了
2497
+ r = nr; c = nc;
2498
+ if (r < 0 || r >= this.ROWS || c < 0 || c >= this.COLS) break; // 範囲外=setActive のクランプに委ねる
2499
+ if (!this.navSkipReadOnly || !this._isReadOnly(r, c)) break; // skip無効 or readonly でない=確定
2380
2500
  }
2381
2501
  this.setActive(r, c);
2382
2502
  }
@@ -2391,7 +2511,11 @@
2391
2511
  if (composing) return;
2392
2512
  if (e.key === 'Enter') { e.preventDefault(); this.commit(); this._advance('enter', e.shiftKey); return; }
2393
2513
  if (e.key === 'Tab') { e.preventDefault(); this.commit(); this._advance('tab', e.shiftKey); return; }
2394
- if (e.key === 'Escape') {
2514
+ // Esc/編集中の Ctrl+Z = この編集をキャンセルして元値に戻す(nav へ)。
2515
+ // input ベース編集では「打ち始め」時に元値が input に入らないためネイティブ undo では元値に戻せない。
2516
+ // ここで元値(this.data[r][c]=未確定)に戻す=Excel流。続けて Ctrl+Z すると nav の history.undo で前の確定操作へ連鎖。
2517
+ const ctrl = e.ctrlKey || e.metaKey;
2518
+ if (e.key === 'Escape' || (ctrl && !e.shiftKey && e.key.toLowerCase() === 'z')) {
2395
2519
  e.preventDefault();
2396
2520
  const r = this.active.r, c = this.active.c, v = this.data[r][c];
2397
2521
  if (this.onEditEnd) { try { this.onEditEnd({ r, c, oldValue: v, newValue: v, canceled: true }); } catch (_) {} }