@squadbase/vantage 0.6.2-dev1.42c4e48 → 0.6.2-dev1.51e401a

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 (295) hide show
  1. package/dist/{build-MFWRPYKW.js → build-U7GO26HR.js} +7 -7
  2. package/dist/{build-MFWRPYKW.js.map → build-U7GO26HR.js.map} +1 -1
  3. package/dist/{check-ZBIH422T.js → check-MRVAIHG5.js} +5 -5
  4. package/dist/{check-ZBIH422T.js.map → check-MRVAIHG5.js.map} +1 -1
  5. package/dist/chunk-2IAPIOPS.js +152 -0
  6. package/dist/chunk-2IAPIOPS.js.map +1 -0
  7. package/dist/chunk-3RJEOP3J.js +174 -0
  8. package/dist/chunk-3RJEOP3J.js.map +1 -0
  9. package/dist/chunk-77CISBMG.js +7581 -0
  10. package/dist/chunk-77CISBMG.js.map +1 -0
  11. package/dist/{chunk-V3LF7BE7.js → chunk-ANZLVSEP.js} +4 -5
  12. package/dist/chunk-ANZLVSEP.js.map +1 -0
  13. package/dist/{chunk-XQUSGIDG.js → chunk-CKWPK4RO.js} +10 -3
  14. package/dist/chunk-CKWPK4RO.js.map +1 -0
  15. package/dist/chunk-EG4PX726.js +678 -0
  16. package/dist/chunk-EG4PX726.js.map +1 -0
  17. package/dist/chunk-NZ5QIIG4.js +74 -0
  18. package/dist/chunk-NZ5QIIG4.js.map +1 -0
  19. package/dist/{chunk-7DZGJQ3P.js → chunk-PJ4SPXHS.js} +23 -6
  20. package/dist/chunk-PJ4SPXHS.js.map +1 -0
  21. package/dist/{chunk-OB6UBJXF.js → chunk-QNOX7WLZ.js} +127 -36
  22. package/dist/chunk-QNOX7WLZ.js.map +1 -0
  23. package/dist/{chunk-PXRX5PEC.js → chunk-ROHNXP7J.js} +73 -4
  24. package/dist/chunk-ROHNXP7J.js.map +1 -0
  25. package/dist/{chunk-CEXAQQ5W.js → chunk-S5MDTHH7.js} +180 -18
  26. package/dist/chunk-S5MDTHH7.js.map +1 -0
  27. package/dist/{chunk-IBGOCWJB.js → chunk-SDJTTMLK.js} +247 -86
  28. package/dist/chunk-SDJTTMLK.js.map +1 -0
  29. package/dist/{chunk-QEWCYW3I.js → chunk-SHZM3EXB.js} +29 -8
  30. package/dist/chunk-SHZM3EXB.js.map +1 -0
  31. package/dist/chunk-TRMHNMMZ.js +61 -0
  32. package/dist/chunk-TRMHNMMZ.js.map +1 -0
  33. package/dist/{chunk-A2UUGASH.js → chunk-U736PVHY.js} +2 -2
  34. package/dist/chunk-U736PVHY.js.map +1 -0
  35. package/dist/{chunk-JKLLFVEU.js → chunk-UWSQUHX6.js} +4 -99
  36. package/dist/chunk-UWSQUHX6.js.map +1 -0
  37. package/dist/cli.js +6 -6
  38. package/dist/client/index.d.ts +2 -0
  39. package/dist/client/index.js +8 -2
  40. package/dist/client/index.js.map +1 -1
  41. package/dist/components/index.d.ts +1617 -101
  42. package/dist/components/index.js +7 -4
  43. package/dist/delta-text-CbrOaW83.d.ts +17 -0
  44. package/dist/{dev-DCTOHQ6W.js → dev-O42JFQ64.js} +7 -7
  45. package/dist/{dev-DCTOHQ6W.js.map → dev-O42JFQ64.js.map} +1 -1
  46. package/dist/doc/index.d.ts +215 -2
  47. package/dist/doc/index.js +423 -5
  48. package/dist/doc/index.js.map +1 -1
  49. package/dist/{doctor-5E2VEZPK.js → doctor-YD3XAAOT.js} +5 -5
  50. package/dist/{doctor-5E2VEZPK.js.map → doctor-YD3XAAOT.js.map} +1 -1
  51. package/dist/format-DihQkL1N.d.ts +249 -0
  52. package/dist/markdown/index.js +1 -1
  53. package/dist/markdown/index.js.map +1 -1
  54. package/dist/query/index.js +2 -2
  55. package/dist/{routes-3SGAFDMC.js → routes-PVKNT777.js} +4 -4
  56. package/dist/{routes-3SGAFDMC.js.map → routes-PVKNT777.js.map} +1 -1
  57. package/dist/slide/index.d.ts +272 -1
  58. package/dist/slide/index.js +1025 -4
  59. package/dist/slide/index.js.map +1 -1
  60. package/dist/table/index.js +11 -8
  61. package/dist/table/index.js.map +1 -1
  62. package/dist/ui/index.d.ts +8 -2
  63. package/dist/ui/index.js +22 -112
  64. package/dist/ui/index.js.map +1 -1
  65. package/dist/{upgrade-DBBE6GIR.js → upgrade-6DT7ADRP.js} +4 -4
  66. package/dist/{upgrade-DBBE6GIR.js.map → upgrade-6DT7ADRP.js.map} +1 -1
  67. package/dist/vite/index.js +3 -3
  68. package/docs/en/agent-skills.md +1 -1
  69. package/docs/en/changelog.md +2 -0
  70. package/docs/en/components.md +42 -17
  71. package/docs/en/doc/delta-text.md +40 -0
  72. package/docs/en/doc/doc-action-items.md +49 -0
  73. package/docs/en/doc/doc-callout.md +38 -0
  74. package/docs/en/doc/doc-data-table.md +60 -0
  75. package/docs/en/doc/doc-executive-summary.md +64 -0
  76. package/docs/en/doc/doc-figure.md +56 -0
  77. package/docs/en/doc/doc-key-findings.md +61 -0
  78. package/docs/en/doc/doc-kpi-scorecard.md +80 -0
  79. package/docs/en/doc/doc-notes-and-sources.md +43 -0
  80. package/docs/en/doc/doc-report-header.md +46 -0
  81. package/docs/en/pages-and-metadata.md +15 -0
  82. package/docs/en/parts/area-chart.md +99 -0
  83. package/docs/en/parts/bar-chart.md +91 -0
  84. package/docs/en/parts/bar-list.md +50 -0
  85. package/docs/en/parts/category-bar.md +37 -0
  86. package/docs/en/parts/chart-frame.md +39 -0
  87. package/docs/en/parts/data-table.md +22 -0
  88. package/docs/en/parts/delta.md +51 -0
  89. package/docs/en/parts/donut-chart.md +72 -0
  90. package/docs/en/parts/format.md +43 -0
  91. package/docs/en/parts/funnel-steps.md +2 -1
  92. package/docs/en/parts/heatmap.md +53 -0
  93. package/docs/en/parts/kpi-card.md +58 -0
  94. package/docs/en/parts/legend.md +35 -0
  95. package/docs/en/parts/line-chart.md +94 -0
  96. package/docs/en/parts/metric-value.md +3 -2
  97. package/docs/en/parts/progress-circle.md +33 -0
  98. package/docs/en/parts/sample-data.md +39 -0
  99. package/docs/en/parts/scatter-chart.md +56 -0
  100. package/docs/en/parts/sparkline.md +1 -1
  101. package/docs/en/parts/tracker.md +36 -0
  102. package/docs/en/parts/trend-indicator.md +17 -1
  103. package/docs/en/parts/updated-at.md +30 -0
  104. package/docs/en/parts/waterfall-chart.md +56 -0
  105. package/docs/en/slide/slide-chart-full.md +48 -0
  106. package/docs/en/slide/slide-chart-insight.md +52 -0
  107. package/docs/en/slide/slide-columns.md +46 -0
  108. package/docs/en/slide/slide-comparison.md +68 -0
  109. package/docs/en/slide/slide-cover.md +39 -0
  110. package/docs/en/slide/slide-exec-summary.md +47 -0
  111. package/docs/en/slide/slide-heading.md +38 -0
  112. package/docs/en/slide/slide-kpi-grid.md +55 -0
  113. package/docs/en/slide/slide-kpi-hero.md +58 -0
  114. package/docs/en/slide/slide-next-actions.md +49 -0
  115. package/docs/en/slide/slide-scorecard-table.md +54 -0
  116. package/docs/en/slide/slide-section.md +44 -0
  117. package/docs/en/slide/slide-source-line.md +34 -0
  118. package/docs/en/ui/alert-dialog.md +42 -0
  119. package/docs/en/ui/avatar.md +31 -0
  120. package/docs/en/ui/combobox.md +74 -0
  121. package/docs/en/ui/drawer.md +41 -0
  122. package/docs/en/ui/empty.md +1 -1
  123. package/docs/en/ui/error-state.md +4 -3
  124. package/docs/en/ui/field.md +42 -0
  125. package/docs/en/ui/hover-card.md +21 -0
  126. package/docs/en/ui/kbd.md +17 -0
  127. package/docs/en/ui/loading.md +1 -1
  128. package/docs/en/ui/pagination.md +36 -0
  129. package/docs/en/ui/radio-group.md +21 -0
  130. package/docs/en/ui/toast.md +44 -0
  131. package/docs/en/ui/toggle-group.md +4 -3
  132. package/docs/en/ui-and-theming.md +1 -1
  133. package/docs/index.json +917 -77
  134. package/docs/ja/agent-skills.md +1 -1
  135. package/docs/ja/changelog.md +2 -0
  136. package/docs/ja/components.md +39 -16
  137. package/docs/ja/doc/delta-text.md +40 -0
  138. package/docs/ja/doc/doc-action-items.md +49 -0
  139. package/docs/ja/doc/doc-callout.md +38 -0
  140. package/docs/ja/doc/doc-data-table.md +60 -0
  141. package/docs/ja/doc/doc-executive-summary.md +64 -0
  142. package/docs/ja/doc/doc-figure.md +56 -0
  143. package/docs/ja/doc/doc-key-findings.md +61 -0
  144. package/docs/ja/doc/doc-kpi-scorecard.md +80 -0
  145. package/docs/ja/doc/doc-notes-and-sources.md +43 -0
  146. package/docs/ja/doc/doc-report-header.md +46 -0
  147. package/docs/ja/page-types/slide.md +1 -0
  148. package/docs/ja/pages-and-metadata.md +15 -0
  149. package/docs/ja/parts/area-chart.md +49 -25
  150. package/docs/ja/parts/bar-chart.md +50 -24
  151. package/docs/ja/parts/bar-list.md +31 -8
  152. package/docs/ja/parts/chart-frame.md +36 -0
  153. package/docs/ja/parts/data-table.md +22 -0
  154. package/docs/ja/parts/delta.md +51 -0
  155. package/docs/ja/parts/donut-chart.md +45 -26
  156. package/docs/ja/parts/format.md +44 -0
  157. package/docs/ja/parts/funnel-steps.md +27 -8
  158. package/docs/ja/parts/heatmap.md +45 -0
  159. package/docs/ja/parts/kpi-card.md +56 -0
  160. package/docs/ja/parts/legend.md +36 -0
  161. package/docs/ja/parts/line-chart.md +48 -22
  162. package/docs/ja/parts/metric-value.md +3 -2
  163. package/docs/ja/parts/sample-data.md +61 -0
  164. package/docs/ja/parts/scatter-chart.md +69 -0
  165. package/docs/ja/parts/sparkline.md +1 -1
  166. package/docs/ja/parts/trend-indicator.md +17 -1
  167. package/docs/ja/parts/updated-at.md +27 -0
  168. package/docs/ja/parts/waterfall-chart.md +61 -0
  169. package/docs/ja/slide/slide-chart-full.md +48 -0
  170. package/docs/ja/slide/slide-chart-insight.md +52 -0
  171. package/docs/ja/slide/slide-columns.md +46 -0
  172. package/docs/ja/slide/slide-comparison.md +68 -0
  173. package/docs/ja/slide/slide-cover.md +39 -0
  174. package/docs/ja/slide/slide-exec-summary.md +47 -0
  175. package/docs/ja/slide/slide-heading.md +38 -0
  176. package/docs/ja/slide/slide-kpi-grid.md +55 -0
  177. package/docs/ja/slide/slide-kpi-hero.md +58 -0
  178. package/docs/ja/slide/slide-next-actions.md +49 -0
  179. package/docs/ja/slide/slide-scorecard-table.md +54 -0
  180. package/docs/ja/slide/slide-section.md +44 -0
  181. package/docs/ja/slide/slide-source-line.md +34 -0
  182. package/docs/ja/ui/empty.md +1 -1
  183. package/docs/ja/ui/error-state.md +4 -3
  184. package/docs/ja/ui/loading.md +1 -1
  185. package/docs/ja/ui/toggle-group.md +4 -3
  186. package/docs/ja/ui-and-theming.md +2 -2
  187. package/package.json +26 -24
  188. package/registry/ui/accordion.tsx +4 -2
  189. package/registry/ui/alert-dialog.tsx +3 -3
  190. package/registry/ui/alert.tsx +3 -1
  191. package/registry/ui/app-shell.tsx +145 -2
  192. package/registry/ui/area-chart.tsx +3111 -166
  193. package/registry/ui/attention-list.tsx +465 -0
  194. package/registry/ui/avatar.tsx +1 -1
  195. package/registry/ui/badge.tsx +3 -1
  196. package/registry/ui/bar-chart.tsx +3105 -164
  197. package/registry/ui/bar-list.tsx +3877 -181
  198. package/registry/ui/box-plot-chart.tsx +4214 -0
  199. package/registry/ui/breadcrumb.tsx +3 -1
  200. package/registry/ui/button.tsx +3 -1
  201. package/registry/ui/calendar.tsx +3 -1
  202. package/registry/ui/card.tsx +4 -2
  203. package/registry/ui/category-bar.tsx +1233 -16
  204. package/registry/ui/chart-frame.tsx +1604 -0
  205. package/registry/ui/checkbox.tsx +3 -1
  206. package/registry/ui/collapsible.tsx +3 -1
  207. package/registry/ui/combobox.tsx +2 -2
  208. package/registry/ui/command.tsx +3 -1
  209. package/registry/ui/dashboard-card.tsx +71 -28
  210. package/registry/ui/data-table.tsx +882 -68
  211. package/registry/ui/date-range-picker.tsx +400 -65
  212. package/registry/ui/delta.tsx +350 -0
  213. package/registry/ui/dialog.tsx +5 -3
  214. package/registry/ui/donut-chart.tsx +3184 -182
  215. package/registry/ui/drawer.tsx +1 -1
  216. package/registry/ui/dropdown-menu.tsx +5 -3
  217. package/registry/ui/echart.tsx +761 -69
  218. package/registry/ui/field.tsx +1 -1
  219. package/registry/ui/filter-bar.tsx +212 -26
  220. package/registry/ui/funnel-steps.tsx +3622 -53
  221. package/registry/ui/heatmap.tsx +2027 -0
  222. package/registry/ui/histogram-chart.tsx +4284 -0
  223. package/registry/ui/hover-card.tsx +2 -2
  224. package/registry/ui/input-group.tsx +3 -1
  225. package/registry/ui/input.tsx +3 -1
  226. package/registry/ui/kbd.tsx +3 -1
  227. package/registry/ui/kpi-card.tsx +905 -0
  228. package/registry/ui/label.tsx +3 -1
  229. package/registry/ui/legend.tsx +3817 -0
  230. package/registry/ui/line-chart.tsx +3109 -158
  231. package/registry/ui/metric-value.tsx +9 -1
  232. package/registry/ui/multi-select.tsx +170 -14
  233. package/registry/ui/page-shell.tsx +244 -51
  234. package/registry/ui/pagination.tsx +3 -1
  235. package/registry/ui/paired-chart.tsx +4086 -0
  236. package/registry/ui/placeholder.tsx +8 -2
  237. package/registry/ui/popover.tsx +4 -2
  238. package/registry/ui/progress-circle.tsx +773 -12
  239. package/registry/ui/progress.tsx +3 -1
  240. package/registry/ui/radio-group.tsx +1 -1
  241. package/registry/ui/sample-data.tsx +230 -0
  242. package/registry/ui/scatter-chart.tsx +4503 -0
  243. package/registry/ui/scroll-area.tsx +3 -2
  244. package/registry/ui/searchable-select.tsx +153 -10
  245. package/registry/ui/section-header.tsx +5 -3
  246. package/registry/ui/segmented-control.tsx +7 -2
  247. package/registry/ui/select.tsx +4 -2
  248. package/registry/ui/separator.tsx +3 -1
  249. package/registry/ui/sheet.tsx +4 -2
  250. package/registry/ui/sidebar.tsx +4 -2
  251. package/registry/ui/skeleton.tsx +4 -2
  252. package/registry/ui/slider.tsx +4 -2
  253. package/registry/ui/sparkline.tsx +84 -9
  254. package/registry/ui/spinner.tsx +3 -1
  255. package/registry/ui/states.tsx +194 -8
  256. package/registry/ui/status-badge.tsx +246 -39
  257. package/registry/ui/switch.tsx +3 -1
  258. package/registry/ui/table.tsx +4 -2
  259. package/registry/ui/tabs.tsx +5 -3
  260. package/registry/ui/textarea.tsx +3 -1
  261. package/registry/ui/toast.tsx +2 -2
  262. package/registry/ui/toggle-group.tsx +3 -1
  263. package/registry/ui/toggle.tsx +3 -1
  264. package/registry/ui/tooltip.tsx +10 -4
  265. package/registry/ui/tracker.tsx +798 -19
  266. package/registry/ui/trend-indicator.tsx +164 -9
  267. package/registry/ui/updated-at.tsx +276 -0
  268. package/registry/ui/waterfall-chart.tsx +4561 -0
  269. package/skills/vantage-add-feature/SKILL.md +5 -0
  270. package/skills/vantage-design/SKILL.md +60 -52
  271. package/skills/vantage-design/references/extract.md +26 -27
  272. package/skills/vantage-design/references/format.md +12 -172
  273. package/skills/vantage-design/references/presets.md +50 -110
  274. package/skills/vantage-pitfalls/SKILL.md +5 -0
  275. package/skills/vantage-slide/SKILL.md +54 -36
  276. package/templates/AGENTS.md +45 -16
  277. package/templates/agents/api.md +7 -0
  278. package/templates/agents/doc.md +48 -0
  279. package/templates/agents/page.md +69 -2
  280. package/templates/agents/slide.md +77 -3
  281. package/theme.css +247 -149
  282. package/dist/chunk-5ZOP4SQR.js +0 -47
  283. package/dist/chunk-5ZOP4SQR.js.map +0 -1
  284. package/dist/chunk-7DZGJQ3P.js.map +0 -1
  285. package/dist/chunk-A2UUGASH.js.map +0 -1
  286. package/dist/chunk-AG6RZ2RR.js +0 -3208
  287. package/dist/chunk-AG6RZ2RR.js.map +0 -1
  288. package/dist/chunk-CEXAQQ5W.js.map +0 -1
  289. package/dist/chunk-IBGOCWJB.js.map +0 -1
  290. package/dist/chunk-JKLLFVEU.js.map +0 -1
  291. package/dist/chunk-OB6UBJXF.js.map +0 -1
  292. package/dist/chunk-PXRX5PEC.js.map +0 -1
  293. package/dist/chunk-QEWCYW3I.js.map +0 -1
  294. package/dist/chunk-V3LF7BE7.js.map +0 -1
  295. package/dist/chunk-XQUSGIDG.js.map +0 -1
@@ -1,5 +1,7 @@
1
1
  // Copied by `vantage add ui data-table`. This file is yours to edit.
2
- // Generated from @squadbase/vantage src/components/data-table.tsx by `npm run registry` (in Vantage, edit src/, not registry/).
2
+ // Generated from @squadbase/squadkit registry/components/data-table.tsx by `pnpm run registry` (in Vantage, edit packages/squadkit, not registry/).
3
+ "use client"
4
+
3
5
  /**
4
6
  * DataTable — a compound table over TanStack Table. `<DataTable>` owns the
5
7
  * table instance and publishes it on context; the toolbar, body, pagination and
@@ -15,7 +17,9 @@ import {
15
17
  forwardRef,
16
18
  useCallback,
17
19
  useContext,
20
+ useEffect,
18
21
  useMemo,
22
+ useRef,
19
23
  useState,
20
24
  type HTMLAttributes,
21
25
  type ReactNode,
@@ -28,18 +32,36 @@ import {
28
32
  getPaginationRowModel,
29
33
  getSortedRowModel,
30
34
  useReactTable,
35
+ type Column,
31
36
  type ColumnDef,
32
37
  type ColumnFiltersState,
33
38
  type OnChangeFn,
34
39
  type PaginationState,
40
+ type RowData,
35
41
  type RowSelectionState,
36
42
  type SortingState,
37
43
  type Table as TanstackTable,
38
44
  type TableMeta,
39
45
  type VisibilityState,
40
46
  } from "@tanstack/react-table"
41
- import { ArrowDown, ArrowUp, ArrowUpDown, Columns3, Search } from "lucide-react"
42
-
47
+ import {
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"
43
65
  import { cn } from "@squadbase/vantage/ui"
44
66
  import { Button } from "@squadbase/vantage/ui"
45
67
  import { Checkbox } from "@squadbase/vantage/ui"
@@ -50,11 +72,335 @@ import {
50
72
  DropdownMenuTrigger,
51
73
  } from "@squadbase/vantage/ui"
52
74
  import { Input } from "@squadbase/vantage/ui"
75
+ import { Skeleton } from "@squadbase/vantage/ui"
53
76
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@squadbase/vantage/ui"
54
77
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@squadbase/vantage/ui"
78
+ import { useSyncExternalStore } from "react"
79
+
80
+ /* ---------- inlined from @squadbase/squadkit registry/lib/locale.ts (internal to @squadbase/vantage) ---------- */
81
+
82
+ /**
83
+ * The display language of the parts and the defaults that follow it.
84
+ *
85
+ * The language comes from `<html lang>`: a tag starting with `ja` reads Japanese, anything else English.
86
+ * Without a usable `lang` (missing, invalid, or no `document` — SSR, Node) the locale is `FALLBACK_LOCALE`
87
+ * (`en-US`). A `locale` passed explicitly (a prop, an argument) always wins.
88
+ *
89
+ * Parts read the locale through `useLocale` (`hooks/use-locale`) and pass it down; the pure helpers here
90
+ * and in `format` / `comparison` / `chart-*` take it as an argument and fall back to `documentLocale()`.
91
+ */
92
+
93
+ /** The language of the built-in wording: Japanese or English. */
94
+ type UiLanguage = "ja" | "en"
95
+
96
+ /** The locale used when `<html lang>` gives none (and on the server). */
97
+ const FALLBACK_LOCALE = "en-US"
98
+
99
+ /** `"ja"` when `locale` starts with `ja` (`ja`, `ja-JP`), else `"en"`. A missing locale is `"en"`. */
100
+ function uiLanguage(locale: string | null | undefined): UiLanguage {
101
+ return typeof locale === "string" && locale.trim().toLowerCase().startsWith("ja") ? "ja" : "en"
102
+ }
103
+
104
+ /** A BCP 47 tag in its canonical form (`en-gb` → `en-GB`), or `FALLBACK_LOCALE` when it is empty or invalid. */
105
+ function normalizeLocale(tag: string | null | undefined): string {
106
+ const value = tag?.trim()
107
+ if (!value) return FALLBACK_LOCALE
108
+ try {
109
+ return Intl.getCanonicalLocales(value)[0] ?? FALLBACK_LOCALE
110
+ } catch {
111
+ return FALLBACK_LOCALE
112
+ }
113
+ }
114
+
115
+ /**
116
+ * The locale of the page: `document.documentElement.lang`, canonicalized. `FALLBACK_LOCALE` when it is empty or
117
+ * invalid, or there is no `document`. Parts read it through `useLocale`, never while rendering.
118
+ */
119
+ function documentLocale(): string {
120
+ if (typeof document === "undefined") return FALLBACK_LOCALE
121
+ return normalizeLocale(document.documentElement?.getAttribute("lang"))
122
+ }
123
+
124
+ /** `locale` when given, else the page's locale (`documentLocale()`). */
125
+ function resolveLocale(locale?: string | null): string {
126
+ return locale || documentLocale()
127
+ }
128
+
129
+ /** Picks the wording table of the locale's language. */
130
+ function pickLocale<T>(table: { ja: T; en: T }, locale?: string | null): T {
131
+ return table[uiLanguage(resolveLocale(locale))]
132
+ }
133
+
134
+ /** The currency `format="currency"` uses without a `currency`: JPY for Japanese, USD otherwise. */
135
+ function defaultCurrency(locale?: string | null): string {
136
+ return uiLanguage(resolveLocale(locale)) === "ja" ? "JPY" : "USD"
137
+ }
138
+
139
+ /** The month a fiscal year starts in (1–12) when none is given: April for Japanese, January otherwise. */
140
+ function defaultFiscalYearStartMonth(locale?: string | null): number {
141
+ return uiLanguage(resolveLocale(locale)) === "ja" ? 4 : 1
142
+ }
143
+
144
+ /** A day of the week, 0 = Sunday … 6 = Saturday (the `Date#getDay` / date-fns numbering). */
145
+ type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6
146
+
147
+ /** Locales whose week starts on Sunday when the runtime has no week data. */
148
+ const SUNDAY_FIRST = new Set(["en", "en-US", "en-CA"])
149
+
150
+ /**
151
+ * The first day of the week: Monday for Japanese; otherwise the locale's convention from `Intl.Locale`
152
+ * (`en-US` Sunday, `en-GB` Monday). Without week data in the runtime: Sunday for `en` / `en-US` / `en-CA`,
153
+ * Monday for the rest.
154
+ */
155
+ function weekStartsOn(locale?: string | null): WeekDay {
156
+ const tag = normalizeLocale(resolveLocale(locale))
157
+ if (uiLanguage(tag) === "ja") return 1
158
+ try {
159
+ const info = new Intl.Locale(tag) as Intl.Locale & {
160
+ getWeekInfo?: () => { firstDay: number }
161
+ weekInfo?: { firstDay: number }
162
+ }
163
+ const firstDay = info.getWeekInfo?.().firstDay ?? info.weekInfo?.firstDay
164
+ if (typeof firstDay === "number" && firstDay >= 1 && firstDay <= 7) return (firstDay % 7) as WeekDay
165
+ } catch {
166
+ // Fall through to the table below.
167
+ }
168
+ return SUNDAY_FIRST.has(tag) ? 0 : 1
169
+ }
170
+
171
+ /* ---------- inlined from @squadbase/squadkit registry/hooks/use-locale.ts (internal to @squadbase/vantage) ---------- */
172
+
173
+ const listeners = new Set<() => void>()
174
+ let observer: MutationObserver | null = null
175
+
176
+ /** One observer on `<html lang>` for every subscribed part. */
177
+ function subscribe(onChange: () => void): () => void {
178
+ if (typeof document === "undefined" || typeof MutationObserver === "undefined") return () => {}
179
+ listeners.add(onChange)
180
+ if (!observer) {
181
+ observer = new MutationObserver(() => {
182
+ for (const listener of [...listeners]) listener()
183
+ })
184
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["lang"] })
185
+ }
186
+ return () => {
187
+ listeners.delete(onChange)
188
+ if (listeners.size === 0 && observer) {
189
+ observer.disconnect()
190
+ observer = null
191
+ }
192
+ }
193
+ }
194
+
195
+ const serverLocale = () => FALLBACK_LOCALE
196
+
197
+ /**
198
+ * The page's locale (`<html lang>`), following changes made while the page runs. On the server and during
199
+ * hydration it is `FALLBACK_LOCALE` (`en-US`), so the markup matches; it switches right after.
200
+ */
201
+ function useDocumentLocale(): string {
202
+ return useSyncExternalStore(subscribe, documentLocale, serverLocale)
203
+ }
204
+
205
+ /** The locale a part renders in: its `locale` prop when given, else the page's (`useDocumentLocale`). */
206
+ function useLocale(locale?: string | null): string {
207
+ const page = useDocumentLocale()
208
+ return locale || page
209
+ }
210
+
211
+ /* ---------- end of inlined code ---------- */
55
212
 
56
213
  /* ------------------------------------------------------------------- types */
57
214
 
215
+ declare module "@tanstack/react-table" {
216
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
217
+ interface ColumnMeta<TData extends RowData, TValue> {
218
+ /**
219
+ * Classes put on the column's header and body cells — e.g.
220
+ * `"hidden md:table-cell"` to drop a column on narrow screens.
221
+ */
222
+ className?: string
223
+ /**
224
+ * 列の数値書式。`cell` を持たない列だけに効き、右寄せ・tabular-nums で描く。
225
+ * 行ごとに通貨が変わるときは関数で行を受ける。CSV の「表示」値にも使われる。
226
+ *
227
+ * @example
228
+ * meta: { format: "currency" }
229
+ * meta: { format: (row) => ({ type: "currency", currency: row.currency, notation: "standard" }) }
230
+ */
231
+ format?: ValueFormat | ((row: TData) => ValueFormat)
232
+ /**
233
+ * 日付の列の書式(`formatDate` の `style`)。`cell` を持たない列だけに効く。左寄せ・tabular-nums で描き、
234
+ * 値は `Date`・エポックミリ秒・日付文字列(`"2026-10-20"`、ISO 8601)。CSV の「表示」値にも使われる。
235
+ * `format`(数値)とは併用しない。
236
+ *
237
+ * @example
238
+ * meta: { dateFormat: "date" }
239
+ * meta: { dateFormat: "datetime", nullLabel: "未更新" }
240
+ */
241
+ dateFormat?: DateStyle
242
+ /**
243
+ * `dateFormat` の列の日時を出すタイムゾーン(IANA 名。例 `"Asia/Tokyo"`)。既定は実行環境(ブラウザ)のタイムゾーン。
244
+ * サーバーとブラウザで表示が食い違う(SSR)なら固定する。`dateFormat` の列だけに効き、CSV の「表示」値にも同じ値を使う。
245
+ * `"2026-10-20"` のような日付だけの文字列は、どのタイムゾーンでもその暦日のまま。
246
+ *
247
+ * @example
248
+ * meta: { dateFormat: "datetime", timeZone: "Asia/Tokyo" }
249
+ */
250
+ timeZone?: string
251
+ /**
252
+ * 値が無いときの表示。既定 `"—"`。null / undefined(数値は NaN も)に効き、文字列の列では空文字 `""` も値なしとして扱う。
253
+ * `format` / `dateFormat` を持つ列と、書式なしの文字列の列に効く。`cell` を自前で書いた列には効かない(`cell` の中で扱う)。
254
+ * CSV の「表示」値は、書式付き・日付の列だけ `nullLabel` で書き、書式なしの文字列の列は空欄のまま。
255
+ *
256
+ * @example
257
+ * meta: { format: "number", nullLabel: "未集計" }
258
+ * meta: { nullLabel: "未設定" } // 書式なしの文字列の列
259
+ */
260
+ nullLabel?: string
261
+ /**
262
+ * 値の範囲でセルを意味色に塗り分ける規則(最初に当たった規則。どれにも当たらない値は通常色)。
263
+ * `cell` を持たない列だけに効き、BarChart / BarList の `thresholds` と同じ型・同じ判定。
264
+ * 色だけに頼らず、色に応じた記号(○ / △ / × / −)を値の前に添える。CSV は生の値のまま。
265
+ *
266
+ * @example
267
+ * meta: { format: { type: "percent", divisor: 100 }, thresholds: [{ lt: 40, tone: "negative" }, { lt: 70, tone: "warning" }] }
268
+ */
269
+ thresholds?: ChartThreshold[]
270
+ /**
271
+ * 狭い幅で列を隠す順。数が小さい列から隠れる(表の置き場の幅が 1 は 640px 未満、2 は 520px 未満、3 は 400px 未満)。
272
+ * 書かない列と 4 以上の列は、どの幅でも出す(部署名・離職率のような肝心の列に使う)。
273
+ * 画面の幅ではなく、表を置いた場所の幅(コンテナ幅)で決まる。隠した列は DOM にも読み上げにも出ず、CSV の出力には含まれる。
274
+ *
275
+ * @example
276
+ * meta: { format: "number", priority: 1 } // 幅が足りないときに最初に隠す
277
+ */
278
+ priority?: number
279
+ /**
280
+ * true にすると、増減(`Delta`)など「直前の列の値との比較」を表示する列になる。表の置き場が 320px 未満に狭まると、
281
+ * この列は隠れる代わりに、直前の列(値の列)のセルの下に同じ内容を小さく(text-xs)積んで出す。比較は消えない。
282
+ * `priority` で隠す順は最後(どの列よりも後)になり、この列の `priority` は無視される。直前の列も `priority` に関わらず残る。
283
+ * 列の `cell` に `<Delta variant="inline" … />` を書く。直前に列が無いときは積まず、通常の列として出す。CSV には通常の列として含まれる。
284
+ *
285
+ * @example
286
+ * { accessorKey: "revenue", header: "売上", meta: { format: "currency" } },
287
+ * { 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" }} /> }
288
+ */
289
+ comparison?: boolean
290
+ /**
291
+ * 列の寄せ。見出し(ソートボタンを含む)とセルの両方に効く。既定は、`format` / `thresholds` の列と
292
+ * `comparison` の列(増減 `Delta`)が `"right"`、それ以外は `"left"`。右寄せの列は tabular-nums で描く。
293
+ *
294
+ * @example
295
+ * meta: { align: "right" }
296
+ */
297
+ align?: "left" | "right"
298
+ }
299
+ }
300
+
301
+ /* ------------------------------------------------------------ wording */
302
+
303
+ const TEXT = {
304
+ ja: {
305
+ tones: { positive: "良好", warning: "注意", negative: "要対応", neutral: "中立" } as Record<ChartTone, string>,
306
+ toneNote: (label: string) => `(${label})`,
307
+ selectAll: "全て選択",
308
+ selectRow: "行を選択",
309
+ trend: (points: number, first: string, last: string, min: string, max: string) =>
310
+ `${points}点の推移、${first}から${last}、最小${min}、最大${max}`,
311
+ noData: "データがありません",
312
+ pageSize: (size: number) => `${size}件`,
313
+ range: (total: number, start: number, end: number) => `${total}件中 ${start} - ${end} を表示`,
314
+ first: "最初",
315
+ previous: "前へ",
316
+ next: "次へ",
317
+ last: "最後",
318
+ columns: "表示する列",
319
+ exportCsv: "CSV を書き出す",
320
+ },
321
+ en: {
322
+ tones: { positive: "Good", warning: "Caution", negative: "Needs action", neutral: "Neutral" } as Record<ChartTone, string>,
323
+ toneNote: (label: string) => ` (${label})`,
324
+ selectAll: "Select all",
325
+ selectRow: "Select row",
326
+ trend: (points: number, first: string, last: string, min: string, max: string) =>
327
+ `Trend over ${points} points: ${first} to ${last}, min ${min}, max ${max}`,
328
+ noData: "No results.",
329
+ pageSize: (size: number) => `${size} / page`,
330
+ range: (total: number, start: number, end: number) => `Showing ${start}–${end} of ${total}`,
331
+ first: "First",
332
+ previous: "Previous",
333
+ next: "Next",
334
+ last: "Last",
335
+ columns: "Columns",
336
+ exportCsv: "Export CSV",
337
+ },
338
+ } satisfies Record<UiLanguage, unknown>
339
+
340
+ const useText = () => TEXT[uiLanguage(useLocale())]
341
+
342
+ /* ------------------------------------------------------------ cell formatting */
343
+
344
+ /** Resolve a column's `meta.format` for one row. */
345
+ function resolveFormat<TData>(
346
+ format: ValueFormat | ((row: TData) => ValueFormat) | undefined,
347
+ row: TData,
348
+ ): ValueFormat | undefined {
349
+ return typeof format === "function" ? format(row) : format
350
+ }
351
+
352
+ /** 値なし: null / undefined / NaN / 空文字。 */
353
+ function isMissingCell(value: unknown): boolean {
354
+ return value === null || value === undefined || value === "" || (typeof value === "number" && Number.isNaN(value))
355
+ }
356
+
357
+ /**
358
+ * Display string of a column's value (`meta.format` / `meta.dateFormat` / plain);
359
+ * a missing value renders as `nullLabel` (default `—`).
360
+ */
361
+ function formatCellText<TData>(
362
+ value: unknown,
363
+ row: TData,
364
+ meta: { format?: ValueFormat | ((row: TData) => ValueFormat); dateFormat?: DateStyle; timeZone?: string; nullLabel?: string },
365
+ locale: string,
366
+ ): string {
367
+ if (isMissingCell(value)) return meta.nullLabel ?? EMPTY_VALUE
368
+ if (meta.dateFormat && (value instanceof Date || typeof value === "string" || typeof value === "number")) {
369
+ const text = formatDate(value, meta.dateFormat, locale, { timeZone: meta.timeZone })
370
+ return text === EMPTY_VALUE ? String(value) : text
371
+ }
372
+ if (typeof value !== "number") return String(value)
373
+ return formatValue(value, resolveFormat(meta.format, row), locale)
374
+ }
375
+
376
+ /** 意味色ごとの記号と色クラス。Tailwind が拾えるよう、クラスはリテラルで書く。 */
377
+ const THRESHOLD_TONE_STYLES: Record<ChartTone, { Icon: LucideIcon; text: string }> = {
378
+ positive: { Icon: CircleCheck, text: "text-positive" },
379
+ warning: { Icon: TriangleAlert, text: "text-warning" },
380
+ negative: { Icon: CircleX, text: "text-negative" },
381
+ neutral: { Icon: Minus, text: "text-neutral" },
382
+ }
383
+
384
+ /** `meta.thresholds` 列のセル。当たった規則の意味色と記号を値に添える(当たらなければ値だけ)。 */
385
+ function ThresholdCell({ value, text, thresholds }: { value: unknown; text: string; thresholds: ChartThreshold[] }) {
386
+ const t = useText()
387
+ const tone = typeof value === "number" ? toneForValue(value, thresholds) : undefined
388
+ if (!tone) return <div className="text-right tabular-nums">{text}</div>
389
+ const { Icon, text: toneClass } = THRESHOLD_TONE_STYLES[tone]
390
+ const label = t.tones[tone]
391
+ return (
392
+ <div
393
+ data-slot="data-table-threshold-cell"
394
+ data-tone={tone}
395
+ className={cn("flex items-center justify-end gap-1.5 tabular-nums", toneClass)}
396
+ >
397
+ <Icon className="size-3.5 shrink-0" aria-hidden="true" />
398
+ <span>{text}</span>
399
+ <span className="sr-only">{t.toneNote(label)}</span>
400
+ </div>
401
+ )
402
+ }
403
+
58
404
  interface DataTableContextValue<TData> {
59
405
  table: TanstackTable<TData>
60
406
  selectedRows: TData[]
@@ -65,6 +411,7 @@ interface DataTableContextValue<TData> {
65
411
  enablePagination: boolean
66
412
  enableRowSelection: boolean
67
413
  visibleColumnCount: number
414
+ isLoading: boolean
68
415
  }
69
416
 
70
417
  /**
@@ -80,7 +427,8 @@ export interface DataTableProps<TData, TValue> {
80
427
  * literal が `string` に拡張され型エラーになる。
81
428
  *
82
429
  * @example
83
- * import type { ColumnDef } from "@squadbase/vantage/components"
430
+ * import { DataTable, type ColumnDef } from "@/components/data-table"
431
+ * // Vantage: import { DataTable, type ColumnDef } from "@squadbase/vantage/components"
84
432
  *
85
433
  * const columns: ColumnDef<User>[] = [
86
434
  * { accessorKey: "id", header: "ID" },
@@ -107,7 +455,42 @@ export interface DataTableProps<TData, TValue> {
107
455
  enablePagination?: boolean
108
456
  pagination?: PaginationState
109
457
  onPaginationChange?: OnChangeFn<PaginationState>
458
+ /**
459
+ * サーバー側ページング時の総ページ数。指定すると manualPagination になる。
460
+ *
461
+ * @example
462
+ * <DataTable pageCount={data.pageCount} … />
463
+ */
110
464
  pageCount?: number
465
+ /**
466
+ * サーバー側ページング時の総件数。指定すると manualPagination になり、ページ数は
467
+ * `rowCount / pageSize` から導かれ、「N件中」もこの値を使う(`pageCount` を同時に渡せばそちらが優先)。
468
+ *
469
+ * @example
470
+ * <DataTable rowCount={1240} pagination={pagination} onPaginationChange={setPagination} … />
471
+ */
472
+ rowCount?: number
473
+ /**
474
+ * true にすると絞り込みを表では行わず、`data` をそのまま使う(サーバー側で絞り込む)。
475
+ *
476
+ * @example
477
+ * <DataTable manualFiltering globalFilter={q} onGlobalFilterChange={setQ} … />
478
+ */
479
+ manualFiltering?: boolean
480
+ /**
481
+ * true にすると並べ替えを表では行わず、`data` の順をそのまま使う(サーバー側で並べ替える)。
482
+ *
483
+ * @example
484
+ * <DataTable manualSorting sorting={sorting} onSortingChange={setSorting} … />
485
+ */
486
+ manualSorting?: boolean
487
+ /**
488
+ * 読み込み中。Content はスケルトン行、Pagination のボタンは無効、表は aria-busy になる。
489
+ *
490
+ * @example
491
+ * <DataTable isLoading={query.isFetching} … />
492
+ */
493
+ isLoading?: boolean
111
494
 
112
495
  // Row selection
113
496
  enableRowSelection?: boolean
@@ -118,6 +501,13 @@ export interface DataTableProps<TData, TValue> {
118
501
  columnVisibility?: VisibilityState
119
502
  onColumnVisibilityChange?: OnChangeFn<VisibilityState>
120
503
 
504
+ /**
505
+ * TanStack Table の `meta`。表全体で共有する値(行を更新する関数など)を渡し、列定義の `cell` から
506
+ * `table.options.meta` で読む。列ごとの書式は列定義の `meta.format` に書く(こちらではない)。
507
+ *
508
+ * @example
509
+ * <DataTable meta={{ updateRow: (id, patch) => mutate({ id, patch }) }} … />
510
+ */
121
511
  meta?: TableMeta<TData>
122
512
  className?: string
123
513
 
@@ -133,6 +523,33 @@ export interface DataTableToolbarProps extends HTMLAttributes<HTMLDivElement> {
133
523
 
134
524
  export interface DataTableContentProps extends HTMLAttributes<HTMLDivElement> {}
135
525
 
526
+ export interface DataTableExportProps {
527
+ /**
528
+ * 保存するファイル名。既定 `"export.csv"`。
529
+ *
530
+ * @example
531
+ * <DataTableExport fileName="sales-2025.csv" />
532
+ */
533
+ fileName?: string
534
+ /**
535
+ * `"display"`(既定)は `meta.format` どおりの表示文字列、`"raw"` は生の値を書き出す。
536
+ *
537
+ * @example
538
+ * <DataTableExport values="raw" />
539
+ */
540
+ values?: CsvValues
541
+ /**
542
+ * `"filtered"`(既定)は並べ替え・絞り込み後の全ページ、`"page"` は表示中のページだけ。
543
+ *
544
+ * @example
545
+ * <DataTableExport scope="page" />
546
+ */
547
+ scope?: CsvScope
548
+ /** ボタンの文言。既定はページの言語で `"CSV を書き出す"`(英語は `"Export CSV"`)。 */
549
+ children?: ReactNode
550
+ className?: string
551
+ }
552
+
136
553
  export interface DataTablePaginationProps extends HTMLAttributes<HTMLDivElement> {
137
554
  pageSizeOptions?: number[]
138
555
  }
@@ -150,6 +567,20 @@ export type DataTablePresetProps<TData, TValue> = Omit<
150
567
  > & {
151
568
  enableColumnVisibility?: boolean
152
569
  columnLabels?: Record<string, string>
570
+ /**
571
+ * true にすると toolbar に CSV 書き出しボタン(`DataTableExport`)を置く。既定 false。
572
+ *
573
+ * @example
574
+ * <DataTablePreset exportable … />
575
+ */
576
+ exportable?: boolean
577
+ /**
578
+ * `exportable` のときの保存ファイル名。既定 `"export.csv"`。
579
+ *
580
+ * @example
581
+ * <DataTablePreset exportable exportFileName="sales.csv" … />
582
+ */
583
+ exportFileName?: string
153
584
 
154
585
  /** Extension slots */
155
586
  toolbar?: ReactNode
@@ -194,9 +625,12 @@ export function useDataTable<TData>(): DataTableContextValue<TData> {
194
625
 
195
626
  /* -------------------------------------------------------------------- root */
196
627
 
628
+ /** Columns / rows not given yet (a draft fills props in steps): draw the empty state, keep the same array between renders. */
629
+ const NO_ROWS: never[] = []
630
+
197
631
  export function DataTable<TData, TValue>({
198
- columns,
199
- data,
632
+ columns = NO_ROWS,
633
+ data = NO_ROWS,
200
634
  enableSorting = true,
201
635
  sorting: sortingProp,
202
636
  onSortingChange,
@@ -209,6 +643,10 @@ export function DataTable<TData, TValue>({
209
643
  pagination: paginationProp,
210
644
  onPaginationChange,
211
645
  pageCount,
646
+ rowCount,
647
+ manualFiltering,
648
+ manualSorting,
649
+ isLoading = false,
212
650
  enableRowSelection = false,
213
651
  rowSelection: rowSelectionProp,
214
652
  onRowSelectionChange,
@@ -218,6 +656,8 @@ export function DataTable<TData, TValue>({
218
656
  className,
219
657
  children,
220
658
  }: DataTableProps<TData, TValue>) {
659
+ const locale = useLocale()
660
+ const t = TEXT[uiLanguage(locale)]
221
661
  const [sorting, setSorting] = useControlledState<SortingState>(sortingProp, [], onSortingChange)
222
662
 
223
663
  const [columnFilters, setColumnFilters] = useControlledState<ColumnFiltersState>(
@@ -252,7 +692,29 @@ export function DataTable<TData, TValue>({
252
692
 
253
693
  // Row selection prepends a checkbox column that the caller never declares.
254
694
  const allColumns = useMemo<ColumnDef<TData, TValue>[]>(() => {
255
- if (!enableRowSelection) return columns
695
+ // A column with `meta.format` / `meta.thresholds` and no `cell` gets a formatted, right-aligned
696
+ // cell; thresholds add the tone colour plus a symbol. `meta.dateFormat` gives a left-aligned date.
697
+ // A plain accessor column (no `cell`) shows `nullLabel` / `—` for a missing value.
698
+ const formatted = columns.map((column) => {
699
+ const meta = column.meta
700
+ if (column.cell !== undefined) return column
701
+ const hasAccessor = "accessorKey" in column || "accessorFn" in column
702
+ if (!(meta?.format || meta?.thresholds || meta?.dateFormat) && !hasAccessor) return column
703
+ const thresholds = meta?.thresholds
704
+ const aligned = Boolean(meta?.format || thresholds)
705
+ return {
706
+ ...column,
707
+ cell: ({ row, getValue }) => {
708
+ const value = getValue()
709
+ const text = formatCellText(value, row.original, meta ?? {}, locale)
710
+ if (thresholds) return <ThresholdCell value={value} text={text} thresholds={thresholds} />
711
+ if (aligned) return <div className="text-right tabular-nums">{text}</div>
712
+ if (meta?.dateFormat) return <span className="tabular-nums">{text}</span>
713
+ return text
714
+ },
715
+ } as ColumnDef<TData, TValue>
716
+ })
717
+ if (!enableRowSelection) return formatted
256
718
 
257
719
  const selectionColumn: ColumnDef<TData, TValue> = {
258
720
  id: "_select",
@@ -261,24 +723,40 @@ export function DataTable<TData, TValue>({
261
723
  checked={table.getIsAllPageRowsSelected()}
262
724
  indeterminate={table.getIsSomePageRowsSelected()}
263
725
  onCheckedChange={(checked) => table.toggleAllPageRowsSelected(checked)}
264
- aria-label="全て選択"
726
+ aria-label={t.selectAll}
265
727
  />
266
728
  ),
267
729
  cell: ({ row }) => (
268
730
  <Checkbox
269
731
  checked={row.getIsSelected()}
270
732
  onCheckedChange={(checked) => row.toggleSelected(checked)}
271
- aria-label="行を選択"
733
+ aria-label={t.selectRow}
272
734
  />
273
735
  ),
274
736
  enableSorting: false,
275
737
  enableHiding: false,
276
738
  }
277
739
 
278
- return [selectionColumn, ...columns]
279
- }, [columns, enableRowSelection])
740
+ return [selectionColumn, ...formatted]
741
+ }, [columns, enableRowSelection, locale, t])
742
+
743
+ // TanStack's own `autoResetPageIndex` queues `resetPageIndex()` from inside a render-time memo, which
744
+ // can fire before the component has mounted ("Can't perform a React state update on a component that
745
+ // hasn't mounted yet"). Turn it off and send the page back to 1 from an effect instead, so it only
746
+ // runs after mount and only when the sort / filter / data actually changed.
747
+ const autoResetPage = enablePagination && pageCount === undefined && rowCount === undefined
748
+ const resetKey = useRef<readonly unknown[] | null>(null)
749
+ useEffect(() => {
750
+ const next = [data, sorting, columnFilters, globalFilter]
751
+ const prev = resetKey.current
752
+ resetKey.current = next
753
+ if (!autoResetPage || prev === null) return
754
+ if (prev.every((value, i) => value === next[i])) return
755
+ setPagination((old) => (old.pageIndex === 0 ? old : { ...old, pageIndex: 0 }))
756
+ }, [autoResetPage, data, sorting, columnFilters, globalFilter, setPagination])
280
757
 
281
758
  const table = useReactTable({
759
+ autoResetPageIndex: false,
282
760
  data,
283
761
  columns: allColumns,
284
762
  state: {
@@ -297,7 +775,11 @@ export function DataTable<TData, TValue>({
297
775
  onRowSelectionChange: setRowSelection,
298
776
  onColumnVisibilityChange: setColumnVisibility,
299
777
  ...(enablePagination ? { onPaginationChange: setPagination } : {}),
300
- ...(pageCount !== undefined ? { pageCount, manualPagination: true } : {}),
778
+ ...(pageCount !== undefined || rowCount !== undefined
779
+ ? { pageCount, rowCount, manualPagination: true }
780
+ : {}),
781
+ ...(manualFiltering ? { manualFiltering: true } : {}),
782
+ ...(manualSorting ? { manualSorting: true } : {}),
301
783
  getCoreRowModel: getCoreRowModel(),
302
784
  getSortedRowModel: getSortedRowModel(),
303
785
  getFilteredRowModel: getFilteredRowModel(),
@@ -325,6 +807,7 @@ export function DataTable<TData, TValue>({
325
807
  enablePagination,
326
808
  enableRowSelection,
327
809
  visibleColumnCount: allColumns.length,
810
+ isLoading,
328
811
  }),
329
812
  // `useReactTable` returns the same object every render, so each piece of
330
813
  // table state has to be tracked explicitly to re-render the consumers.
@@ -339,6 +822,8 @@ export function DataTable<TData, TValue>({
339
822
  enablePagination,
340
823
  enableRowSelection,
341
824
  allColumns.length,
825
+ isLoading,
826
+ rowCount,
342
827
  sorting,
343
828
  columnFilters,
344
829
  columnVisibility,
@@ -349,7 +834,7 @@ export function DataTable<TData, TValue>({
349
834
 
350
835
  return (
351
836
  <DataTableContext.Provider value={contextValue as DataTableContextValue<unknown>}>
352
- <div data-slot="data-table" className={cn("space-y-4", className)}>
837
+ <div data-slot="data-table" aria-busy={isLoading || undefined} className={cn("space-y-4", className)}>
353
838
  {children}
354
839
  </div>
355
840
  </DataTableContext.Provider>
@@ -357,6 +842,76 @@ export function DataTable<TData, TValue>({
357
842
  }
358
843
  DataTable.displayName = "DataTable"
359
844
 
845
+ /* ------------------------------------------------------ sparkline column */
846
+
847
+ /** Fewest points a row needs for a trend (one point has no direction). */
848
+ const SPARKLINE_MIN_POINTS = 2
849
+
850
+ export interface SparklineColumnOptions<TData> {
851
+ /** 列 ID(CSV・列の表示切り替えで使う)。 */
852
+ id: string
853
+ /** 見出し。 */
854
+ header: string
855
+ /**
856
+ * 行の数列を返す(古い順。例: 直近 8 週の売上)。null / undefined / 2 点未満の行は「—」。
857
+ * NaN などの有限でない値は取り除いてから数える。
858
+ */
859
+ values: (row: TData) => readonly (number | null | undefined)[] | null | undefined
860
+ /** スクリーンリーダー向けの数値の書式(`meta.format` と同じ語彙)。既定は `"number"`。 */
861
+ format?: ValueFormat
862
+ }
863
+
864
+ /**
865
+ * 行の数列をセルの小さなトレンド(Sparkline)にする列。色は `--chart-1` 固定、軸・ツールチップ・ラベルなし。
866
+ * 図は読み上げず、同じセルの sr-only に「N 点、最初 → 最後、最小、最大」を書式付きで添える。
867
+ * 数列の並べ替えは意味が無いので無効。DataTable 本体の props は増やさず、`columns` に置くだけで使う。
868
+ *
869
+ * @example
870
+ * const columns: ColumnDef<Store>[] = [
871
+ * { accessorKey: "store", header: "店舗" },
872
+ * sparklineColumn<Store>({ id: "trend", header: "直近 8 週", values: (r) => r.weekly, format: "currency" }),
873
+ * ]
874
+ */
875
+ export function sparklineColumn<TData>({
876
+ id,
877
+ header,
878
+ values,
879
+ format = "number",
880
+ }: SparklineColumnOptions<TData>): ColumnDef<TData, unknown> {
881
+ return {
882
+ id,
883
+ header,
884
+ accessorFn: (row) => values(row) ?? null,
885
+ enableSorting: false,
886
+ cell: ({ getValue }) => {
887
+ const raw = getValue() as readonly (number | null | undefined)[] | null
888
+ const series = (raw ?? []).filter((v): v is number => typeof v === "number" && Number.isFinite(v))
889
+ if (series.length < SPARKLINE_MIN_POINTS) return <span className="text-muted-foreground">{EMPTY_VALUE}</span>
890
+ return (
891
+ <div data-slot="data-table-sparkline-cell" className="flex h-5 items-center">
892
+ <div aria-hidden="true" className="h-5 w-24 shrink-0">
893
+ <Sparkline data={series.map((value) => ({ value }))} height={20} />
894
+ </div>
895
+ <SparklineSummary series={series} format={format} />
896
+ </div>
897
+ )
898
+ },
899
+ }
900
+ }
901
+
902
+ /** The sparkline cell's screen-reader text, in the page's language. */
903
+ function SparklineSummary({ series, format }: { series: number[]; format: ValueFormat }) {
904
+ const locale = useLocale()
905
+ const f = (v: number) => formatValue(v, format, locale)
906
+ const first = series[0] as number
907
+ const last = series[series.length - 1] as number
908
+ return (
909
+ <span className="sr-only">
910
+ {TEXT[uiLanguage(locale)].trend(series.length, f(first), f(last), f(Math.min(...series)), f(Math.max(...series)))}
911
+ </span>
912
+ )
913
+ }
914
+
360
915
  /* ----------------------------------------------------------------- toolbar */
361
916
 
362
917
  export const DataTableToolbar = forwardRef<HTMLDivElement, DataTableToolbarProps>(
@@ -399,63 +954,200 @@ DataTableToolbar.displayName = "DataTableToolbar"
399
954
 
400
955
  /* ----------------------------------------------------------------- content */
401
956
 
957
+ /**
958
+ * `meta.priority` → the classes that hide the column below a container width (`@container` on the content wrapper).
959
+ * The class strings are literals so Tailwind picks them up.
960
+ */
961
+ const PRIORITY_CLASS: Record<number, string> = {
962
+ 1: "hidden @min-[640px]:table-cell",
963
+ 2: "hidden @min-[520px]:table-cell",
964
+ 3: "hidden @min-[400px]:table-cell",
965
+ }
966
+
967
+ /**
968
+ * A `meta.comparison` column is the last tier: it stays as its own column down to 320px, below which it is hidden and its
969
+ * content is stacked under the value cell instead (`COMPARISON_STACK_CLASS`, visible only below the same width).
970
+ */
971
+ const COMPARISON_CLASS = "hidden @min-[320px]:table-cell"
972
+ const COMPARISON_STACK_CLASS = "@min-[320px]:hidden"
973
+
974
+ /** A column's hide-on-narrow classes: none without a priority (or at 4 and above), the lowest tier for anything below 1. */
975
+ export function priorityClassName(priority: number | undefined): string | undefined {
976
+ if (priority === undefined || !Number.isFinite(priority) || priority >= 4) return undefined
977
+ return PRIORITY_CLASS[Math.max(1, Math.floor(priority))]
978
+ }
979
+
980
+ type CellMeta = {
981
+ className?: string
982
+ format?: unknown
983
+ thresholds?: unknown
984
+ priority?: number
985
+ comparison?: boolean
986
+ align?: "left" | "right"
987
+ }
988
+
989
+ /** `meta.align` wins; otherwise numeric columns (`format` / `thresholds`) and Delta (`comparison`) columns are right-aligned, header included. */
990
+ function isRightAligned(meta: CellMeta | undefined): boolean {
991
+ if (meta?.align) return meta.align === "right"
992
+ return Boolean(meta?.format || meta?.thresholds || meta?.comparison)
993
+ }
994
+
995
+ /**
996
+ * A column that carries a stacked comparison (`isHost`) never hides, and a comparison column ignores its own
997
+ * `priority` and takes the last tier.
998
+ */
999
+ function columnClassName(meta: CellMeta | undefined, options: { isComparison?: boolean; isHost?: boolean } = {}) {
1000
+ const tier = options.isComparison
1001
+ ? COMPARISON_CLASS
1002
+ : options.isHost
1003
+ ? undefined
1004
+ : priorityClassName(meta?.priority)
1005
+ return cn(tier, meta?.className, isRightAligned(meta) ? "text-right tabular-nums" : undefined) || undefined
1006
+ }
1007
+
1008
+ /**
1009
+ * Pair every `meta.comparison` column with the visible column right before it (the value it compares).
1010
+ * A comparison column with nothing before it stays an ordinary column.
1011
+ */
1012
+ function pairComparisons<TData>(columns: Column<TData, unknown>[]) {
1013
+ const comparisonOf = new Map<string, Column<TData, unknown>>() // host column id → its comparison column
1014
+ const comparisonIds = new Set<string>()
1015
+ columns.forEach((column, i) => {
1016
+ if (i > 0 && column.columnDef.meta?.comparison && !columns[i - 1]!.columnDef.meta?.comparison) {
1017
+ comparisonOf.set(columns[i - 1]!.id, column)
1018
+ comparisonIds.add(column.id)
1019
+ }
1020
+ })
1021
+ return { comparisonOf, comparisonIds }
1022
+ }
1023
+
1024
+ const SORT_ICON_CLASS = "size-4 shrink-0"
1025
+
1026
+ /** The sort icon: always shown on the sorted column, only on hover / keyboard focus on the others. */
1027
+ function SortIcon({ sorted }: { sorted: false | "asc" | "desc" }) {
1028
+ if (sorted === "asc") return <ArrowUp className={SORT_ICON_CLASS} aria-hidden="true" />
1029
+ if (sorted === "desc") return <ArrowDown className={SORT_ICON_CLASS} aria-hidden="true" />
1030
+ return (
1031
+ <ArrowUpDown
1032
+ className={cn(
1033
+ SORT_ICON_CLASS,
1034
+ "text-muted-foreground opacity-0 group-hover/sort:opacity-100 group-focus-visible/sort:opacity-100",
1035
+ )}
1036
+ aria-hidden="true"
1037
+ />
1038
+ )
1039
+ }
1040
+
402
1041
  export const DataTableContent = forwardRef<HTMLDivElement, DataTableContentProps>(
403
1042
  ({ className, ...props }, ref) => {
404
- const { table, visibleColumnCount } = useDataTable()
1043
+ const { table, visibleColumnCount, isLoading, enablePagination } = useDataTable()
1044
+ const t = useText()
1045
+ const { comparisonOf, comparisonIds } = pairComparisons(table.getVisibleLeafColumns())
405
1046
 
406
1047
  return (
407
1048
  <div
408
1049
  ref={ref}
409
1050
  data-slot="data-table-content"
410
- className={cn("overflow-x-auto", className)}
1051
+ aria-busy={isLoading || undefined}
1052
+ className={cn("@container overflow-x-auto", className)}
411
1053
  {...props}
412
1054
  >
413
- <Table>
1055
+ <Table className="[&_th:first-child]:pl-0 [&_td:first-child]:pl-0 [&_th:last-child]:pr-0 [&_td:last-child]:pr-0">
414
1056
  <TableHeader>
415
1057
  {table.getHeaderGroups().map((headerGroup) => (
416
1058
  <TableRow key={headerGroup.id}>
417
- {headerGroup.headers.map((header) => (
418
- <TableHead key={header.id}>
419
- {header.isPlaceholder ? null : header.column.getCanSort() ? (
420
- <button
421
- type="button"
422
- 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"
423
- onClick={header.column.getToggleSortingHandler()}
424
- aria-sort={
425
- header.column.getIsSorted() === "asc"
1059
+ {headerGroup.headers.map((header) => {
1060
+ const column = header.column
1061
+ const meta = column.columnDef.meta
1062
+ const sorted = column.getIsSorted()
1063
+ const canSort = column.getCanSort()
1064
+ const label = header.isPlaceholder ? null : flexRender(column.columnDef.header, header.getContext())
1065
+ return (
1066
+ <TableHead
1067
+ key={header.id}
1068
+ className={columnClassName(meta, {
1069
+ isComparison: comparisonIds.has(column.id),
1070
+ isHost: comparisonOf.has(column.id),
1071
+ })}
1072
+ aria-sort={
1073
+ canSort && !header.isPlaceholder
1074
+ ? sorted === "asc"
426
1075
  ? "ascending"
427
- : header.column.getIsSorted() === "desc"
1076
+ : sorted === "desc"
428
1077
  ? "descending"
429
1078
  : "none"
430
- }
431
- >
432
- {flexRender(header.column.columnDef.header, header.getContext())}
433
- {header.column.getIsSorted() === "asc" ? (
434
- <ArrowUp className="size-4" />
435
- ) : header.column.getIsSorted() === "desc" ? (
436
- <ArrowDown className="size-4" />
437
- ) : (
438
- <ArrowUpDown className="size-4 text-muted-foreground" />
439
- )}
440
- </button>
441
- ) : (
442
- flexRender(header.column.columnDef.header, header.getContext())
443
- )}
444
- </TableHead>
445
- ))}
1079
+ : undefined
1080
+ }
1081
+ >
1082
+ {header.isPlaceholder ? null : canSort ? (
1083
+ <button
1084
+ type="button"
1085
+ className={cn(
1086
+ "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",
1087
+ isRightAligned(meta) ? "flex w-full justify-end" : "inline-flex",
1088
+ )}
1089
+ onClick={column.getToggleSortingHandler()}
1090
+ >
1091
+ {isRightAligned(meta) && <SortIcon sorted={sorted} />}
1092
+ {label}
1093
+ {!isRightAligned(meta) && <SortIcon sorted={sorted} />}
1094
+ </button>
1095
+ ) : (
1096
+ label
1097
+ )}
1098
+ </TableHead>
1099
+ )
1100
+ })}
446
1101
  </TableRow>
447
1102
  ))}
448
1103
  </TableHeader>
449
1104
 
450
1105
  <TableBody>
451
- {table.getRowModel().rows.length ? (
1106
+ {isLoading ? (
1107
+ Array.from({ length: enablePagination ? table.getState().pagination.pageSize : 5 }).map(
1108
+ (_, i) => (
1109
+ <TableRow key={i} data-slot="data-table-skeleton-row">
1110
+ {Array.from({ length: visibleColumnCount }).map((__, j) => (
1111
+ <TableCell key={j}>
1112
+ <Skeleton className="h-4 w-full" />
1113
+ </TableCell>
1114
+ ))}
1115
+ </TableRow>
1116
+ ),
1117
+ )
1118
+ ) : table.getRowModel().rows.length ? (
452
1119
  table.getRowModel().rows.map((row) => (
453
1120
  <TableRow key={row.id} data-state={row.getIsSelected() ? "selected" : undefined}>
454
- {row.getVisibleCells().map((cell) => (
455
- <TableCell key={cell.id}>
456
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
457
- </TableCell>
458
- ))}
1121
+ {row.getVisibleCells().map((cell) => {
1122
+ const meta = cell.column.columnDef.meta
1123
+ const comparisonColumn = comparisonOf.get(cell.column.id)
1124
+ const comparisonCell = comparisonColumn
1125
+ ? row.getVisibleCells().find((c) => c.column.id === comparisonColumn.id)
1126
+ : undefined
1127
+ return (
1128
+ <TableCell
1129
+ key={cell.id}
1130
+ className={columnClassName(meta, {
1131
+ isComparison: comparisonIds.has(cell.column.id),
1132
+ isHost: comparisonOf.has(cell.column.id),
1133
+ })}
1134
+ >
1135
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
1136
+ {comparisonCell && (
1137
+ <div
1138
+ data-slot="data-table-stacked-comparison"
1139
+ className={cn(
1140
+ COMPARISON_STACK_CLASS,
1141
+ "mt-0.5 text-xs [&_[data-slot=delta]]:text-xs",
1142
+ isRightAligned(meta) ? "text-right" : undefined,
1143
+ )}
1144
+ >
1145
+ {flexRender(comparisonCell.column.columnDef.cell, comparisonCell.getContext())}
1146
+ </div>
1147
+ )}
1148
+ </TableCell>
1149
+ )
1150
+ })}
459
1151
  </TableRow>
460
1152
  ))
461
1153
  ) : (
@@ -464,7 +1156,7 @@ export const DataTableContent = forwardRef<HTMLDivElement, DataTableContentProps
464
1156
  colSpan={visibleColumnCount}
465
1157
  className="h-24 text-center text-muted-foreground"
466
1158
  >
467
- データがありません
1159
+ {t.noData}
468
1160
  </TableCell>
469
1161
  </TableRow>
470
1162
  )}
@@ -480,36 +1172,46 @@ DataTableContent.displayName = "DataTableContent"
480
1172
 
481
1173
  export const DataTablePagination = forwardRef<HTMLDivElement, DataTablePaginationProps>(
482
1174
  ({ pageSizeOptions = [10, 20, 50], className, ...props }, ref) => {
483
- const { table } = useDataTable()
1175
+ const { table, isLoading } = useDataTable()
1176
+ const t = useText()
484
1177
 
485
- const totalRows = table.getFilteredRowModel().rows.length
1178
+ const totalRows =
1179
+ table.options.rowCount !== undefined
1180
+ ? table.getRowCount()
1181
+ : table.getFilteredRowModel().rows.length
486
1182
  const currentPageSize = table.getState().pagination.pageSize
487
1183
  const currentPageIndex = table.getState().pagination.pageIndex
488
1184
  const rangeStart = currentPageIndex * currentPageSize + 1
489
1185
  const rangeEnd = Math.min((currentPageIndex + 1) * currentPageSize, totalRows)
1186
+ // One list feeds both the menu and `items`, so the trigger shows "10 / page" (ja: "10件")
1187
+ // rather than the raw "10" under Base UI's SelectValue.
1188
+ const pageSizeItems = pageSizeOptions.map((size) => ({
1189
+ value: String(size),
1190
+ label: t.pageSize(size),
1191
+ }))
490
1192
 
491
1193
  return (
492
1194
  <div
493
1195
  ref={ref}
494
1196
  data-slot="data-table-pagination"
1197
+ aria-busy={isLoading || undefined}
495
1198
  className={cn("flex flex-wrap items-center justify-between gap-4", className)}
496
1199
  {...props}
497
1200
  >
498
1201
  <div className="flex items-center gap-2 text-sm text-muted-foreground">
499
- <span>
500
- {totalRows}件中 {rangeStart} - {rangeEnd} を表示
501
- </span>
1202
+ <span>{t.range(totalRows, rangeStart, rangeEnd)}</span>
502
1203
  <Select
1204
+ items={pageSizeItems}
503
1205
  value={String(currentPageSize)}
504
1206
  onValueChange={(value) => table.setPageSize(Number(value))}
505
1207
  >
506
- <SelectTrigger size="sm" className="w-[80px]">
1208
+ <SelectTrigger size="sm" className="min-w-[80px]">
507
1209
  <SelectValue />
508
1210
  </SelectTrigger>
509
1211
  <SelectContent>
510
- {pageSizeOptions.map((size) => (
511
- <SelectItem key={size} value={String(size)}>
512
- {size}件
1212
+ {pageSizeItems.map((item) => (
1213
+ <SelectItem key={item.value} value={item.value}>
1214
+ {item.label}
513
1215
  </SelectItem>
514
1216
  ))}
515
1217
  </SelectContent>
@@ -521,33 +1223,33 @@ export const DataTablePagination = forwardRef<HTMLDivElement, DataTablePaginatio
521
1223
  variant="outline"
522
1224
  size="sm"
523
1225
  onClick={() => table.firstPage()}
524
- disabled={!table.getCanPreviousPage()}
1226
+ disabled={isLoading || !table.getCanPreviousPage()}
525
1227
  >
526
- 最初
1228
+ {t.first}
527
1229
  </Button>
528
1230
  <Button
529
1231
  variant="outline"
530
1232
  size="sm"
531
1233
  onClick={() => table.previousPage()}
532
- disabled={!table.getCanPreviousPage()}
1234
+ disabled={isLoading || !table.getCanPreviousPage()}
533
1235
  >
534
- 前へ
1236
+ {t.previous}
535
1237
  </Button>
536
1238
  <Button
537
1239
  variant="outline"
538
1240
  size="sm"
539
1241
  onClick={() => table.nextPage()}
540
- disabled={!table.getCanNextPage()}
1242
+ disabled={isLoading || !table.getCanNextPage()}
541
1243
  >
542
- 次へ
1244
+ {t.next}
543
1245
  </Button>
544
1246
  <Button
545
1247
  variant="outline"
546
1248
  size="sm"
547
1249
  onClick={() => table.lastPage()}
548
- disabled={!table.getCanNextPage()}
1250
+ disabled={isLoading || !table.getCanNextPage()}
549
1251
  >
550
- 最後
1252
+ {t.last}
551
1253
  </Button>
552
1254
  </div>
553
1255
  </div>
@@ -563,6 +1265,7 @@ export const DataTableColumnVisibility = forwardRef<
563
1265
  DataTableColumnVisibilityProps
564
1266
  >(({ columnLabels, className }, ref) => {
565
1267
  const { table } = useDataTable()
1268
+ const t = useText()
566
1269
 
567
1270
  const hideableColumns = table.getAllColumns().filter((column) => column.getCanHide())
568
1271
 
@@ -574,8 +1277,9 @@ export const DataTableColumnVisibility = forwardRef<
574
1277
  ref={ref}
575
1278
  render={<Button variant="outline" size="sm" className={cn(className)} />}
576
1279
  data-slot="data-table-column-visibility"
1280
+ aria-label={t.columns}
577
1281
  >
578
- <Columns3 className="size-4" />
1282
+ <Columns3 className="size-4" aria-hidden="true" />
579
1283
  </DropdownMenuTrigger>
580
1284
  <DropdownMenuContent align="end" className="min-w-[10rem]">
581
1285
  {hideableColumns.map((column) => {
@@ -598,20 +1302,130 @@ export const DataTableColumnVisibility = forwardRef<
598
1302
  })
599
1303
  DataTableColumnVisibility.displayName = "DataTableColumnVisibility"
600
1304
 
1305
+ /* ------------------------------------------------------------------ export */
1306
+
1307
+ export type CsvCell = string | number | null | undefined
1308
+ export type CsvValues = "display" | "raw"
1309
+ export type CsvScope = "filtered" | "page"
1310
+
1311
+ // A signed number with an optional short currency / unit ("-¥1,200", "+530万", "-12.5%").
1312
+ const SIGNED_NUMBER = /^[+-]\s?[^\d\s=+\-@]{0,3}\d[\d,.]*[^\d\s=+\-@]{0,4}$/
1313
+
1314
+ /**
1315
+ * CSV 文字列を作る純関数(RFC 4180: CRLF、`,` `"` 改行を含む欄は `"` で囲み `"` は二重化)。
1316
+ * 既定で BOM 付き UTF-8(Excel で文字化けしない)。`guardFormulas` が true の間、先頭が
1317
+ * `= + - @` の文字列欄には `'` を付けて数式として実行されないようにする(数値と符号付きの数値表記は除く)。
1318
+ *
1319
+ * @example
1320
+ * toCsv(["月", "売上"], [["2025-01", 1200], ["2025-02", null]])
1321
+ * // "\uFEFF月,売上\r\n2025-01,1200\r\n2025-02,"
1322
+ */
1323
+ export function toCsv(
1324
+ header: string[],
1325
+ rows: CsvCell[][],
1326
+ options: { bom?: boolean; guardFormulas?: boolean } = {},
1327
+ ): string {
1328
+ const { bom = true, guardFormulas = false } = options
1329
+ const field = (cell: CsvCell, guard: boolean): string => {
1330
+ let text = cell === null || cell === undefined ? "" : String(cell)
1331
+ if (guard && typeof cell === "string" && /^[=+\-@]/.test(text) && !SIGNED_NUMBER.test(text)) {
1332
+ text = `'${text}`
1333
+ }
1334
+ return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
1335
+ }
1336
+ const lines = [
1337
+ header.map((h) => field(h, false)).join(","),
1338
+ ...rows.map((row) => row.map((cell) => field(cell, guardFormulas)).join(",")),
1339
+ ]
1340
+ return (bom ? "\uFEFF" : "") + lines.join("\r\n")
1341
+ }
1342
+
1343
+ /**
1344
+ * 表の現在の状態から CSV を作る。行は画面と同じ並べ替え・絞り込み後の順、列は表示中の列のみ。
1345
+ * `"display"` の書式のロケールは `locale`(既定はページのロケール、`<html lang>`)。
1346
+ *
1347
+ * @example
1348
+ * const csv = tableToCsv(table, { values: "raw", scope: "page" })
1349
+ */
1350
+ export function tableToCsv<TData>(
1351
+ table: TanstackTable<TData>,
1352
+ { values = "display", scope = "filtered", locale: localeArg }: { values?: CsvValues; scope?: CsvScope; locale?: string } = {},
1353
+ ): string {
1354
+ const locale = resolveLocale(localeArg)
1355
+ const columns = table.getVisibleLeafColumns().filter((column) => column.id !== "_select")
1356
+ const rows = scope === "page" ? table.getRowModel().rows : table.getSortedRowModel().rows
1357
+ const header = columns.map((column) =>
1358
+ typeof column.columnDef.header === "string" ? column.columnDef.header : column.id,
1359
+ )
1360
+ const body = rows.map((row) =>
1361
+ columns.map((column): CsvCell => {
1362
+ const value = row.getValue(column.id)
1363
+ const meta = column.columnDef.meta
1364
+ if (values === "display" && (meta?.format || meta?.dateFormat)) return formatCellText(value, row.original, meta, locale)
1365
+ if (value === null || value === undefined) return ""
1366
+ if (typeof value === "number" || typeof value === "string") return value
1367
+ if (value instanceof Date) return value.toISOString()
1368
+ return String(value)
1369
+ }),
1370
+ )
1371
+ return toCsv(header, body, { guardFormulas: values === "display" })
1372
+ }
1373
+
1374
+ /** toolbar に置く CSV 書き出しボタン。`useDataTable()` の table から画面と同じ順・同じ集合を書き出す。 */
1375
+ export function DataTableExport({
1376
+ fileName = "export.csv",
1377
+ values = "display",
1378
+ scope = "filtered",
1379
+ children,
1380
+ className,
1381
+ }: DataTableExportProps) {
1382
+ const { table, isLoading } = useDataTable()
1383
+ const locale = useLocale()
1384
+
1385
+ const onClick = () => {
1386
+ const csv = tableToCsv(table, { values, scope, locale })
1387
+ const url = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" }))
1388
+ const a = document.createElement("a")
1389
+ a.href = url
1390
+ a.download = fileName
1391
+ a.click()
1392
+ URL.revokeObjectURL(url)
1393
+ }
1394
+
1395
+ return (
1396
+ <Button
1397
+ type="button"
1398
+ variant="outline"
1399
+ size="sm"
1400
+ className={className}
1401
+ data-slot="data-table-export"
1402
+ onClick={onClick}
1403
+ disabled={isLoading}
1404
+ >
1405
+ <Download className="size-4" aria-hidden="true" />
1406
+ {children ?? TEXT[uiLanguage(locale)].exportCsv}
1407
+ </Button>
1408
+ )
1409
+ }
1410
+ DataTableExport.displayName = "DataTableExport"
1411
+
601
1412
  /* ------------------------------------------------------------------ preset */
602
1413
 
603
1414
  /** The usual arrangement — toolbar, body and pagination — in one component. */
604
1415
  export function DataTablePreset<TData, TValue>({
605
1416
  enableColumnVisibility = false,
606
1417
  columnLabels,
1418
+ exportable = false,
1419
+ exportFileName,
607
1420
  toolbar,
608
1421
  bulkActions,
609
1422
  ...tableProps
610
1423
  }: DataTablePresetProps<TData, TValue>) {
611
1424
  const toolbarSlot =
612
- toolbar || enableColumnVisibility ? (
1425
+ toolbar || enableColumnVisibility || exportable ? (
613
1426
  <>
614
1427
  {toolbar}
1428
+ {exportable && <DataTableExport fileName={exportFileName} />}
615
1429
  {enableColumnVisibility && <DataTableColumnVisibility columnLabels={columnLabels} />}
616
1430
  </>
617
1431
  ) : undefined