@squadbase/vantage 0.6.2-dev1.ac99e02 → 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 (141) hide show
  1. package/dist/{build-MFWRPYKW.js → build-X5NVCQKT.js} +4 -4
  2. package/dist/{build-MFWRPYKW.js.map → build-X5NVCQKT.js.map} +1 -1
  3. package/dist/{chunk-IBGOCWJB.js → chunk-2ICNHUG6.js} +186 -82
  4. package/dist/chunk-2ICNHUG6.js.map +1 -0
  5. package/dist/{chunk-5ZOP4SQR.js → chunk-2N72FKLB.js} +12 -7
  6. package/dist/chunk-2N72FKLB.js.map +1 -0
  7. package/dist/{chunk-PXRX5PEC.js → chunk-PDOBHMUQ.js} +3 -3
  8. package/dist/chunk-PDOBHMUQ.js.map +1 -0
  9. package/dist/{chunk-A2UUGASH.js → chunk-U736PVHY.js} +2 -2
  10. package/dist/chunk-U736PVHY.js.map +1 -0
  11. package/dist/{chunk-QEWCYW3I.js → chunk-WAMZWZJB.js} +13 -3
  12. package/dist/chunk-WAMZWZJB.js.map +1 -0
  13. package/dist/chunk-WRSX2MDK.js +7866 -0
  14. package/dist/chunk-WRSX2MDK.js.map +1 -0
  15. package/dist/{chunk-OB6UBJXF.js → chunk-XPQSTZUU.js} +125 -35
  16. package/dist/chunk-XPQSTZUU.js.map +1 -0
  17. package/dist/cli.js +3 -3
  18. package/dist/components/index.d.ts +1831 -99
  19. package/dist/components/index.js +4 -4
  20. package/dist/{dev-DCTOHQ6W.js → dev-4XEDTELV.js} +4 -4
  21. package/dist/{dev-DCTOHQ6W.js.map → dev-4XEDTELV.js.map} +1 -1
  22. package/dist/doc/index.js +2 -2
  23. package/dist/doc/index.js.map +1 -1
  24. package/dist/markdown/index.js +1 -1
  25. package/dist/markdown/index.js.map +1 -1
  26. package/dist/table/index.js +5 -5
  27. package/dist/table/index.js.map +1 -1
  28. package/dist/ui/index.d.ts +4 -1
  29. package/dist/ui/index.js +21 -111
  30. package/dist/ui/index.js.map +1 -1
  31. package/dist/{upgrade-DBBE6GIR.js → upgrade-JHXZBGQS.js} +3 -3
  32. package/dist/{upgrade-DBBE6GIR.js.map → upgrade-JHXZBGQS.js.map} +1 -1
  33. package/dist/vite/index.js +2 -2
  34. package/docs/en/components.md +29 -15
  35. package/docs/en/parts/data-table.md +22 -0
  36. package/docs/en/parts/delta.md +50 -0
  37. package/docs/en/parts/format.md +43 -0
  38. package/docs/en/parts/kpi-card.md +57 -0
  39. package/docs/en/parts/metric-value.md +3 -2
  40. package/docs/en/parts/sparkline.md +1 -1
  41. package/docs/en/parts/trend-indicator.md +17 -1
  42. package/docs/en/ui-and-theming.md +1 -1
  43. package/docs/index.json +92 -32
  44. package/docs/ja/changelog.md +5 -0
  45. package/docs/ja/components.md +27 -15
  46. package/docs/ja/parts/data-table.md +22 -0
  47. package/docs/ja/parts/delta.md +50 -0
  48. package/docs/ja/parts/format.md +44 -0
  49. package/docs/ja/parts/kpi-card.md +55 -0
  50. package/docs/ja/parts/metric-value.md +3 -2
  51. package/docs/ja/parts/sparkline.md +1 -1
  52. package/docs/ja/parts/trend-indicator.md +17 -1
  53. package/docs/ja/ui-and-theming.md +2 -2
  54. package/package.json +2 -1
  55. package/registry/ui/accordion.tsx +4 -2
  56. package/registry/ui/alert-dialog.tsx +3 -3
  57. package/registry/ui/alert.tsx +3 -1
  58. package/registry/ui/app-shell.tsx +7 -1
  59. package/registry/ui/area-chart.tsx +2781 -157
  60. package/registry/ui/avatar.tsx +1 -1
  61. package/registry/ui/badge.tsx +3 -1
  62. package/registry/ui/bar-chart.tsx +2780 -157
  63. package/registry/ui/bar-list.tsx +3471 -93
  64. package/registry/ui/breadcrumb.tsx +3 -1
  65. package/registry/ui/button.tsx +3 -1
  66. package/registry/ui/calendar.tsx +3 -1
  67. package/registry/ui/card.tsx +4 -2
  68. package/registry/ui/category-bar.tsx +990 -16
  69. package/registry/ui/chart-frame.tsx +1358 -0
  70. package/registry/ui/checkbox.tsx +3 -1
  71. package/registry/ui/collapsible.tsx +3 -1
  72. package/registry/ui/combobox.tsx +2 -2
  73. package/registry/ui/command.tsx +3 -1
  74. package/registry/ui/dashboard-card.tsx +69 -26
  75. package/registry/ui/data-table.tsx +678 -56
  76. package/registry/ui/date-range-picker.tsx +245 -64
  77. package/registry/ui/delta.tsx +188 -0
  78. package/registry/ui/dialog.tsx +5 -3
  79. package/registry/ui/donut-chart.tsx +2857 -173
  80. package/registry/ui/drawer.tsx +1 -1
  81. package/registry/ui/dropdown-menu.tsx +5 -3
  82. package/registry/ui/echart.tsx +448 -44
  83. package/registry/ui/field.tsx +1 -1
  84. package/registry/ui/filter-bar.tsx +33 -6
  85. package/registry/ui/funnel-steps.tsx +3316 -49
  86. package/registry/ui/heatmap.tsx +1752 -0
  87. package/registry/ui/hover-card.tsx +2 -2
  88. package/registry/ui/input-group.tsx +3 -1
  89. package/registry/ui/input.tsx +3 -1
  90. package/registry/ui/kbd.tsx +3 -1
  91. package/registry/ui/kpi-card.tsx +677 -0
  92. package/registry/ui/label.tsx +3 -1
  93. package/registry/ui/legend.tsx +3500 -0
  94. package/registry/ui/line-chart.tsx +2781 -151
  95. package/registry/ui/metric-value.tsx +9 -1
  96. package/registry/ui/multi-select.tsx +11 -5
  97. package/registry/ui/page-shell.tsx +104 -49
  98. package/registry/ui/pagination.tsx +3 -1
  99. package/registry/ui/placeholder.tsx +8 -2
  100. package/registry/ui/popover.tsx +4 -2
  101. package/registry/ui/progress-circle.tsx +607 -12
  102. package/registry/ui/progress.tsx +3 -1
  103. package/registry/ui/radio-group.tsx +1 -1
  104. package/registry/ui/sample-data.tsx +86 -0
  105. package/registry/ui/scatter-chart.tsx +4150 -0
  106. package/registry/ui/scroll-area.tsx +3 -2
  107. package/registry/ui/searchable-select.tsx +8 -3
  108. package/registry/ui/section-header.tsx +4 -2
  109. package/registry/ui/segmented-control.tsx +7 -2
  110. package/registry/ui/select.tsx +4 -2
  111. package/registry/ui/separator.tsx +3 -1
  112. package/registry/ui/sheet.tsx +4 -2
  113. package/registry/ui/sidebar.tsx +4 -2
  114. package/registry/ui/skeleton.tsx +4 -2
  115. package/registry/ui/slider.tsx +3 -1
  116. package/registry/ui/sparkline.tsx +84 -9
  117. package/registry/ui/spinner.tsx +3 -1
  118. package/registry/ui/states.tsx +34 -5
  119. package/registry/ui/status-badge.tsx +81 -39
  120. package/registry/ui/switch.tsx +3 -1
  121. package/registry/ui/table.tsx +4 -2
  122. package/registry/ui/tabs.tsx +5 -3
  123. package/registry/ui/textarea.tsx +3 -1
  124. package/registry/ui/toast.tsx +2 -2
  125. package/registry/ui/toggle-group.tsx +3 -1
  126. package/registry/ui/toggle.tsx +3 -1
  127. package/registry/ui/tooltip.tsx +4 -2
  128. package/registry/ui/tracker.tsx +624 -17
  129. package/registry/ui/trend-indicator.tsx +13 -1
  130. package/registry/ui/updated-at.tsx +122 -0
  131. package/registry/ui/waterfall-chart.tsx +4186 -0
  132. package/templates/AGENTS.md +13 -1
  133. package/theme.css +237 -136
  134. package/dist/chunk-5ZOP4SQR.js.map +0 -1
  135. package/dist/chunk-A2UUGASH.js.map +0 -1
  136. package/dist/chunk-AG6RZ2RR.js +0 -3208
  137. package/dist/chunk-AG6RZ2RR.js.map +0 -1
  138. package/dist/chunk-IBGOCWJB.js.map +0 -1
  139. package/dist/chunk-OB6UBJXF.js.map +0 -1
  140. package/dist/chunk-PXRX5PEC.js.map +0 -1
  141. package/dist/chunk-QEWCYW3I.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 `npm 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,157 @@ 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"
55
78
 
56
79
  /* ------------------------------------------------------------------- types */
57
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
+
58
226
  interface DataTableContextValue<TData> {
59
227
  table: TanstackTable<TData>
60
228
  selectedRows: TData[]
@@ -65,6 +233,7 @@ interface DataTableContextValue<TData> {
65
233
  enablePagination: boolean
66
234
  enableRowSelection: boolean
67
235
  visibleColumnCount: number
236
+ isLoading: boolean
68
237
  }
69
238
 
70
239
  /**
@@ -80,7 +249,8 @@ export interface DataTableProps<TData, TValue> {
80
249
  * literal が `string` に拡張され型エラーになる。
81
250
  *
82
251
  * @example
83
- * 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"
84
254
  *
85
255
  * const columns: ColumnDef<User>[] = [
86
256
  * { accessorKey: "id", header: "ID" },
@@ -107,7 +277,42 @@ export interface DataTableProps<TData, TValue> {
107
277
  enablePagination?: boolean
108
278
  pagination?: PaginationState
109
279
  onPaginationChange?: OnChangeFn<PaginationState>
280
+ /**
281
+ * サーバー側ページング時の総ページ数。指定すると manualPagination になる。
282
+ *
283
+ * @example
284
+ * <DataTable pageCount={data.pageCount} … />
285
+ */
110
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
111
316
 
112
317
  // Row selection
113
318
  enableRowSelection?: boolean
@@ -118,6 +323,13 @@ export interface DataTableProps<TData, TValue> {
118
323
  columnVisibility?: VisibilityState
119
324
  onColumnVisibilityChange?: OnChangeFn<VisibilityState>
120
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
+ */
121
333
  meta?: TableMeta<TData>
122
334
  className?: string
123
335
 
@@ -133,6 +345,33 @@ export interface DataTableToolbarProps extends HTMLAttributes<HTMLDivElement> {
133
345
 
134
346
  export interface DataTableContentProps extends HTMLAttributes<HTMLDivElement> {}
135
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
+
136
375
  export interface DataTablePaginationProps extends HTMLAttributes<HTMLDivElement> {
137
376
  pageSizeOptions?: number[]
138
377
  }
@@ -150,6 +389,20 @@ export type DataTablePresetProps<TData, TValue> = Omit<
150
389
  > & {
151
390
  enableColumnVisibility?: boolean
152
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
153
406
 
154
407
  /** Extension slots */
155
408
  toolbar?: ReactNode
@@ -194,9 +447,12 @@ export function useDataTable<TData>(): DataTableContextValue<TData> {
194
447
 
195
448
  /* -------------------------------------------------------------------- root */
196
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
+
197
453
  export function DataTable<TData, TValue>({
198
- columns,
199
- data,
454
+ columns = NO_ROWS,
455
+ data = NO_ROWS,
200
456
  enableSorting = true,
201
457
  sorting: sortingProp,
202
458
  onSortingChange,
@@ -209,6 +465,10 @@ export function DataTable<TData, TValue>({
209
465
  pagination: paginationProp,
210
466
  onPaginationChange,
211
467
  pageCount,
468
+ rowCount,
469
+ manualFiltering,
470
+ manualSorting,
471
+ isLoading = false,
212
472
  enableRowSelection = false,
213
473
  rowSelection: rowSelectionProp,
214
474
  onRowSelectionChange,
@@ -252,7 +512,29 @@ export function DataTable<TData, TValue>({
252
512
 
253
513
  // Row selection prepends a checkbox column that the caller never declares.
254
514
  const allColumns = useMemo<ColumnDef<TData, TValue>[]>(() => {
255
- 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
256
538
 
257
539
  const selectionColumn: ColumnDef<TData, TValue> = {
258
540
  id: "_select",
@@ -275,10 +557,26 @@ export function DataTable<TData, TValue>({
275
557
  enableHiding: false,
276
558
  }
277
559
 
278
- return [selectionColumn, ...columns]
560
+ return [selectionColumn, ...formatted]
279
561
  }, [columns, enableRowSelection])
280
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
+
281
578
  const table = useReactTable({
579
+ autoResetPageIndex: false,
282
580
  data,
283
581
  columns: allColumns,
284
582
  state: {
@@ -297,7 +595,11 @@ export function DataTable<TData, TValue>({
297
595
  onRowSelectionChange: setRowSelection,
298
596
  onColumnVisibilityChange: setColumnVisibility,
299
597
  ...(enablePagination ? { onPaginationChange: setPagination } : {}),
300
- ...(pageCount !== undefined ? { pageCount, manualPagination: true } : {}),
598
+ ...(pageCount !== undefined || rowCount !== undefined
599
+ ? { pageCount, rowCount, manualPagination: true }
600
+ : {}),
601
+ ...(manualFiltering ? { manualFiltering: true } : {}),
602
+ ...(manualSorting ? { manualSorting: true } : {}),
301
603
  getCoreRowModel: getCoreRowModel(),
302
604
  getSortedRowModel: getSortedRowModel(),
303
605
  getFilteredRowModel: getFilteredRowModel(),
@@ -325,6 +627,7 @@ export function DataTable<TData, TValue>({
325
627
  enablePagination,
326
628
  enableRowSelection,
327
629
  visibleColumnCount: allColumns.length,
630
+ isLoading,
328
631
  }),
329
632
  // `useReactTable` returns the same object every render, so each piece of
330
633
  // table state has to be tracked explicitly to re-render the consumers.
@@ -339,6 +642,8 @@ export function DataTable<TData, TValue>({
339
642
  enablePagination,
340
643
  enableRowSelection,
341
644
  allColumns.length,
645
+ isLoading,
646
+ rowCount,
342
647
  sorting,
343
648
  columnFilters,
344
649
  columnVisibility,
@@ -349,7 +654,7 @@ export function DataTable<TData, TValue>({
349
654
 
350
655
  return (
351
656
  <DataTableContext.Provider value={contextValue as DataTableContextValue<unknown>}>
352
- <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)}>
353
658
  {children}
354
659
  </div>
355
660
  </DataTableContext.Provider>
@@ -357,6 +662,68 @@ export function DataTable<TData, TValue>({
357
662
  }
358
663
  DataTable.displayName = "DataTable"
359
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
+
360
727
  /* ----------------------------------------------------------------- toolbar */
361
728
 
362
729
  export const DataTableToolbar = forwardRef<HTMLDivElement, DataTableToolbarProps>(
@@ -399,63 +766,199 @@ DataTableToolbar.displayName = "DataTableToolbar"
399
766
 
400
767
  /* ----------------------------------------------------------------- content */
401
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
+
402
853
  export const DataTableContent = forwardRef<HTMLDivElement, DataTableContentProps>(
403
854
  ({ className, ...props }, ref) => {
404
- const { table, visibleColumnCount } = useDataTable()
855
+ const { table, visibleColumnCount, isLoading, enablePagination } = useDataTable()
856
+ const { comparisonOf, comparisonIds } = pairComparisons(table.getVisibleLeafColumns())
405
857
 
406
858
  return (
407
859
  <div
408
860
  ref={ref}
409
861
  data-slot="data-table-content"
410
- className={cn("overflow-x-auto", className)}
862
+ aria-busy={isLoading || undefined}
863
+ className={cn("@container overflow-x-auto", className)}
411
864
  {...props}
412
865
  >
413
- <Table>
866
+ <Table className="[&_th:first-child]:pl-0 [&_td:first-child]:pl-0 [&_th:last-child]:pr-0 [&_td:last-child]:pr-0">
414
867
  <TableHeader>
415
868
  {table.getHeaderGroups().map((headerGroup) => (
416
869
  <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"
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"
426
886
  ? "ascending"
427
- : header.column.getIsSorted() === "desc"
887
+ : sorted === "desc"
428
888
  ? "descending"
429
889
  : "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
- ))}
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
+ })}
446
912
  </TableRow>
447
913
  ))}
448
914
  </TableHeader>
449
915
 
450
916
  <TableBody>
451
- {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 ? (
452
930
  table.getRowModel().rows.map((row) => (
453
931
  <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
- ))}
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
+ })}
459
962
  </TableRow>
460
963
  ))
461
964
  ) : (
@@ -480,18 +983,28 @@ DataTableContent.displayName = "DataTableContent"
480
983
 
481
984
  export const DataTablePagination = forwardRef<HTMLDivElement, DataTablePaginationProps>(
482
985
  ({ pageSizeOptions = [10, 20, 50], className, ...props }, ref) => {
483
- const { table } = useDataTable()
986
+ const { table, isLoading } = useDataTable()
484
987
 
485
- const totalRows = table.getFilteredRowModel().rows.length
988
+ const totalRows =
989
+ table.options.rowCount !== undefined
990
+ ? table.getRowCount()
991
+ : table.getFilteredRowModel().rows.length
486
992
  const currentPageSize = table.getState().pagination.pageSize
487
993
  const currentPageIndex = table.getState().pagination.pageIndex
488
994
  const rangeStart = currentPageIndex * currentPageSize + 1
489
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
+ }))
490
1002
 
491
1003
  return (
492
1004
  <div
493
1005
  ref={ref}
494
1006
  data-slot="data-table-pagination"
1007
+ aria-busy={isLoading || undefined}
495
1008
  className={cn("flex flex-wrap items-center justify-between gap-4", className)}
496
1009
  {...props}
497
1010
  >
@@ -500,6 +1013,7 @@ export const DataTablePagination = forwardRef<HTMLDivElement, DataTablePaginatio
500
1013
  {totalRows}件中 {rangeStart} - {rangeEnd} を表示
501
1014
  </span>
502
1015
  <Select
1016
+ items={pageSizeItems}
503
1017
  value={String(currentPageSize)}
504
1018
  onValueChange={(value) => table.setPageSize(Number(value))}
505
1019
  >
@@ -507,9 +1021,9 @@ export const DataTablePagination = forwardRef<HTMLDivElement, DataTablePaginatio
507
1021
  <SelectValue />
508
1022
  </SelectTrigger>
509
1023
  <SelectContent>
510
- {pageSizeOptions.map((size) => (
511
- <SelectItem key={size} value={String(size)}>
512
- {size}件
1024
+ {pageSizeItems.map((item) => (
1025
+ <SelectItem key={item.value} value={item.value}>
1026
+ {item.label}
513
1027
  </SelectItem>
514
1028
  ))}
515
1029
  </SelectContent>
@@ -521,7 +1035,7 @@ export const DataTablePagination = forwardRef<HTMLDivElement, DataTablePaginatio
521
1035
  variant="outline"
522
1036
  size="sm"
523
1037
  onClick={() => table.firstPage()}
524
- disabled={!table.getCanPreviousPage()}
1038
+ disabled={isLoading || !table.getCanPreviousPage()}
525
1039
  >
526
1040
  最初
527
1041
  </Button>
@@ -529,7 +1043,7 @@ export const DataTablePagination = forwardRef<HTMLDivElement, DataTablePaginatio
529
1043
  variant="outline"
530
1044
  size="sm"
531
1045
  onClick={() => table.previousPage()}
532
- disabled={!table.getCanPreviousPage()}
1046
+ disabled={isLoading || !table.getCanPreviousPage()}
533
1047
  >
534
1048
  前へ
535
1049
  </Button>
@@ -537,7 +1051,7 @@ export const DataTablePagination = forwardRef<HTMLDivElement, DataTablePaginatio
537
1051
  variant="outline"
538
1052
  size="sm"
539
1053
  onClick={() => table.nextPage()}
540
- disabled={!table.getCanNextPage()}
1054
+ disabled={isLoading || !table.getCanNextPage()}
541
1055
  >
542
1056
  次へ
543
1057
  </Button>
@@ -545,7 +1059,7 @@ export const DataTablePagination = forwardRef<HTMLDivElement, DataTablePaginatio
545
1059
  variant="outline"
546
1060
  size="sm"
547
1061
  onClick={() => table.lastPage()}
548
- disabled={!table.getCanNextPage()}
1062
+ disabled={isLoading || !table.getCanNextPage()}
549
1063
  >
550
1064
  最後
551
1065
  </Button>
@@ -574,8 +1088,9 @@ export const DataTableColumnVisibility = forwardRef<
574
1088
  ref={ref}
575
1089
  render={<Button variant="outline" size="sm" className={cn(className)} />}
576
1090
  data-slot="data-table-column-visibility"
1091
+ aria-label="表示する列"
577
1092
  >
578
- <Columns3 className="size-4" />
1093
+ <Columns3 className="size-4" aria-hidden="true" />
579
1094
  </DropdownMenuTrigger>
580
1095
  <DropdownMenuContent align="end" className="min-w-[10rem]">
581
1096
  {hideableColumns.map((column) => {
@@ -598,20 +1113,127 @@ export const DataTableColumnVisibility = forwardRef<
598
1113
  })
599
1114
  DataTableColumnVisibility.displayName = "DataTableColumnVisibility"
600
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
+
601
1220
  /* ------------------------------------------------------------------ preset */
602
1221
 
603
1222
  /** The usual arrangement — toolbar, body and pagination — in one component. */
604
1223
  export function DataTablePreset<TData, TValue>({
605
1224
  enableColumnVisibility = false,
606
1225
  columnLabels,
1226
+ exportable = false,
1227
+ exportFileName,
607
1228
  toolbar,
608
1229
  bulkActions,
609
1230
  ...tableProps
610
1231
  }: DataTablePresetProps<TData, TValue>) {
611
1232
  const toolbarSlot =
612
- toolbar || enableColumnVisibility ? (
1233
+ toolbar || enableColumnVisibility || exportable ? (
613
1234
  <>
614
1235
  {toolbar}
1236
+ {exportable && <DataTableExport fileName={exportFileName} />}
615
1237
  {enableColumnVisibility && <DataTableColumnVisibility columnLabels={columnLabels} />}
616
1238
  </>
617
1239
  ) : undefined