@squadbase/vantage 0.6.2-dev1.b332e5a → 0.6.2-dev1.d7c0911

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.
Files changed (212) hide show
  1. package/dist/add-4GHF2TX2.js +6 -0
  2. package/dist/add-4GHF2TX2.js.map +1 -0
  3. package/dist/{build-TVKFIVFQ.js → build-X5NVCQKT.js} +7 -7
  4. package/dist/{build-TVKFIVFQ.js.map → build-X5NVCQKT.js.map} +1 -1
  5. package/dist/{check-3KHZXZMQ.js → check-ZBIH422T.js} +5 -5
  6. package/dist/{check-3KHZXZMQ.js.map → check-ZBIH422T.js.map} +1 -1
  7. package/dist/chunk-2ICNHUG6.js +1820 -0
  8. package/dist/chunk-2ICNHUG6.js.map +1 -0
  9. package/dist/{chunk-5ZOP4SQR.js → chunk-2N72FKLB.js} +12 -7
  10. package/dist/chunk-2N72FKLB.js.map +1 -0
  11. package/dist/{chunk-P7W4LR3Z.js → chunk-7DZGJQ3P.js} +3 -3
  12. package/dist/{chunk-P7W4LR3Z.js.map → chunk-7DZGJQ3P.js.map} +1 -1
  13. package/dist/{chunk-U2M6HGKQ.js → chunk-AABMKHEU.js} +4 -4
  14. package/dist/{chunk-U2M6HGKQ.js.map → chunk-AABMKHEU.js.map} +1 -1
  15. package/dist/{chunk-QI3XQKHS.js → chunk-GNXCX4WE.js} +10 -3
  16. package/dist/chunk-GNXCX4WE.js.map +1 -0
  17. package/dist/{chunk-PXRX5PEC.js → chunk-PDOBHMUQ.js} +3 -3
  18. package/dist/chunk-PDOBHMUQ.js.map +1 -0
  19. package/dist/{chunk-ZVANIULZ.js → chunk-RNTDBX6S.js} +4 -4
  20. package/dist/{chunk-ZVANIULZ.js.map → chunk-RNTDBX6S.js.map} +1 -1
  21. package/dist/{chunk-A2UUGASH.js → chunk-U736PVHY.js} +2 -2
  22. package/dist/chunk-U736PVHY.js.map +1 -0
  23. package/dist/{chunk-PVTHHV7X.js → chunk-V3LF7BE7.js} +27 -5
  24. package/dist/chunk-V3LF7BE7.js.map +1 -0
  25. package/dist/{chunk-LMUACBMA.js → chunk-WAMZWZJB.js} +53 -8
  26. package/dist/chunk-WAMZWZJB.js.map +1 -0
  27. package/dist/chunk-WRSX2MDK.js +7866 -0
  28. package/dist/chunk-WRSX2MDK.js.map +1 -0
  29. package/dist/{chunk-RPZAALCX.js → chunk-XPQSTZUU.js} +254 -164
  30. package/dist/chunk-XPQSTZUU.js.map +1 -0
  31. package/dist/{chunk-DTDVSFRY.js → chunk-XQUSGIDG.js} +17 -3
  32. package/dist/chunk-XQUSGIDG.js.map +1 -0
  33. package/dist/cli.js +12 -11
  34. package/dist/cli.js.map +1 -1
  35. package/dist/client/index.d.ts +32 -3
  36. package/dist/client/index.js +205 -12
  37. package/dist/client/index.js.map +1 -1
  38. package/dist/components/index.d.ts +1975 -28
  39. package/dist/components/index.js +4 -4
  40. package/dist/{dev-GDKATCC7.js → dev-4XEDTELV.js} +8 -8
  41. package/dist/dev-4XEDTELV.js.map +1 -0
  42. package/dist/doc/index.js +2 -2
  43. package/dist/doc/index.js.map +1 -1
  44. package/dist/{docs-EJJ3BECT.js → docs-JVP4RPKT.js} +3 -3
  45. package/dist/{docs-EJJ3BECT.js.map → docs-JVP4RPKT.js.map} +1 -1
  46. package/dist/{doctor-WQXPXP6K.js → doctor-5E2VEZPK.js} +5 -5
  47. package/dist/{doctor-WQXPXP6K.js.map → doctor-5E2VEZPK.js.map} +1 -1
  48. package/dist/markdown/index.js +1 -1
  49. package/dist/markdown/index.js.map +1 -1
  50. package/dist/{preview-OGMCNQUO.js → preview-T6CLSQVX.js} +3 -3
  51. package/dist/{preview-OGMCNQUO.js.map → preview-T6CLSQVX.js.map} +1 -1
  52. package/dist/query/index.d.ts +0 -11
  53. package/dist/query/index.js +2 -2
  54. package/dist/{routes-MWBWWOJ2.js → routes-3SGAFDMC.js} +4 -4
  55. package/dist/{routes-MWBWWOJ2.js.map → routes-3SGAFDMC.js.map} +1 -1
  56. package/dist/{search-6Y422SV6.js → search-5LC4XPB5.js} +3 -3
  57. package/dist/{search-6Y422SV6.js.map → search-5LC4XPB5.js.map} +1 -1
  58. package/dist/table/index.js +10 -9
  59. package/dist/table/index.js.map +1 -1
  60. package/dist/ui/index.d.ts +144 -11
  61. package/dist/ui/index.js +1339 -87
  62. package/dist/ui/index.js.map +1 -1
  63. package/dist/{upgrade-HCANX4GQ.js → upgrade-JHXZBGQS.js} +6 -6
  64. package/dist/{upgrade-HCANX4GQ.js.map → upgrade-JHXZBGQS.js.map} +1 -1
  65. package/dist/vite/index.d.ts +10 -0
  66. package/dist/vite/index.js +3 -3
  67. package/docs/en/components.md +29 -15
  68. package/docs/en/parts/data-table.md +22 -0
  69. package/docs/en/parts/delta.md +50 -0
  70. package/docs/en/parts/format.md +43 -0
  71. package/docs/en/parts/kpi-card.md +57 -0
  72. package/docs/en/parts/metric-value.md +3 -2
  73. package/docs/en/parts/sparkline.md +1 -1
  74. package/docs/en/parts/trend-indicator.md +17 -1
  75. package/docs/en/ui-and-theming.md +1 -1
  76. package/docs/index.json +366 -76
  77. package/docs/ja/changelog.md +31 -0
  78. package/docs/ja/components.md +57 -101
  79. package/docs/ja/page-types/csv.md +83 -0
  80. package/docs/ja/page-types/doc.md +83 -0
  81. package/docs/ja/page-types/excel.md +82 -0
  82. package/docs/ja/page-types/slide.md +132 -0
  83. package/docs/ja/page-types.md +47 -0
  84. package/docs/ja/parts/area-chart.md +61 -0
  85. package/docs/ja/parts/bar-chart.md +69 -0
  86. package/docs/ja/parts/bar-list.md +35 -0
  87. package/docs/ja/parts/category-bar.md +36 -0
  88. package/docs/ja/parts/data-table.md +22 -0
  89. package/docs/ja/parts/delta.md +50 -0
  90. package/docs/ja/parts/donut-chart.md +47 -0
  91. package/docs/ja/parts/echart.md +3 -0
  92. package/docs/ja/parts/format.md +44 -0
  93. package/docs/ja/parts/kpi-card.md +55 -0
  94. package/docs/ja/parts/line-chart.md +58 -0
  95. package/docs/ja/parts/metric-value.md +3 -2
  96. package/docs/ja/parts/progress-circle.md +34 -0
  97. package/docs/ja/parts/sparkline.md +2 -2
  98. package/docs/ja/parts/tracker.md +32 -0
  99. package/docs/ja/parts/trend-indicator.md +17 -1
  100. package/docs/ja/routing.md +8 -86
  101. package/docs/ja/ui/alert-dialog.md +41 -0
  102. package/docs/ja/ui/avatar.md +31 -0
  103. package/docs/ja/ui/combobox.md +74 -0
  104. package/docs/ja/ui/drawer.md +41 -0
  105. package/docs/ja/ui/field.md +42 -0
  106. package/docs/ja/ui/hover-card.md +21 -0
  107. package/docs/ja/ui/kbd.md +16 -0
  108. package/docs/ja/ui/pagination.md +36 -0
  109. package/docs/ja/ui/radio-group.md +21 -0
  110. package/docs/ja/ui/toast.md +46 -0
  111. package/docs/ja/ui-and-theming.md +14 -7
  112. package/package.json +13 -4
  113. package/registry/ui/accordion.tsx +76 -0
  114. package/registry/ui/alert-dialog.tsx +189 -0
  115. package/registry/ui/alert.tsx +80 -0
  116. package/registry/ui/app-shell.tsx +287 -0
  117. package/registry/ui/area-chart.tsx +3639 -0
  118. package/registry/ui/avatar.tsx +110 -0
  119. package/registry/ui/badge.tsx +56 -0
  120. package/registry/ui/bar-chart.tsx +3636 -0
  121. package/registry/ui/bar-list.tsx +3680 -0
  122. package/registry/ui/breadcrumb.tsx +128 -0
  123. package/registry/ui/button.tsx +62 -0
  124. package/registry/ui/calendar.tsx +223 -0
  125. package/registry/ui/card.tsx +107 -0
  126. package/registry/ui/category-bar.tsx +1255 -0
  127. package/registry/ui/chart-frame.tsx +1358 -0
  128. package/registry/ui/checkbox.tsx +30 -0
  129. package/registry/ui/collapsible.tsx +23 -0
  130. package/registry/ui/combobox.tsx +310 -0
  131. package/registry/ui/command.tsx +198 -0
  132. package/registry/ui/dashboard-card.tsx +217 -0
  133. package/registry/ui/data-table.tsx +693 -146
  134. package/registry/ui/date-range-picker.tsx +390 -0
  135. package/registry/ui/delta.tsx +188 -0
  136. package/registry/ui/dialog.tsx +161 -0
  137. package/registry/ui/donut-chart.tsx +3688 -0
  138. package/registry/ui/drawer.tsx +229 -0
  139. package/registry/ui/dropdown-menu.tsx +274 -0
  140. package/registry/ui/echart.tsx +890 -0
  141. package/registry/ui/field.tsx +240 -0
  142. package/registry/ui/filter-bar.tsx +392 -0
  143. package/registry/ui/funnel-steps.tsx +3541 -0
  144. package/registry/ui/heatmap.tsx +1752 -0
  145. package/registry/ui/hover-card.tsx +52 -0
  146. package/registry/ui/input-group.tsx +160 -0
  147. package/registry/ui/input.tsx +24 -0
  148. package/registry/ui/kbd.tsx +30 -0
  149. package/registry/ui/kpi-card.tsx +677 -0
  150. package/registry/ui/label.tsx +22 -0
  151. package/registry/ui/legend.tsx +3500 -0
  152. package/registry/ui/line-chart.tsx +3637 -0
  153. package/registry/ui/metric-value.tsx +62 -0
  154. package/registry/ui/multi-select.tsx +232 -0
  155. package/registry/ui/page-shell.tsx +282 -0
  156. package/registry/ui/pagination.tsx +140 -0
  157. package/registry/ui/placeholder.tsx +36 -0
  158. package/registry/ui/popover.tsx +92 -0
  159. package/registry/ui/progress-circle.tsx +867 -0
  160. package/registry/ui/progress.tsx +85 -0
  161. package/registry/ui/radio-group.tsx +39 -0
  162. package/registry/ui/sample-data.tsx +86 -0
  163. package/registry/ui/scatter-chart.tsx +4150 -0
  164. package/registry/ui/scroll-area.tsx +56 -0
  165. package/registry/ui/searchable-select.tsx +214 -0
  166. package/registry/ui/section-header.tsx +43 -0
  167. package/registry/ui/segmented-control.tsx +76 -0
  168. package/registry/ui/select.tsx +255 -0
  169. package/registry/ui/separator.tsx +27 -0
  170. package/registry/ui/sheet.tsx +139 -0
  171. package/registry/ui/sidebar.tsx +725 -0
  172. package/registry/ui/skeleton.tsx +17 -0
  173. package/registry/ui/slider.tsx +56 -0
  174. package/registry/ui/sparkline.tsx +230 -0
  175. package/registry/ui/spinner.tsx +14 -0
  176. package/registry/ui/states.tsx +90 -0
  177. package/registry/ui/status-badge.tsx +121 -0
  178. package/registry/ui/switch.tsx +34 -0
  179. package/registry/ui/table.tsx +118 -0
  180. package/registry/ui/tabs.tsx +84 -0
  181. package/registry/ui/textarea.tsx +22 -0
  182. package/registry/ui/toast.tsx +250 -0
  183. package/registry/ui/toggle-group.tsx +91 -0
  184. package/registry/ui/toggle.tsx +47 -0
  185. package/registry/ui/tooltip.tsx +68 -0
  186. package/registry/ui/tracker.tsx +843 -0
  187. package/registry/ui/trend-indicator.tsx +74 -0
  188. package/registry/ui/updated-at.tsx +122 -0
  189. package/registry/ui/waterfall-chart.tsx +4186 -0
  190. package/skills/vantage-add-feature/SKILL.md +9 -7
  191. package/skills/vantage-pitfalls/SKILL.md +5 -1
  192. package/templates/AGENTS.md +22 -1
  193. package/templates/agents/csv.md +2 -1
  194. package/templates/agents/doc.md +4 -3
  195. package/templates/agents/page.md +20 -6
  196. package/templates/agents/xlsx.md +2 -1
  197. package/theme.css +237 -136
  198. package/dist/add-DY4AXANK.js +0 -6
  199. package/dist/add-DY4AXANK.js.map +0 -1
  200. package/dist/chunk-5ZOP4SQR.js.map +0 -1
  201. package/dist/chunk-A2UUGASH.js.map +0 -1
  202. package/dist/chunk-DTDVSFRY.js.map +0 -1
  203. package/dist/chunk-LMUACBMA.js.map +0 -1
  204. package/dist/chunk-NKFBFDRZ.js +0 -2241
  205. package/dist/chunk-NKFBFDRZ.js.map +0 -1
  206. package/dist/chunk-PVTHHV7X.js.map +0 -1
  207. package/dist/chunk-PXRX5PEC.js.map +0 -1
  208. package/dist/chunk-QI3XQKHS.js.map +0 -1
  209. package/dist/chunk-RPZAALCX.js.map +0 -1
  210. package/dist/chunk-VA776YE5.js +0 -316
  211. package/dist/chunk-VA776YE5.js.map +0 -1
  212. package/dist/dev-GDKATCC7.js.map +0 -1
@@ -1,4 +1,7 @@
1
1
  // Copied by `vantage add ui data-table`. This file is yours to edit.
2
+ // Generated from @squadbase/squadkit registry/components/data-table.tsx by `npm run registry` (in Vantage, edit packages/squadkit, not registry/).
3
+ "use client"
4
+
2
5
  /**
3
6
  * DataTable — a compound table over TanStack Table. `<DataTable>` owns the
4
7
  * table instance and publishes it on context; the toolbar, body, pagination and
@@ -14,7 +17,9 @@ import {
14
17
  forwardRef,
15
18
  useCallback,
16
19
  useContext,
20
+ useEffect,
17
21
  useMemo,
22
+ useRef,
18
23
  useState,
19
24
  type HTMLAttributes,
20
25
  type ReactNode,
@@ -27,43 +32,197 @@ import {
27
32
  getPaginationRowModel,
28
33
  getSortedRowModel,
29
34
  useReactTable,
35
+ type Column,
30
36
  type ColumnDef,
31
37
  type ColumnFiltersState,
32
38
  type OnChangeFn,
33
39
  type PaginationState,
40
+ type RowData,
34
41
  type RowSelectionState,
35
42
  type SortingState,
36
43
  type Table as TanstackTable,
37
44
  type TableMeta,
38
45
  type VisibilityState,
39
46
  } from "@tanstack/react-table"
40
- import { ArrowDown, ArrowUp, ArrowUpDown, Columns3, Search } from "lucide-react"
41
-
42
47
  import {
43
- Button,
44
- Checkbox,
48
+ ArrowDown,
49
+ ArrowUp,
50
+ ArrowUpDown,
51
+ CircleCheck,
52
+ CircleX,
53
+ Columns3,
54
+ Download,
55
+ Minus,
56
+ Search,
57
+ TriangleAlert,
58
+ type LucideIcon,
59
+ } from "lucide-react"
60
+
61
+ import { Sparkline } from "@squadbase/vantage/components"
62
+ import { toneForValue } from "@squadbase/vantage/components"
63
+ import { EMPTY_VALUE, formatDate, formatValue, type DateStyle, type ValueFormat } from "@squadbase/vantage/components"
64
+ import type { ChartThreshold, ChartTone } from "@squadbase/vantage/components"
65
+ import { cn } from "@squadbase/vantage/ui"
66
+ import { Button } from "@squadbase/vantage/ui"
67
+ import { Checkbox } from "@squadbase/vantage/ui"
68
+ import {
45
69
  DropdownMenu,
46
70
  DropdownMenuCheckboxItem,
47
71
  DropdownMenuContent,
48
72
  DropdownMenuTrigger,
49
- Input,
50
- Select,
51
- SelectContent,
52
- SelectItem,
53
- SelectTrigger,
54
- SelectValue,
55
- Table,
56
- TableBody,
57
- TableCell,
58
- TableHead,
59
- TableHeader,
60
- TableRow,
61
- cn,
62
73
  } from "@squadbase/vantage/ui"
63
-
74
+ import { Input } from "@squadbase/vantage/ui"
75
+ import { Skeleton } from "@squadbase/vantage/ui"
76
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@squadbase/vantage/ui"
77
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@squadbase/vantage/ui"
64
78
 
65
79
  /* ------------------------------------------------------------------- types */
66
80
 
81
+ declare module "@tanstack/react-table" {
82
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
83
+ interface ColumnMeta<TData extends RowData, TValue> {
84
+ /**
85
+ * Classes put on the column's header and body cells — e.g.
86
+ * `"hidden md:table-cell"` to drop a column on narrow screens.
87
+ */
88
+ className?: string
89
+ /**
90
+ * 列の数値書式。`cell` を持たない列だけに効き、右寄せ・tabular-nums で描く。
91
+ * 行ごとに通貨が変わるときは関数で行を受ける。CSV の「表示」値にも使われる。
92
+ *
93
+ * @example
94
+ * meta: { format: "currency" }
95
+ * meta: { format: (row) => ({ type: "currency", currency: row.currency, notation: "standard" }) }
96
+ */
97
+ format?: ValueFormat | ((row: TData) => ValueFormat)
98
+ /**
99
+ * 日付の列の書式(`formatDate` の `style`)。`cell` を持たない列だけに効く。左寄せ・tabular-nums で描き、
100
+ * 値は `Date`・エポックミリ秒・日付文字列(`"2026-10-20"`、ISO 8601)。CSV の「表示」値にも使われる。
101
+ * `format`(数値)とは併用しない。
102
+ *
103
+ * @example
104
+ * meta: { dateFormat: "date" }
105
+ * meta: { dateFormat: "datetime", nullLabel: "未更新" }
106
+ */
107
+ dateFormat?: DateStyle
108
+ /**
109
+ * `dateFormat` の列の日時を出すタイムゾーン(IANA 名。例 `"Asia/Tokyo"`)。既定は実行環境(ブラウザ)のタイムゾーン。
110
+ * サーバーとブラウザで表示が食い違う(SSR)なら固定する。`dateFormat` の列だけに効き、CSV の「表示」値にも同じ値を使う。
111
+ * `"2026-10-20"` のような日付だけの文字列は、どのタイムゾーンでもその暦日のまま。
112
+ *
113
+ * @example
114
+ * meta: { dateFormat: "datetime", timeZone: "Asia/Tokyo" }
115
+ */
116
+ timeZone?: string
117
+ /**
118
+ * 値が無いときの表示。既定 `"—"`。null / undefined(数値は NaN も)に効き、文字列の列では空文字 `""` も値なしとして扱う。
119
+ * `format` / `dateFormat` を持つ列と、書式なしの文字列の列に効く。`cell` を自前で書いた列には効かない(`cell` の中で扱う)。
120
+ * CSV の「表示」値は、書式付き・日付の列だけ `nullLabel` で書き、書式なしの文字列の列は空欄のまま。
121
+ *
122
+ * @example
123
+ * meta: { format: "number", nullLabel: "未集計" }
124
+ * meta: { nullLabel: "未設定" } // 書式なしの文字列の列
125
+ */
126
+ nullLabel?: string
127
+ /**
128
+ * 値の範囲でセルを意味色に塗り分ける規則(最初に当たった規則。どれにも当たらない値は通常色)。
129
+ * `cell` を持たない列だけに効き、BarChart / BarList の `thresholds` と同じ型・同じ判定。
130
+ * 色だけに頼らず、色に応じた記号(○ / △ / × / −)を値の前に添える。CSV は生の値のまま。
131
+ *
132
+ * @example
133
+ * meta: { format: { type: "percent", divisor: 100 }, thresholds: [{ lt: 40, tone: "negative" }, { lt: 70, tone: "warning" }] }
134
+ */
135
+ thresholds?: ChartThreshold[]
136
+ /**
137
+ * 狭い幅で列を隠す順。数が小さい列から隠れる(表の置き場の幅が 1 は 640px 未満、2 は 520px 未満、3 は 400px 未満)。
138
+ * 書かない列と 4 以上の列は、どの幅でも出す(部署名・離職率のような肝心の列に使う)。
139
+ * 画面の幅ではなく、表を置いた場所の幅(コンテナ幅)で決まる。隠した列は DOM にも読み上げにも出ず、CSV の出力には含まれる。
140
+ *
141
+ * @example
142
+ * meta: { format: "number", priority: 1 } // 幅が足りないときに最初に隠す
143
+ */
144
+ priority?: number
145
+ /**
146
+ * true にすると、増減(`Delta`)など「直前の列の値との比較」を表示する列になる。表の置き場が 320px 未満に狭まると、
147
+ * この列は隠れる代わりに、直前の列(値の列)のセルの下に同じ内容を小さく(text-xs)積んで出す。比較は消えない。
148
+ * `priority` で隠す順は最後(どの列よりも後)になり、この列の `priority` は無視される。直前の列も `priority` に関わらず残る。
149
+ * 列の `cell` に `<Delta variant="inline" … />` を書く。直前に列が無いときは積まず、通常の列として出す。CSV には通常の列として含まれる。
150
+ *
151
+ * @example
152
+ * { accessorKey: "revenue", header: "売上", meta: { format: "currency" } },
153
+ * { id: "revenueDelta", header: "前月比", meta: { comparison: true }, cell: ({ row }) => <Delta variant="inline" value={row.original.revenue} comparison={{ previous: row.original.prev, vs: "prior-period", grain: "month" }} /> }
154
+ */
155
+ comparison?: boolean
156
+ /**
157
+ * 列の寄せ。見出し(ソートボタンを含む)とセルの両方に効く。既定は、`format` / `thresholds` の列と
158
+ * `comparison` の列(増減 `Delta`)が `"right"`、それ以外は `"left"`。右寄せの列は tabular-nums で描く。
159
+ *
160
+ * @example
161
+ * meta: { align: "right" }
162
+ */
163
+ align?: "left" | "right"
164
+ }
165
+ }
166
+
167
+ /* ------------------------------------------------------------ cell formatting */
168
+
169
+ /** Resolve a column's `meta.format` for one row. */
170
+ function resolveFormat<TData>(
171
+ format: ValueFormat | ((row: TData) => ValueFormat) | undefined,
172
+ row: TData,
173
+ ): ValueFormat | undefined {
174
+ return typeof format === "function" ? format(row) : format
175
+ }
176
+
177
+ /** 値なし: null / undefined / NaN / 空文字。 */
178
+ function isMissingCell(value: unknown): boolean {
179
+ return value === null || value === undefined || value === "" || (typeof value === "number" && Number.isNaN(value))
180
+ }
181
+
182
+ /**
183
+ * Display string of a column's value (`meta.format` / `meta.dateFormat` / plain);
184
+ * a missing value renders as `nullLabel` (default `—`).
185
+ */
186
+ function formatCellText<TData>(
187
+ value: unknown,
188
+ row: TData,
189
+ meta: { format?: ValueFormat | ((row: TData) => ValueFormat); dateFormat?: DateStyle; timeZone?: string; nullLabel?: string },
190
+ ): string {
191
+ if (isMissingCell(value)) return meta.nullLabel ?? EMPTY_VALUE
192
+ if (meta.dateFormat && (value instanceof Date || typeof value === "string" || typeof value === "number")) {
193
+ const text = formatDate(value, meta.dateFormat, undefined, { timeZone: meta.timeZone })
194
+ return text === EMPTY_VALUE ? String(value) : text
195
+ }
196
+ if (typeof value !== "number") return String(value)
197
+ return formatValue(value, resolveFormat(meta.format, row))
198
+ }
199
+
200
+ /** 意味色ごとの記号と色クラス。Tailwind が拾えるよう、クラスはリテラルで書く。 */
201
+ const THRESHOLD_TONE_STYLES: Record<ChartTone, { Icon: LucideIcon; text: string; label: string }> = {
202
+ positive: { Icon: CircleCheck, text: "text-positive", label: "良好" },
203
+ warning: { Icon: TriangleAlert, text: "text-warning", label: "注意" },
204
+ negative: { Icon: CircleX, text: "text-negative", label: "要対応" },
205
+ neutral: { Icon: Minus, text: "text-neutral", label: "中立" },
206
+ }
207
+
208
+ /** `meta.thresholds` 列のセル。当たった規則の意味色と記号を値に添える(当たらなければ値だけ)。 */
209
+ function ThresholdCell({ value, text, thresholds }: { value: unknown; text: string; thresholds: ChartThreshold[] }) {
210
+ const tone = typeof value === "number" ? toneForValue(value, thresholds) : undefined
211
+ if (!tone) return <div className="text-right tabular-nums">{text}</div>
212
+ const { Icon, text: toneClass, label } = THRESHOLD_TONE_STYLES[tone]
213
+ return (
214
+ <div
215
+ data-slot="data-table-threshold-cell"
216
+ data-tone={tone}
217
+ className={cn("flex items-center justify-end gap-1.5 tabular-nums", toneClass)}
218
+ >
219
+ <Icon className="size-3.5 shrink-0" aria-hidden="true" />
220
+ <span>{text}</span>
221
+ <span className="sr-only">({label})</span>
222
+ </div>
223
+ )
224
+ }
225
+
67
226
  interface DataTableContextValue<TData> {
68
227
  table: TanstackTable<TData>
69
228
  selectedRows: TData[]
@@ -74,6 +233,7 @@ interface DataTableContextValue<TData> {
74
233
  enablePagination: boolean
75
234
  enableRowSelection: boolean
76
235
  visibleColumnCount: number
236
+ isLoading: boolean
77
237
  }
78
238
 
79
239
  /**
@@ -89,7 +249,8 @@ export interface DataTableProps<TData, TValue> {
89
249
  * literal が `string` に拡張され型エラーになる。
90
250
  *
91
251
  * @example
92
- * import type { ColumnDef } from "@squadbase/vantage/components"
252
+ * import { DataTable, type ColumnDef } from "@/components/data-table"
253
+ * // Vantage: import { DataTable, type ColumnDef } from "@squadbase/vantage/components"
93
254
  *
94
255
  * const columns: ColumnDef<User>[] = [
95
256
  * { accessorKey: "id", header: "ID" },
@@ -116,7 +277,42 @@ export interface DataTableProps<TData, TValue> {
116
277
  enablePagination?: boolean
117
278
  pagination?: PaginationState
118
279
  onPaginationChange?: OnChangeFn<PaginationState>
280
+ /**
281
+ * サーバー側ページング時の総ページ数。指定すると manualPagination になる。
282
+ *
283
+ * @example
284
+ * <DataTable pageCount={data.pageCount} … />
285
+ */
119
286
  pageCount?: number
287
+ /**
288
+ * サーバー側ページング時の総件数。指定すると manualPagination になり、ページ数は
289
+ * `rowCount / pageSize` から導かれ、「N件中」もこの値を使う(`pageCount` を同時に渡せばそちらが優先)。
290
+ *
291
+ * @example
292
+ * <DataTable rowCount={1240} pagination={pagination} onPaginationChange={setPagination} … />
293
+ */
294
+ rowCount?: number
295
+ /**
296
+ * true にすると絞り込みを表では行わず、`data` をそのまま使う(サーバー側で絞り込む)。
297
+ *
298
+ * @example
299
+ * <DataTable manualFiltering globalFilter={q} onGlobalFilterChange={setQ} … />
300
+ */
301
+ manualFiltering?: boolean
302
+ /**
303
+ * true にすると並べ替えを表では行わず、`data` の順をそのまま使う(サーバー側で並べ替える)。
304
+ *
305
+ * @example
306
+ * <DataTable manualSorting sorting={sorting} onSortingChange={setSorting} … />
307
+ */
308
+ manualSorting?: boolean
309
+ /**
310
+ * 読み込み中。Content はスケルトン行、Pagination のボタンは無効、表は aria-busy になる。
311
+ *
312
+ * @example
313
+ * <DataTable isLoading={query.isFetching} … />
314
+ */
315
+ isLoading?: boolean
120
316
 
121
317
  // Row selection
122
318
  enableRowSelection?: boolean
@@ -127,6 +323,13 @@ export interface DataTableProps<TData, TValue> {
127
323
  columnVisibility?: VisibilityState
128
324
  onColumnVisibilityChange?: OnChangeFn<VisibilityState>
129
325
 
326
+ /**
327
+ * TanStack Table の `meta`。表全体で共有する値(行を更新する関数など)を渡し、列定義の `cell` から
328
+ * `table.options.meta` で読む。列ごとの書式は列定義の `meta.format` に書く(こちらではない)。
329
+ *
330
+ * @example
331
+ * <DataTable meta={{ updateRow: (id, patch) => mutate({ id, patch }) }} … />
332
+ */
130
333
  meta?: TableMeta<TData>
131
334
  className?: string
132
335
 
@@ -142,6 +345,33 @@ export interface DataTableToolbarProps extends HTMLAttributes<HTMLDivElement> {
142
345
 
143
346
  export interface DataTableContentProps extends HTMLAttributes<HTMLDivElement> {}
144
347
 
348
+ export interface DataTableExportProps {
349
+ /**
350
+ * 保存するファイル名。既定 `"export.csv"`。
351
+ *
352
+ * @example
353
+ * <DataTableExport fileName="sales-2025.csv" />
354
+ */
355
+ fileName?: string
356
+ /**
357
+ * `"display"`(既定)は `meta.format` どおりの表示文字列、`"raw"` は生の値を書き出す。
358
+ *
359
+ * @example
360
+ * <DataTableExport values="raw" />
361
+ */
362
+ values?: CsvValues
363
+ /**
364
+ * `"filtered"`(既定)は並べ替え・絞り込み後の全ページ、`"page"` は表示中のページだけ。
365
+ *
366
+ * @example
367
+ * <DataTableExport scope="page" />
368
+ */
369
+ scope?: CsvScope
370
+ /** ボタンの文言。既定 `"CSV を書き出す"`。 */
371
+ children?: ReactNode
372
+ className?: string
373
+ }
374
+
145
375
  export interface DataTablePaginationProps extends HTMLAttributes<HTMLDivElement> {
146
376
  pageSizeOptions?: number[]
147
377
  }
@@ -152,40 +382,31 @@ export interface DataTableColumnVisibilityProps {
152
382
  className?: string
153
383
  }
154
384
 
155
- export interface DataTablePresetProps<TData, TValue> {
156
- columns: ColumnDef<TData, TValue>[]
157
- data: TData[]
158
-
159
- enableSorting?: boolean
160
- sorting?: SortingState
161
- onSortingChange?: OnChangeFn<SortingState>
162
-
163
- enableFiltering?: boolean
164
- columnFilters?: ColumnFiltersState
165
- onColumnFiltersChange?: OnChangeFn<ColumnFiltersState>
166
- globalFilter?: string
167
- onGlobalFilterChange?: OnChangeFn<string>
168
-
169
- enablePagination?: boolean
170
- pagination?: PaginationState
171
- onPaginationChange?: OnChangeFn<PaginationState>
172
- pageCount?: number
173
-
174
- enableRowSelection?: boolean
175
- rowSelection?: RowSelectionState
176
- onRowSelectionChange?: OnChangeFn<RowSelectionState>
177
-
385
+ /** The preset takes every `DataTable` prop except `children`, plus a few slots. */
386
+ export type DataTablePresetProps<TData, TValue> = Omit<
387
+ DataTableProps<TData, TValue>,
388
+ "children"
389
+ > & {
178
390
  enableColumnVisibility?: boolean
179
- columnVisibility?: VisibilityState
180
- onColumnVisibilityChange?: OnChangeFn<VisibilityState>
181
391
  columnLabels?: Record<string, string>
392
+ /**
393
+ * true にすると toolbar に CSV 書き出しボタン(`DataTableExport`)を置く。既定 false。
394
+ *
395
+ * @example
396
+ * <DataTablePreset exportable … />
397
+ */
398
+ exportable?: boolean
399
+ /**
400
+ * `exportable` のときの保存ファイル名。既定 `"export.csv"`。
401
+ *
402
+ * @example
403
+ * <DataTablePreset exportable exportFileName="sales.csv" … />
404
+ */
405
+ exportFileName?: string
182
406
 
183
407
  /** Extension slots */
184
408
  toolbar?: ReactNode
185
409
  bulkActions?: (selectedRows: TData[]) => ReactNode
186
-
187
- meta?: TableMeta<TData>
188
- className?: string
189
410
  }
190
411
 
191
412
  /* ------------------------------------------------------------------ helper */
@@ -226,9 +447,12 @@ export function useDataTable<TData>(): DataTableContextValue<TData> {
226
447
 
227
448
  /* -------------------------------------------------------------------- root */
228
449
 
450
+ /** Columns / rows not given yet (a draft fills props in steps): draw the empty state, keep the same array between renders. */
451
+ const NO_ROWS: never[] = []
452
+
229
453
  export function DataTable<TData, TValue>({
230
- columns,
231
- data,
454
+ columns = NO_ROWS,
455
+ data = NO_ROWS,
232
456
  enableSorting = true,
233
457
  sorting: sortingProp,
234
458
  onSortingChange,
@@ -241,6 +465,10 @@ export function DataTable<TData, TValue>({
241
465
  pagination: paginationProp,
242
466
  onPaginationChange,
243
467
  pageCount,
468
+ rowCount,
469
+ manualFiltering,
470
+ manualSorting,
471
+ isLoading = false,
244
472
  enableRowSelection = false,
245
473
  rowSelection: rowSelectionProp,
246
474
  onRowSelectionChange,
@@ -284,7 +512,29 @@ export function DataTable<TData, TValue>({
284
512
 
285
513
  // Row selection prepends a checkbox column that the caller never declares.
286
514
  const allColumns = useMemo<ColumnDef<TData, TValue>[]>(() => {
287
- if (!enableRowSelection) return columns
515
+ // A column with `meta.format` / `meta.thresholds` and no `cell` gets a formatted, right-aligned
516
+ // cell; thresholds add the tone colour plus a symbol. `meta.dateFormat` gives a left-aligned date.
517
+ // A plain accessor column (no `cell`) shows `nullLabel` / `—` for a missing value.
518
+ const formatted = columns.map((column) => {
519
+ const meta = column.meta
520
+ if (column.cell !== undefined) return column
521
+ const hasAccessor = "accessorKey" in column || "accessorFn" in column
522
+ if (!(meta?.format || meta?.thresholds || meta?.dateFormat) && !hasAccessor) return column
523
+ const thresholds = meta?.thresholds
524
+ const aligned = Boolean(meta?.format || thresholds)
525
+ return {
526
+ ...column,
527
+ cell: ({ row, getValue }) => {
528
+ const value = getValue()
529
+ const text = formatCellText(value, row.original, meta ?? {})
530
+ if (thresholds) return <ThresholdCell value={value} text={text} thresholds={thresholds} />
531
+ if (aligned) return <div className="text-right tabular-nums">{text}</div>
532
+ if (meta?.dateFormat) return <span className="tabular-nums">{text}</span>
533
+ return text
534
+ },
535
+ } as ColumnDef<TData, TValue>
536
+ })
537
+ if (!enableRowSelection) return formatted
288
538
 
289
539
  const selectionColumn: ColumnDef<TData, TValue> = {
290
540
  id: "_select",
@@ -307,10 +557,26 @@ export function DataTable<TData, TValue>({
307
557
  enableHiding: false,
308
558
  }
309
559
 
310
- return [selectionColumn, ...columns]
560
+ return [selectionColumn, ...formatted]
311
561
  }, [columns, enableRowSelection])
312
562
 
563
+ // TanStack's own `autoResetPageIndex` queues `resetPageIndex()` from inside a render-time memo, which
564
+ // can fire before the component has mounted ("Can't perform a React state update on a component that
565
+ // hasn't mounted yet"). Turn it off and send the page back to 1 from an effect instead, so it only
566
+ // runs after mount and only when the sort / filter / data actually changed.
567
+ const autoResetPage = enablePagination && pageCount === undefined && rowCount === undefined
568
+ const resetKey = useRef<readonly unknown[] | null>(null)
569
+ useEffect(() => {
570
+ const next = [data, sorting, columnFilters, globalFilter]
571
+ const prev = resetKey.current
572
+ resetKey.current = next
573
+ if (!autoResetPage || prev === null) return
574
+ if (prev.every((value, i) => value === next[i])) return
575
+ setPagination((old) => (old.pageIndex === 0 ? old : { ...old, pageIndex: 0 }))
576
+ }, [autoResetPage, data, sorting, columnFilters, globalFilter, setPagination])
577
+
313
578
  const table = useReactTable({
579
+ autoResetPageIndex: false,
314
580
  data,
315
581
  columns: allColumns,
316
582
  state: {
@@ -329,7 +595,11 @@ export function DataTable<TData, TValue>({
329
595
  onRowSelectionChange: setRowSelection,
330
596
  onColumnVisibilityChange: setColumnVisibility,
331
597
  ...(enablePagination ? { onPaginationChange: setPagination } : {}),
332
- ...(pageCount !== undefined ? { pageCount, manualPagination: true } : {}),
598
+ ...(pageCount !== undefined || rowCount !== undefined
599
+ ? { pageCount, rowCount, manualPagination: true }
600
+ : {}),
601
+ ...(manualFiltering ? { manualFiltering: true } : {}),
602
+ ...(manualSorting ? { manualSorting: true } : {}),
333
603
  getCoreRowModel: getCoreRowModel(),
334
604
  getSortedRowModel: getSortedRowModel(),
335
605
  getFilteredRowModel: getFilteredRowModel(),
@@ -357,6 +627,7 @@ export function DataTable<TData, TValue>({
357
627
  enablePagination,
358
628
  enableRowSelection,
359
629
  visibleColumnCount: allColumns.length,
630
+ isLoading,
360
631
  }),
361
632
  // `useReactTable` returns the same object every render, so each piece of
362
633
  // table state has to be tracked explicitly to re-render the consumers.
@@ -371,6 +642,8 @@ export function DataTable<TData, TValue>({
371
642
  enablePagination,
372
643
  enableRowSelection,
373
644
  allColumns.length,
645
+ isLoading,
646
+ rowCount,
374
647
  sorting,
375
648
  columnFilters,
376
649
  columnVisibility,
@@ -381,7 +654,7 @@ export function DataTable<TData, TValue>({
381
654
 
382
655
  return (
383
656
  <DataTableContext.Provider value={contextValue as DataTableContextValue<unknown>}>
384
- <div data-slot="data-table" className={cn("space-y-4", className)}>
657
+ <div data-slot="data-table" aria-busy={isLoading || undefined} className={cn("space-y-4", className)}>
385
658
  {children}
386
659
  </div>
387
660
  </DataTableContext.Provider>
@@ -389,6 +662,68 @@ export function DataTable<TData, TValue>({
389
662
  }
390
663
  DataTable.displayName = "DataTable"
391
664
 
665
+ /* ------------------------------------------------------ sparkline column */
666
+
667
+ /** Fewest points a row needs for a trend (one point has no direction). */
668
+ const SPARKLINE_MIN_POINTS = 2
669
+
670
+ export interface SparklineColumnOptions<TData> {
671
+ /** 列 ID(CSV・列の表示切り替えで使う)。 */
672
+ id: string
673
+ /** 見出し。 */
674
+ header: string
675
+ /**
676
+ * 行の数列を返す(古い順。例: 直近 8 週の売上)。null / undefined / 2 点未満の行は「—」。
677
+ * NaN などの有限でない値は取り除いてから数える。
678
+ */
679
+ values: (row: TData) => readonly (number | null | undefined)[] | null | undefined
680
+ /** スクリーンリーダー向けの数値の書式(`meta.format` と同じ語彙)。既定は `"number"`。 */
681
+ format?: ValueFormat
682
+ }
683
+
684
+ /**
685
+ * 行の数列をセルの小さなトレンド(Sparkline)にする列。色は `--chart-1` 固定、軸・ツールチップ・ラベルなし。
686
+ * 図は読み上げず、同じセルの sr-only に「N 点、最初 → 最後、最小、最大」を書式付きで添える。
687
+ * 数列の並べ替えは意味が無いので無効。DataTable 本体の props は増やさず、`columns` に置くだけで使う。
688
+ *
689
+ * @example
690
+ * const columns: ColumnDef<Store>[] = [
691
+ * { accessorKey: "store", header: "店舗" },
692
+ * sparklineColumn<Store>({ id: "trend", header: "直近 8 週", values: (r) => r.weekly, format: "currency" }),
693
+ * ]
694
+ */
695
+ export function sparklineColumn<TData>({
696
+ id,
697
+ header,
698
+ values,
699
+ format = "number",
700
+ }: SparklineColumnOptions<TData>): ColumnDef<TData, unknown> {
701
+ return {
702
+ id,
703
+ header,
704
+ accessorFn: (row) => values(row) ?? null,
705
+ enableSorting: false,
706
+ cell: ({ getValue }) => {
707
+ const raw = getValue() as readonly (number | null | undefined)[] | null
708
+ const series = (raw ?? []).filter((v): v is number => typeof v === "number" && Number.isFinite(v))
709
+ if (series.length < SPARKLINE_MIN_POINTS) return <span className="text-muted-foreground">{EMPTY_VALUE}</span>
710
+ const f = (v: number) => formatValue(v, format)
711
+ const first = series[0] as number
712
+ const last = series[series.length - 1] as number
713
+ return (
714
+ <div data-slot="data-table-sparkline-cell" className="flex h-5 items-center">
715
+ <div aria-hidden="true" className="h-5 w-24 shrink-0">
716
+ <Sparkline data={series.map((value) => ({ value }))} height={20} />
717
+ </div>
718
+ <span className="sr-only">
719
+ {series.length}点の推移、{f(first)}から{f(last)}、最小{f(Math.min(...series))}、最大{f(Math.max(...series))}
720
+ </span>
721
+ </div>
722
+ )
723
+ },
724
+ }
725
+ }
726
+
392
727
  /* ----------------------------------------------------------------- toolbar */
393
728
 
394
729
  export const DataTableToolbar = forwardRef<HTMLDivElement, DataTableToolbarProps>(
@@ -431,63 +766,199 @@ DataTableToolbar.displayName = "DataTableToolbar"
431
766
 
432
767
  /* ----------------------------------------------------------------- content */
433
768
 
769
+ /**
770
+ * `meta.priority` → the classes that hide the column below a container width (`@container` on the content wrapper).
771
+ * The class strings are literals so Tailwind picks them up.
772
+ */
773
+ const PRIORITY_CLASS: Record<number, string> = {
774
+ 1: "hidden @min-[640px]:table-cell",
775
+ 2: "hidden @min-[520px]:table-cell",
776
+ 3: "hidden @min-[400px]:table-cell",
777
+ }
778
+
779
+ /**
780
+ * A `meta.comparison` column is the last tier: it stays as its own column down to 320px, below which it is hidden and its
781
+ * content is stacked under the value cell instead (`COMPARISON_STACK_CLASS`, visible only below the same width).
782
+ */
783
+ const COMPARISON_CLASS = "hidden @min-[320px]:table-cell"
784
+ const COMPARISON_STACK_CLASS = "@min-[320px]:hidden"
785
+
786
+ /** A column's hide-on-narrow classes: none without a priority (or at 4 and above), the lowest tier for anything below 1. */
787
+ export function priorityClassName(priority: number | undefined): string | undefined {
788
+ if (priority === undefined || !Number.isFinite(priority) || priority >= 4) return undefined
789
+ return PRIORITY_CLASS[Math.max(1, Math.floor(priority))]
790
+ }
791
+
792
+ type CellMeta = {
793
+ className?: string
794
+ format?: unknown
795
+ thresholds?: unknown
796
+ priority?: number
797
+ comparison?: boolean
798
+ align?: "left" | "right"
799
+ }
800
+
801
+ /** `meta.align` wins; otherwise numeric columns (`format` / `thresholds`) and Delta (`comparison`) columns are right-aligned, header included. */
802
+ function isRightAligned(meta: CellMeta | undefined): boolean {
803
+ if (meta?.align) return meta.align === "right"
804
+ return Boolean(meta?.format || meta?.thresholds || meta?.comparison)
805
+ }
806
+
807
+ /**
808
+ * A column that carries a stacked comparison (`isHost`) never hides, and a comparison column ignores its own
809
+ * `priority` and takes the last tier.
810
+ */
811
+ function columnClassName(meta: CellMeta | undefined, options: { isComparison?: boolean; isHost?: boolean } = {}) {
812
+ const tier = options.isComparison
813
+ ? COMPARISON_CLASS
814
+ : options.isHost
815
+ ? undefined
816
+ : priorityClassName(meta?.priority)
817
+ return cn(tier, meta?.className, isRightAligned(meta) ? "text-right tabular-nums" : undefined) || undefined
818
+ }
819
+
820
+ /**
821
+ * Pair every `meta.comparison` column with the visible column right before it (the value it compares).
822
+ * A comparison column with nothing before it stays an ordinary column.
823
+ */
824
+ function pairComparisons<TData>(columns: Column<TData, unknown>[]) {
825
+ const comparisonOf = new Map<string, Column<TData, unknown>>() // host column id → its comparison column
826
+ const comparisonIds = new Set<string>()
827
+ columns.forEach((column, i) => {
828
+ if (i > 0 && column.columnDef.meta?.comparison && !columns[i - 1]!.columnDef.meta?.comparison) {
829
+ comparisonOf.set(columns[i - 1]!.id, column)
830
+ comparisonIds.add(column.id)
831
+ }
832
+ })
833
+ return { comparisonOf, comparisonIds }
834
+ }
835
+
836
+ const SORT_ICON_CLASS = "size-4 shrink-0"
837
+
838
+ /** The sort icon: always shown on the sorted column, only on hover / keyboard focus on the others. */
839
+ function SortIcon({ sorted }: { sorted: false | "asc" | "desc" }) {
840
+ if (sorted === "asc") return <ArrowUp className={SORT_ICON_CLASS} aria-hidden="true" />
841
+ if (sorted === "desc") return <ArrowDown className={SORT_ICON_CLASS} aria-hidden="true" />
842
+ return (
843
+ <ArrowUpDown
844
+ className={cn(
845
+ SORT_ICON_CLASS,
846
+ "text-muted-foreground opacity-0 group-hover/sort:opacity-100 group-focus-visible/sort:opacity-100",
847
+ )}
848
+ aria-hidden="true"
849
+ />
850
+ )
851
+ }
852
+
434
853
  export const DataTableContent = forwardRef<HTMLDivElement, DataTableContentProps>(
435
854
  ({ className, ...props }, ref) => {
436
- const { table, visibleColumnCount } = useDataTable()
855
+ const { table, visibleColumnCount, isLoading, enablePagination } = useDataTable()
856
+ const { comparisonOf, comparisonIds } = pairComparisons(table.getVisibleLeafColumns())
437
857
 
438
858
  return (
439
859
  <div
440
860
  ref={ref}
441
861
  data-slot="data-table-content"
442
- className={cn("overflow-x-auto", className)}
862
+ aria-busy={isLoading || undefined}
863
+ className={cn("@container overflow-x-auto", className)}
443
864
  {...props}
444
865
  >
445
- <Table>
866
+ <Table className="[&_th:first-child]:pl-0 [&_td:first-child]:pl-0 [&_th:last-child]:pr-0 [&_td:last-child]:pr-0">
446
867
  <TableHeader>
447
868
  {table.getHeaderGroups().map((headerGroup) => (
448
869
  <TableRow key={headerGroup.id}>
449
- {headerGroup.headers.map((header) => (
450
- <TableHead key={header.id}>
451
- {header.isPlaceholder ? null : header.column.getCanSort() ? (
452
- <button
453
- type="button"
454
- className="inline-flex items-center gap-1 rounded hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:outline-none"
455
- onClick={header.column.getToggleSortingHandler()}
456
- aria-sort={
457
- header.column.getIsSorted() === "asc"
870
+ {headerGroup.headers.map((header) => {
871
+ const column = header.column
872
+ const meta = column.columnDef.meta
873
+ const sorted = column.getIsSorted()
874
+ const canSort = column.getCanSort()
875
+ const label = header.isPlaceholder ? null : flexRender(column.columnDef.header, header.getContext())
876
+ return (
877
+ <TableHead
878
+ key={header.id}
879
+ className={columnClassName(meta, {
880
+ isComparison: comparisonIds.has(column.id),
881
+ isHost: comparisonOf.has(column.id),
882
+ })}
883
+ aria-sort={
884
+ canSort && !header.isPlaceholder
885
+ ? sorted === "asc"
458
886
  ? "ascending"
459
- : header.column.getIsSorted() === "desc"
887
+ : sorted === "desc"
460
888
  ? "descending"
461
889
  : "none"
462
- }
463
- >
464
- {flexRender(header.column.columnDef.header, header.getContext())}
465
- {header.column.getIsSorted() === "asc" ? (
466
- <ArrowUp className="size-4" />
467
- ) : header.column.getIsSorted() === "desc" ? (
468
- <ArrowDown className="size-4" />
469
- ) : (
470
- <ArrowUpDown className="size-4 text-muted-foreground" />
471
- )}
472
- </button>
473
- ) : (
474
- flexRender(header.column.columnDef.header, header.getContext())
475
- )}
476
- </TableHead>
477
- ))}
890
+ : undefined
891
+ }
892
+ >
893
+ {header.isPlaceholder ? null : canSort ? (
894
+ <button
895
+ type="button"
896
+ className={cn(
897
+ "group/sort relative items-center gap-1 rounded leading-5 after:absolute after:inset-x-0 after:top-1/2 after:h-6 after:-translate-y-1/2 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:outline-none",
898
+ isRightAligned(meta) ? "flex w-full justify-end" : "inline-flex",
899
+ )}
900
+ onClick={column.getToggleSortingHandler()}
901
+ >
902
+ {isRightAligned(meta) && <SortIcon sorted={sorted} />}
903
+ {label}
904
+ {!isRightAligned(meta) && <SortIcon sorted={sorted} />}
905
+ </button>
906
+ ) : (
907
+ label
908
+ )}
909
+ </TableHead>
910
+ )
911
+ })}
478
912
  </TableRow>
479
913
  ))}
480
914
  </TableHeader>
481
915
 
482
916
  <TableBody>
483
- {table.getRowModel().rows.length ? (
917
+ {isLoading ? (
918
+ Array.from({ length: enablePagination ? table.getState().pagination.pageSize : 5 }).map(
919
+ (_, i) => (
920
+ <TableRow key={i} data-slot="data-table-skeleton-row">
921
+ {Array.from({ length: visibleColumnCount }).map((__, j) => (
922
+ <TableCell key={j}>
923
+ <Skeleton className="h-4 w-full" />
924
+ </TableCell>
925
+ ))}
926
+ </TableRow>
927
+ ),
928
+ )
929
+ ) : table.getRowModel().rows.length ? (
484
930
  table.getRowModel().rows.map((row) => (
485
931
  <TableRow key={row.id} data-state={row.getIsSelected() ? "selected" : undefined}>
486
- {row.getVisibleCells().map((cell) => (
487
- <TableCell key={cell.id}>
488
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
489
- </TableCell>
490
- ))}
932
+ {row.getVisibleCells().map((cell) => {
933
+ const meta = cell.column.columnDef.meta
934
+ const comparisonColumn = comparisonOf.get(cell.column.id)
935
+ const comparisonCell = comparisonColumn
936
+ ? row.getVisibleCells().find((c) => c.column.id === comparisonColumn.id)
937
+ : undefined
938
+ return (
939
+ <TableCell
940
+ key={cell.id}
941
+ className={columnClassName(meta, {
942
+ isComparison: comparisonIds.has(cell.column.id),
943
+ isHost: comparisonOf.has(cell.column.id),
944
+ })}
945
+ >
946
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
947
+ {comparisonCell && (
948
+ <div
949
+ data-slot="data-table-stacked-comparison"
950
+ className={cn(
951
+ COMPARISON_STACK_CLASS,
952
+ "mt-0.5 text-xs [&_[data-slot=delta]]:text-xs",
953
+ isRightAligned(meta) ? "text-right" : undefined,
954
+ )}
955
+ >
956
+ {flexRender(comparisonCell.column.columnDef.cell, comparisonCell.getContext())}
957
+ </div>
958
+ )}
959
+ </TableCell>
960
+ )
961
+ })}
491
962
  </TableRow>
492
963
  ))
493
964
  ) : (
@@ -512,18 +983,28 @@ DataTableContent.displayName = "DataTableContent"
512
983
 
513
984
  export const DataTablePagination = forwardRef<HTMLDivElement, DataTablePaginationProps>(
514
985
  ({ pageSizeOptions = [10, 20, 50], className, ...props }, ref) => {
515
- const { table } = useDataTable()
986
+ const { table, isLoading } = useDataTable()
516
987
 
517
- const totalRows = table.getFilteredRowModel().rows.length
988
+ const totalRows =
989
+ table.options.rowCount !== undefined
990
+ ? table.getRowCount()
991
+ : table.getFilteredRowModel().rows.length
518
992
  const currentPageSize = table.getState().pagination.pageSize
519
993
  const currentPageIndex = table.getState().pagination.pageIndex
520
994
  const rangeStart = currentPageIndex * currentPageSize + 1
521
995
  const rangeEnd = Math.min((currentPageIndex + 1) * currentPageSize, totalRows)
996
+ // One list feeds both the menu and `items`, so the trigger shows "10件"
997
+ // rather than the raw "10" under Base UI's SelectValue.
998
+ const pageSizeItems = pageSizeOptions.map((size) => ({
999
+ value: String(size),
1000
+ label: `${size}件`,
1001
+ }))
522
1002
 
523
1003
  return (
524
1004
  <div
525
1005
  ref={ref}
526
1006
  data-slot="data-table-pagination"
1007
+ aria-busy={isLoading || undefined}
527
1008
  className={cn("flex flex-wrap items-center justify-between gap-4", className)}
528
1009
  {...props}
529
1010
  >
@@ -532,7 +1013,7 @@ export const DataTablePagination = forwardRef<HTMLDivElement, DataTablePaginatio
532
1013
  {totalRows}件中 {rangeStart} - {rangeEnd} を表示
533
1014
  </span>
534
1015
  <Select
535
- items={Object.fromEntries(pageSizeOptions.map((size) => [String(size), `${size}件`]))}
1016
+ items={pageSizeItems}
536
1017
  value={String(currentPageSize)}
537
1018
  onValueChange={(value) => table.setPageSize(Number(value))}
538
1019
  >
@@ -540,9 +1021,9 @@ export const DataTablePagination = forwardRef<HTMLDivElement, DataTablePaginatio
540
1021
  <SelectValue />
541
1022
  </SelectTrigger>
542
1023
  <SelectContent>
543
- {pageSizeOptions.map((size) => (
544
- <SelectItem key={size} value={String(size)}>
545
- {size}件
1024
+ {pageSizeItems.map((item) => (
1025
+ <SelectItem key={item.value} value={item.value}>
1026
+ {item.label}
546
1027
  </SelectItem>
547
1028
  ))}
548
1029
  </SelectContent>
@@ -554,7 +1035,7 @@ export const DataTablePagination = forwardRef<HTMLDivElement, DataTablePaginatio
554
1035
  variant="outline"
555
1036
  size="sm"
556
1037
  onClick={() => table.firstPage()}
557
- disabled={!table.getCanPreviousPage()}
1038
+ disabled={isLoading || !table.getCanPreviousPage()}
558
1039
  >
559
1040
  最初
560
1041
  </Button>
@@ -562,7 +1043,7 @@ export const DataTablePagination = forwardRef<HTMLDivElement, DataTablePaginatio
562
1043
  variant="outline"
563
1044
  size="sm"
564
1045
  onClick={() => table.previousPage()}
565
- disabled={!table.getCanPreviousPage()}
1046
+ disabled={isLoading || !table.getCanPreviousPage()}
566
1047
  >
567
1048
  前へ
568
1049
  </Button>
@@ -570,7 +1051,7 @@ export const DataTablePagination = forwardRef<HTMLDivElement, DataTablePaginatio
570
1051
  variant="outline"
571
1052
  size="sm"
572
1053
  onClick={() => table.nextPage()}
573
- disabled={!table.getCanNextPage()}
1054
+ disabled={isLoading || !table.getCanNextPage()}
574
1055
  >
575
1056
  次へ
576
1057
  </Button>
@@ -578,7 +1059,7 @@ export const DataTablePagination = forwardRef<HTMLDivElement, DataTablePaginatio
578
1059
  variant="outline"
579
1060
  size="sm"
580
1061
  onClick={() => table.lastPage()}
581
- disabled={!table.getCanNextPage()}
1062
+ disabled={isLoading || !table.getCanNextPage()}
582
1063
  >
583
1064
  最後
584
1065
  </Button>
@@ -607,8 +1088,9 @@ export const DataTableColumnVisibility = forwardRef<
607
1088
  ref={ref}
608
1089
  render={<Button variant="outline" size="sm" className={cn(className)} />}
609
1090
  data-slot="data-table-column-visibility"
1091
+ aria-label="表示する列"
610
1092
  >
611
- <Columns3 className="size-4" />
1093
+ <Columns3 className="size-4" aria-hidden="true" />
612
1094
  </DropdownMenuTrigger>
613
1095
  <DropdownMenuContent align="end" className="min-w-[10rem]">
614
1096
  {hideableColumns.map((column) => {
@@ -631,75 +1113,140 @@ export const DataTableColumnVisibility = forwardRef<
631
1113
  })
632
1114
  DataTableColumnVisibility.displayName = "DataTableColumnVisibility"
633
1115
 
1116
+ /* ------------------------------------------------------------------ export */
1117
+
1118
+ export type CsvCell = string | number | null | undefined
1119
+ export type CsvValues = "display" | "raw"
1120
+ export type CsvScope = "filtered" | "page"
1121
+
1122
+ // A signed number with an optional short currency / unit ("-¥1,200", "+530万", "-12.5%").
1123
+ const SIGNED_NUMBER = /^[+-]\s?[^\d\s=+\-@]{0,3}\d[\d,.]*[^\d\s=+\-@]{0,4}$/
1124
+
1125
+ /**
1126
+ * CSV 文字列を作る純関数(RFC 4180: CRLF、`,` `"` 改行を含む欄は `"` で囲み `"` は二重化)。
1127
+ * 既定で BOM 付き UTF-8(Excel で文字化けしない)。`guardFormulas` が true の間、先頭が
1128
+ * `= + - @` の文字列欄には `'` を付けて数式として実行されないようにする(数値と符号付きの数値表記は除く)。
1129
+ *
1130
+ * @example
1131
+ * toCsv(["月", "売上"], [["2025-01", 1200], ["2025-02", null]])
1132
+ * // "\uFEFF月,売上\r\n2025-01,1200\r\n2025-02,"
1133
+ */
1134
+ export function toCsv(
1135
+ header: string[],
1136
+ rows: CsvCell[][],
1137
+ options: { bom?: boolean; guardFormulas?: boolean } = {},
1138
+ ): string {
1139
+ const { bom = true, guardFormulas = false } = options
1140
+ const field = (cell: CsvCell, guard: boolean): string => {
1141
+ let text = cell === null || cell === undefined ? "" : String(cell)
1142
+ if (guard && typeof cell === "string" && /^[=+\-@]/.test(text) && !SIGNED_NUMBER.test(text)) {
1143
+ text = `'${text}`
1144
+ }
1145
+ return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
1146
+ }
1147
+ const lines = [
1148
+ header.map((h) => field(h, false)).join(","),
1149
+ ...rows.map((row) => row.map((cell) => field(cell, guardFormulas)).join(",")),
1150
+ ]
1151
+ return (bom ? "\uFEFF" : "") + lines.join("\r\n")
1152
+ }
1153
+
1154
+ /**
1155
+ * 表の現在の状態から CSV を作る。行は画面と同じ並べ替え・絞り込み後の順、列は表示中の列のみ。
1156
+ *
1157
+ * @example
1158
+ * const csv = tableToCsv(table, { values: "raw", scope: "page" })
1159
+ */
1160
+ export function tableToCsv<TData>(
1161
+ table: TanstackTable<TData>,
1162
+ { values = "display", scope = "filtered" }: { values?: CsvValues; scope?: CsvScope } = {},
1163
+ ): string {
1164
+ const columns = table.getVisibleLeafColumns().filter((column) => column.id !== "_select")
1165
+ const rows = scope === "page" ? table.getRowModel().rows : table.getSortedRowModel().rows
1166
+ const header = columns.map((column) =>
1167
+ typeof column.columnDef.header === "string" ? column.columnDef.header : column.id,
1168
+ )
1169
+ const body = rows.map((row) =>
1170
+ columns.map((column): CsvCell => {
1171
+ const value = row.getValue(column.id)
1172
+ const meta = column.columnDef.meta
1173
+ if (values === "display" && (meta?.format || meta?.dateFormat)) return formatCellText(value, row.original, meta)
1174
+ if (value === null || value === undefined) return ""
1175
+ if (typeof value === "number" || typeof value === "string") return value
1176
+ if (value instanceof Date) return value.toISOString()
1177
+ return String(value)
1178
+ }),
1179
+ )
1180
+ return toCsv(header, body, { guardFormulas: values === "display" })
1181
+ }
1182
+
1183
+ /** toolbar に置く CSV 書き出しボタン。`useDataTable()` の table から画面と同じ順・同じ集合を書き出す。 */
1184
+ export function DataTableExport({
1185
+ fileName = "export.csv",
1186
+ values = "display",
1187
+ scope = "filtered",
1188
+ children = "CSV を書き出す",
1189
+ className,
1190
+ }: DataTableExportProps) {
1191
+ const { table, isLoading } = useDataTable()
1192
+
1193
+ const onClick = () => {
1194
+ const csv = tableToCsv(table, { values, scope })
1195
+ const url = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" }))
1196
+ const a = document.createElement("a")
1197
+ a.href = url
1198
+ a.download = fileName
1199
+ a.click()
1200
+ URL.revokeObjectURL(url)
1201
+ }
1202
+
1203
+ return (
1204
+ <Button
1205
+ type="button"
1206
+ variant="outline"
1207
+ size="sm"
1208
+ className={className}
1209
+ data-slot="data-table-export"
1210
+ onClick={onClick}
1211
+ disabled={isLoading}
1212
+ >
1213
+ <Download className="size-4" aria-hidden="true" />
1214
+ {children}
1215
+ </Button>
1216
+ )
1217
+ }
1218
+ DataTableExport.displayName = "DataTableExport"
1219
+
634
1220
  /* ------------------------------------------------------------------ preset */
635
1221
 
636
1222
  /** The usual arrangement — toolbar, body and pagination — in one component. */
637
1223
  export function DataTablePreset<TData, TValue>({
638
- columns,
639
- data,
640
- enableSorting,
641
- sorting,
642
- onSortingChange,
643
- enableFiltering,
644
- columnFilters,
645
- onColumnFiltersChange,
646
- globalFilter,
647
- onGlobalFilterChange,
648
- enablePagination,
649
- pagination,
650
- onPaginationChange,
651
- pageCount,
652
- enableRowSelection,
653
- rowSelection,
654
- onRowSelectionChange,
655
1224
  enableColumnVisibility = false,
656
- columnVisibility,
657
- onColumnVisibilityChange,
658
1225
  columnLabels,
1226
+ exportable = false,
1227
+ exportFileName,
659
1228
  toolbar,
660
1229
  bulkActions,
661
- meta,
662
- className,
1230
+ ...tableProps
663
1231
  }: DataTablePresetProps<TData, TValue>) {
664
1232
  const toolbarSlot =
665
- toolbar || enableColumnVisibility ? (
1233
+ toolbar || enableColumnVisibility || exportable ? (
666
1234
  <>
667
1235
  {toolbar}
1236
+ {exportable && <DataTableExport fileName={exportFileName} />}
668
1237
  {enableColumnVisibility && <DataTableColumnVisibility columnLabels={columnLabels} />}
669
1238
  </>
670
1239
  ) : undefined
671
1240
 
672
1241
  return (
673
- <DataTable<TData, TValue>
674
- columns={columns}
675
- data={data}
676
- enableSorting={enableSorting}
677
- sorting={sorting}
678
- onSortingChange={onSortingChange}
679
- enableFiltering={enableFiltering}
680
- columnFilters={columnFilters}
681
- onColumnFiltersChange={onColumnFiltersChange}
682
- globalFilter={globalFilter}
683
- onGlobalFilterChange={onGlobalFilterChange}
684
- enablePagination={enablePagination}
685
- pagination={pagination}
686
- onPaginationChange={onPaginationChange}
687
- pageCount={pageCount}
688
- enableRowSelection={enableRowSelection}
689
- rowSelection={rowSelection}
690
- onRowSelectionChange={onRowSelectionChange}
691
- columnVisibility={columnVisibility}
692
- onColumnVisibilityChange={onColumnVisibilityChange}
693
- meta={meta}
694
- className={className}
695
- >
1242
+ <DataTable<TData, TValue> {...tableProps}>
696
1243
  <DataTableToolbar
697
- showSearch={enableFiltering}
1244
+ showSearch={tableProps.enableFiltering}
698
1245
  toolbar={toolbarSlot}
699
1246
  bulkActions={bulkActions as ((selectedRows: unknown[]) => ReactNode) | undefined}
700
1247
  />
701
1248
  <DataTableContent />
702
- {enablePagination && <DataTablePagination />}
1249
+ {tableProps.enablePagination && <DataTablePagination />}
703
1250
  </DataTable>
704
1251
  )
705
1252
  }